6. 직접 만들기: 회의록 정리 Agent

안 깨지게 테스트로 못 박기

이 차시를 마치면

안전 장치가 계속 살아 있게 테스트 28개로 고정합니다.

코드를 고친 뒤에도 안전 장치가 살아 있는지 확인하는 테스트 28개를 씁니다.

왜 테스트를 쓰는가

앞 차시에서 Agent 가 돌아가는 것을 봤습니다. 그런데 이런 일이 생깁니다.

한 달 뒤, 실행항목 형식을 바꾸려고 render_todo 를 고친다
   ↓
그 김에 write_file 도 조금 손댄다
   ↓
approved 검사를 실수로 지운다
   ↓
아무도 모른다. 화면상 잘 돌기 때문이다

테스트는 그 순간 알려 주는 장치입니다. 사람의 기억에 기대지 않는 방법입니다.

아래 테스트 28개는 2026-08-15 에 모두 통과했습니다. 쓰기 도구 11개, 확정 로직 9개, 그래프 8개입니다. 로컬 모델이 꺼져 있어도 돌아갑니다. 모델을 부르는 자리를 가짜 함수(fake_extractor)로 바꿔 넣기 때문입니다. 네트워크를 아예 끊고(--network none) 돌려도 28개가 모두 통과하는 것을 확인했습니다.

세 파일로 나눕니다

파일개수무엇을 고정하는가langgraph 필요
test_sec_write_tools.py11경로 차단 · 백업 · 백업 폴더 보호 · 되돌리기불필요
test_notes_plan.py9문서 생성의 안정성 · 키 파생 · 차이불필요
test_notes_graph.py8세 갈래 분기 · 승인 게이트 · 멱등성 · 감사 로그필요

앞의 두 파일은 langgraph 없이 돌아갑니다. 환경 준비가 끝나기 전에도 도구부터 확인할 수 있습니다.

1. 쓰기 도구 테스트

test_sec_write_tools.py 전문입니다.

"""쓰기 도구 검증.

읽기 도구와 같은 경로 규칙을 쓰되, 덮어쓰기 전에 반드시 백업한다.
langgraph 없이 실행된다.
"""
import shutil
import unittest

import sec_write_tools
from sec_tools import ALLOWED_ROOT, ToolPermissionError


class WriteToolTest(unittest.TestCase):
    def setUp(self):
        shutil.rmtree(ALLOWED_ROOT, ignore_errors=True)
        ALLOWED_ROOT.mkdir(parents=True, exist_ok=True)

    def tearDown(self):
        shutil.rmtree(ALLOWED_ROOT, ignore_errors=True)

    def test_creates_new_file_without_backup(self):
        result = sec_write_tools.write_workspace_file("actions/TODO.md", "첫 내용")
        self.assertEqual(result["written"], "actions/TODO.md")
        self.assertIsNone(result["backup"])
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"), "첫 내용"
        )

    def test_overwrite_makes_a_backup_first(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "옛 내용")
        result = sec_write_tools.write_workspace_file("actions/TODO.md", "새 내용")

        self.assertIsNotNone(result["backup"])
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"), "새 내용"
        )
        backup_path = sec_write_tools.BACKUP_DIR / result["backup"]
        self.assertEqual(backup_path.read_text(encoding="utf-8"), "옛 내용")

    def test_blocks_parent_directory_escape(self):
        with self.assertRaises(ToolPermissionError):
            sec_write_tools.write_workspace_file("../escaped.md", "나쁜 내용")

    def test_blocks_absolute_path(self):
        with self.assertRaises(ToolPermissionError):
            sec_write_tools.write_workspace_file("/tmp/escaped.md", "나쁜 내용")

    def test_blocks_writing_into_backup_folder(self):
        """백업 폴더를 덮어쓰면 되돌리기가 무력해진다."""
        with self.assertRaises(ToolPermissionError):
            sec_write_tools.write_workspace_file(".backup/fake.md", "위조")

    def test_lists_backups_newest_first(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "1")
        sec_write_tools.write_workspace_file("actions/TODO.md", "2")
        sec_write_tools.write_workspace_file("actions/TODO.md", "3")

        backups = sec_write_tools.list_backups("actions/TODO.md")
        self.assertEqual(len(backups), 2)
        self.assertEqual(backups, sorted(backups, reverse=True))

    def test_restores_from_backup(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "원본")
        result = sec_write_tools.write_workspace_file("actions/TODO.md", "덮어쓴 것")

        restored = sec_write_tools.restore_from_backup("actions/TODO.md", result["backup"])

        self.assertEqual(restored["restored"], "actions/TODO.md")
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"), "원본"
        )

    def test_restore_rejects_unknown_backup(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "원본")
        with self.assertRaises(ToolPermissionError):
            sec_write_tools.restore_from_backup("actions/TODO.md", "없는백업.md.20260815")

    def test_write_tools_are_separate_from_read_tools(self):
        """노출 목록이 섞이지 않아야 한다. 트랙 3 의 규칙."""
        import sec_tools

        read_names = {fn.__name__ for fn in sec_tools.READ_ONLY_TOOLS}
        write_names = {fn.__name__ for fn in sec_write_tools.WRITE_TOOLS}
        self.assertEqual(read_names & write_names, set())
        self.assertIn("write_workspace_file", write_names)


class RestoreScenarioTest(unittest.TestCase):
    """실수로 덮어쓴 뒤 되돌리는 상황을 그대로 재현한다."""

    def setUp(self):
        shutil.rmtree(ALLOWED_ROOT, ignore_errors=True)
        ALLOWED_ROOT.mkdir(parents=True, exist_ok=True)

    def tearDown(self):
        shutil.rmtree(ALLOWED_ROOT, ignore_errors=True)

    def test_restore_after_two_overwrites(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "1차\n")
        second = sec_write_tools.write_workspace_file("actions/TODO.md", "2차\n")
        sec_write_tools.write_workspace_file("actions/TODO.md", "3차\n")

        current = (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8")
        self.assertEqual(current, "3차\n")

        # 2차로 덮어쓸 때 남긴 백업에는 1차 내용이 들어 있다
        sec_write_tools.restore_from_backup("actions/TODO.md", second["backup"])
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"), "1차\n"
        )

    def test_restore_itself_makes_no_backup_loop(self):
        """되돌리기가 백업을 지우거나 덮어쓰지 않아야 한다."""
        sec_write_tools.write_workspace_file("actions/TODO.md", "원본\n")
        result = sec_write_tools.write_workspace_file("actions/TODO.md", "바뀐 것\n")
        before = set(sec_write_tools.list_backups("actions/TODO.md"))

        sec_write_tools.restore_from_backup("actions/TODO.md", result["backup"])
        after = set(sec_write_tools.list_backups("actions/TODO.md"))

        self.assertTrue(before.issubset(after))


if __name__ == "__main__":
    unittest.main(verbosity=2)

눈여겨볼 두 가지입니다.

  • test_blocks_writing_into_backup_folder백업 폴더를 지키는 테스트입니다. 이것이 깨지면 되돌리기 장치 자체가 무력해집니다
  • RestoreScenarioTest — 실수로 두 번 덮어쓴 뒤 되돌리는 상황을 그대로 재현합니다. 함수 하나가 아니라 실제로 겪는 흐름을 확인합니다

2. 확정 로직 테스트

test_notes_plan.py 전문입니다.

"""실행 대상 확정 로직 검증. langgraph 없이 실행된다."""
import unittest

from notes.notes_plan import make_action_id, make_diff, render_todo

ACTIONS = [
    {"owner": "김", "task": "매출 자료 정리", "due": "8월 20일"},
    {"owner": "이", "task": "채널 조사", "due": "8월 25일"},
]


class RenderTodoTest(unittest.TestCase):
    def test_renders_every_action(self):
        text = render_todo(ACTIONS)
        for action in ACTIONS:
            self.assertIn(action["owner"], text)
            self.assertIn(action["task"], text)
            self.assertIn(action["due"], text)

    def test_output_is_stable(self):
        """같은 입력이면 같은 문서가 나와야 멱등성 키가 의미를 갖는다."""
        self.assertEqual(render_todo(ACTIONS), render_todo(ACTIONS))

    def test_empty_actions_still_makes_a_document(self):
        text = render_todo([])
        self.assertIn("실행항목", text)


class ActionIdTest(unittest.TestCase):
    def test_same_input_gives_same_id(self):
        first = make_action_id(["회의록 A"], "actions/TODO.md", "user-01")
        second = make_action_id(["회의록 A"], "actions/TODO.md", "user-01")
        self.assertEqual(first, second)

    def test_different_note_gives_different_id(self):
        first = make_action_id(["회의록 A"], "actions/TODO.md", "user-01")
        second = make_action_id(["회의록 B"], "actions/TODO.md", "user-01")
        self.assertNotEqual(first, second)

    def test_different_target_gives_different_id(self):
        first = make_action_id(["회의록 A"], "actions/TODO.md", "user-01")
        second = make_action_id(["회의록 A"], "actions/OTHER.md", "user-01")
        self.assertNotEqual(first, second)


class DiffTest(unittest.TestCase):
    def test_new_file_has_no_diff(self):
        self.assertEqual(make_diff("", "새 내용", "actions/TODO.md"), "")

    def test_shows_changed_lines(self):
        diff = make_diff("가\n나\n", "가\n다\n", "actions/TODO.md")
        self.assertIn("-나", diff)
        self.assertIn("+다", diff)

    def test_diff_is_a_plain_string(self):
        """interrupt payload 에 실리므로 직렬화 가능해야 한다."""
        import json

        diff = make_diff("가\n", "나\n", "actions/TODO.md")
        json.dumps({"diff": diff}, ensure_ascii=False)


if __name__ == "__main__":
    unittest.main(verbosity=2)

test_output_is_stable 이 왜 중요한가

같은 입력에 같은 문서가 나와야 멱등성 키가 의미를 갖습니다. 문서에 실행 시각 같은 것이 들어가면 매번 내용이 달라지고, 그러면 같은 회의록인데도 다른 작업으로 판정됩니다.

겉보기에 사소한 테스트가 실제로는 중복 실행 방어를 떠받치고 있습니다.

3. 그래프 테스트

test_notes_graph.py 전문입니다.

"""회의록 Agent 그래프 검증.

모델 호출은 가짜 함수로 바꿔 넣는다. 그래프의 판단과 승인 게이트만 본다.
실제 모델 연결은 run_demo.py 가 확인한다.
"""
import shutil
import unittest

from langgraph.types import Command

import sec_write_tools
from notes import notes_graph
from notes.notes_plan import render_todo
from sec_tools import ALLOWED_ROOT

FAKE_ACTIONS = [
    {"owner": "김", "task": "매출 자료 정리", "due": "8월 20일"},
    {"owner": "이", "task": "채널 조사", "due": "8월 25일"},
]


def fake_extractor(note_texts):
    return FAKE_ACTIONS


def base_input():
    return {
        "meeting_dir": "meetings",
        "target_path": "actions/TODO.md",
        "requester_id": "tester-01",
        "note_names": [],
        "note_texts": [],
        "actions": [],
        "pending_content": "",
        "diff": "",
        "target_exists": False,
        "risk_level": "low",
        "approved": False,
        "executed_action_id": None,
        "backup_name": None,
        "result": "",
        "audit_log": [],
    }


class NotesGraphTest(unittest.TestCase):
    def setUp(self):
        notes_graph._EXECUTED_ACTION_IDS.clear()
        shutil.rmtree(ALLOWED_ROOT, ignore_errors=True)
        (ALLOWED_ROOT / "meetings").mkdir(parents=True, exist_ok=True)
        (ALLOWED_ROOT / "meetings" / "0812.md").write_text(
            "# 8월 2주차\n- 김: 매출 자료 정리 8월 20일\n", encoding="utf-8"
        )
        self.graph = notes_graph.build_graph(extractor=fake_extractor)

    def tearDown(self):
        shutil.rmtree(ALLOWED_ROOT, ignore_errors=True)

    def test_new_file_is_written_without_approval(self):
        """새 파일 만들기는 저위험이다."""
        config = {"configurable": {"thread_id": "notes-new"}}
        state = self.graph.invoke(base_input(), config)

        self.assertNotIn("__interrupt__", state)
        self.assertEqual(state["risk_level"], "low")
        self.assertTrue(state["result"].startswith("작성 완료"))
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"),
            render_todo(FAKE_ACTIONS),
        )

    def test_overwrite_stops_for_approval(self):
        """기존 파일 덮어쓰기는 고위험이다."""
        sec_write_tools.write_workspace_file("actions/TODO.md", "예전 내용\n")

        config = {"configurable": {"thread_id": "notes-over"}}
        state = self.graph.invoke(base_input(), config)

        self.assertIn("__interrupt__", state, "덮어쓰기인데 멈추지 않았습니다")
        self.assertEqual(state["risk_level"], "high")
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"), "예전 내용\n"
        )

    def test_approval_payload_shows_the_diff_and_pending_content(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "예전 내용\n")
        config = {"configurable": {"thread_id": "notes-payload"}}
        paused = self.graph.invoke(base_input(), config)

        shown = paused["__interrupt__"][0].value
        self.assertIn("diff", shown)
        self.assertIn("pending_content", shown)
        self.assertEqual(shown["pending_content"], paused["pending_content"])
        self.assertIn("예전 내용", shown["diff"])

    def test_approve_writes_and_backs_up(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "예전 내용\n")
        config = {"configurable": {"thread_id": "notes-approve"}}
        self.graph.invoke(base_input(), config)
        done = self.graph.invoke(Command(resume="approve"), config)

        self.assertTrue(done["result"].startswith("작성 완료"))
        self.assertIsNotNone(done["backup_name"])
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"),
            render_todo(FAKE_ACTIONS),
        )
        backup = sec_write_tools.BACKUP_DIR / done["backup_name"]
        self.assertEqual(backup.read_text(encoding="utf-8"), "예전 내용\n")

    def test_reject_leaves_the_file_untouched(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "예전 내용\n")
        config = {"configurable": {"thread_id": "notes-reject"}}
        self.graph.invoke(base_input(), config)
        after = self.graph.invoke(Command(resume="reject"), config)

        self.assertFalse(after["approved"])
        self.assertEqual(
            (ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"), "예전 내용\n"
        )

    def test_same_input_executes_once(self):
        """같은 회의록으로 두 번 돌리면 두 번째는 막힌다.

        승인을 묻지 않고 바로 끝나야 한다. 할 일이 없는데
        사람에게 확인을 요구하면 시간만 쓴다.
        """
        first = self.graph.invoke(base_input(), {"configurable": {"thread_id": "idem-1"}})
        second = self.graph.invoke(base_input(), {"configurable": {"thread_id": "idem-2"}})

        self.assertTrue(first["result"].startswith("작성 완료"))
        self.assertNotIn("__interrupt__", second, "중복인데 승인을 물었습니다")
        self.assertEqual(second["result"], "이미 처리됨")
        self.assertEqual(len(notes_graph._EXECUTED_ACTION_IDS), 1)

    def test_audit_log_records_decision_and_write(self):
        sec_write_tools.write_workspace_file("actions/TODO.md", "예전 내용\n")
        config = {"configurable": {"thread_id": "notes-audit"}}
        self.graph.invoke(base_input(), config)
        done = self.graph.invoke(Command(resume="approve"), config)

        joined = " | ".join(done["audit_log"])
        self.assertIn("회의록 1건", joined)
        self.assertIn("실행항목 2건", joined)
        self.assertIn("사람 결정: approve", joined)
        self.assertIn("백업", joined)

    def test_registered_nodes(self):
        nodes = set(self.graph.get_graph().nodes)
        for name in ("collect", "extract", "plan", "skip", "auto_approve", "approval", "write"):
            self.assertIn(name, nodes, "%s 가 등록되지 않았습니다" % name)


if __name__ == "__main__":
    unittest.main(verbosity=2)

모델을 가짜로 바꿔 넣는 부분

def fake_extractor(note_texts):
    return FAKE_ACTIONS


self.graph = notes_graph.build_graph(extractor=fake_extractor)

6차시에서 설명한 대로입니다. 이 테스트가 보려는 것은 그래프의 판단이지 모델의 품질이 아닙니다. 확인 대상을 나눈 것입니다.

실행

cd webapp/verify
docker run --rm -v "$PWD":/work -w /work llmwiki-verify \
  python -m unittest discover -p "test_*.py" -v

langgraph 없이 도구만 확인하려면 이렇게 합니다.

python3 -m unittest test_sec_write_tools test_notes_plan -v

세 개는 절대 지우지 마십시오

코드를 고친 뒤 테스트가 실패하면 테스트를 고치고 싶은 유혹이 생깁니다. 3번 트랙의 C-6 이 그것을 금지합니다.

테스트깨졌다는 것은
test_reject_leaves_the_file_untouched승인 게이트가 뚫렸습니다. 반려했는데 파일이 바뀌었습니다
test_approve_writes_and_backs_up백업 없이 덮어썼습니다. 되돌릴 수 없습니다
test_same_input_executes_once중복 방어가 깨졌습니다. 같은 작업이 두 번 수행될 수 있습니다

이 셋이 실패하면 원인을 고칩니다. 테스트를 고치지 않습니다.

여기까지 오면

빈 폴더에서 시작해 파일을 만들고 고치는 Agent 를 만들고, 그 성질을 테스트로 고정했습니다.

이제 코드를 고쳐도 됩니다. 무엇이 깨지는지 알려 줄 장치가 있기 때문입니다.

이해도 확인

문항을 불러오는 중입니다.