6. 직접 만들기: 회의록 정리 Agent
실제로 돌려 보기
이 차시를 마치면
빈 폴더에서 세 경우를 실제로 돌려 봅니다.
빈 폴더에서 시작해 세 번 돌립니다. 아래는 실제 실행 결과입니다.
아래 출력은 2026-08-15 에 빈 폴더에서 시작해 실제로 세 번 돌려 받은 것입니다. 이 차시는 로컬 모델을 부르므로 5번 트랙에서 띄운 서버가 켜져 있어야 같은 결과가 나옵니다.
실행 스크립트
notes/run_demo.py 를 만듭니다. 파일 전문입니다. 빈 폴더에서 시작해 세 경우를 차례로 보여 줍니다.
"""회의록 정리 Agent 전체 흐름을 한 번 돌린다.
빈 폴더에서 시작해 세 번 실행한다.
1회차 — 새 파일. 승인 없이 작성
2회차 — 같은 입력. 멱등성으로 건너뜀 (승인을 묻지 않는다)
3회차 — 회의록이 늘어난 상태. 덮어쓰기이므로 승인 요청
컨테이너에서 실행한다. LM Studio 서버가 켜져 있어야 한다.
python -m notes.run_demo
모듈로 실행해야 한다. `python notes/run_demo.py` 로 실행하면
notes/ 가 경로에 들어가 최상위의 sec_tools 를 찾지 못한다.
"""
import shutil
import sys
from langgraph.types import Command
import sec_write_tools
from notes import notes_graph
from sec_tools import ALLOWED_ROOT
FIRST_NOTE = """# 8월 2주차 회의록
참석: 김, 이
- 김: 3분기 매출 자료 8월 20일까지 정리
- 이: 신규 채널 후보 3곳 조사, 8월 25일
"""
SECOND_NOTE = """# 8월 3주차 회의록
참석: 박
- 박: 다음 회의 일정 공지 8월 18일
"""
def base_input():
return {
"meeting_dir": "meetings",
"target_path": "actions/TODO.md",
"requester_id": "demo-user",
"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": [],
}
def show(title, state):
print("--- %s ---" % title)
print(" 위험 등급 :", state.get("risk_level"))
print(" 멈춤 여부 :", "__interrupt__" in state)
print(" 결과 :", state.get("result") or "(아직 없음)")
for line in state.get("audit_log", []):
print(" -", line)
print()
def main():
shutil.rmtree(ALLOWED_ROOT, ignore_errors=True)
(ALLOWED_ROOT / "meetings").mkdir(parents=True, exist_ok=True)
(ALLOWED_ROOT / "meetings" / "0812.md").write_text(FIRST_NOTE, encoding="utf-8")
graph = notes_graph.build_graph()
print("== 1회차: 새 파일 ==")
try:
first = graph.invoke(base_input(), {"configurable": {"thread_id": "demo-1"}})
except Exception as error:
print("실패:", type(error).__name__, error)
print("LM Studio 서버가 켜져 있는지 확인하세요.")
return 2
show("1회차", first)
print("만들어진 파일:")
print((ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"))
print("== 2회차: 같은 입력 ==")
second = graph.invoke(base_input(), {"configurable": {"thread_id": "demo-2"}})
show("2회차", second)
print("== 3회차: 회의록이 늘어난 뒤 ==")
(ALLOWED_ROOT / "meetings" / "0819.md").write_text(SECOND_NOTE, encoding="utf-8")
config = {"configurable": {"thread_id": "demo-3"}}
paused = graph.invoke(base_input(), config)
show("3회차 (승인 대기)", paused)
if "__interrupt__" in paused:
payload = paused["__interrupt__"][0].value
print("사람에게 보여 주는 차이:")
print(payload["diff"])
print()
resumed = graph.invoke(Command(resume="approve"), config)
show("3회차 (승인 후)", resumed)
print("최종 파일:")
print((ALLOWED_ROOT / "actions/TODO.md").read_text(encoding="utf-8"))
print("남은 백업:", sec_write_tools.list_backups("actions/TODO.md"))
return 0
if __name__ == "__main__":
sys.exit(main())
실행
cd webapp/verify
docker run --rm -v "$PWD":/work -w /work llmwiki-verify python -m notes.run_demo
1회차 — 새 파일
--- 1회차 ---
위험 등급 : low
멈춤 여부 : False
결과 : 작성 완료: actions/TODO.md
- 회의록 1건 읽음: 0812.md
- 실행항목 2건 추출
- 쓸 내용 확정 (risk=low, action_id=0bc7f85dfd8126ca). 아직 쓰지 않음
- 새 파일이므로 자동 승인 (사람 개입 없음)
- actions/TODO.md 에 씀. 백업 없음 (새 파일)
만들어진 파일입니다.
# 실행항목
회의록에서 자동으로 뽑았습니다. 사람이 확인한 뒤 쓰십시오.
- [ ] **김** — 3분기 매출 자료 정리 (기한 8월 20일)
- [ ] **이** — 신규 채널 후보 3곳 조사 (기한 8월 25일)
새 파일이라 저위험이고, 자동 승인되었으며, 그 사실이 기록에 남았습니다. 백업은 없습니다. 지울 원본이 없기 때문입니다.
2회차 — 같은 입력
--- 2회차 ---
위험 등급 : high
멈춤 여부 : False
결과 : 이미 처리됨
- 회의록 1건 읽음: 0812.md
- 실행항목 2건 추출
- 쓸 내용 확정 (risk=high, action_id=0bc7f85dfd8126ca). 아직 쓰지 않음
- 같은 입력이 이미 처리되어 건너뜀 (action_id=0bc7f85dfd8126ca)
두 가지를 봅니다.
action_id가 1회차와 같습니다(0bc7f85dfd8126ca). 입력이 같으니 키도 같습니다- 위험 등급은
high인데 멈추지 않았습니다. 중복 확인이 승인보다 앞에 있기 때문입니다
3회차 — 회의록이 늘어난 뒤
--- 3회차 (승인 대기) ---
위험 등급 : high
멈춤 여부 : True
결과 : (아직 없음)
- 회의록 2건 읽음: 0812.md, 0819.md
- 실행항목 3건 추출
- 쓸 내용 확정 (risk=high, action_id=fa5eaa6ef4b993c8). 아직 쓰지 않음
action_id 가 바뀌었습니다(fa5eaa6ef4b993c8). 회의록이 늘어 입력이 달라졌으니 다른 작업입니다. 그래서 건너뛰지 않고 승인을 요청합니다.
사람에게 보여 준 차이입니다.
--- actions/TODO.md (현재)
+++ actions/TODO.md (바뀔 내용)
@@ -5,2 +5,3 @@
- [ ] **김** — 3분기 매출 자료 정리 (기한 8월 20일)
- [ ] **이** — 신규 채널 후보 3곳 조사 (기한 8월 25일)
+- [ ] **박** — 다음 회의 일정 공지 (기한 8월 18일)
한 줄이 추가된다는 것이 한눈에 보입니다. 전체 문서를 읽지 않아도 판단할 수 있습니다.
--- 3회차 (승인 후) ---
결과 : 작성 완료: actions/TODO.md
- 사람 결정: approve
- actions/TODO.md 에 씀. 백업 actions_TODO.md.20260815-061341-208193
남은 백업: ['actions_TODO.md.20260815-061341-208193']
이번에는 백업이 남았습니다. 덮어쓰기였기 때문입니다.
되돌리기
덮어쓴 것을 되돌립니다.
import sec_write_tools
backups = sec_write_tools.list_backups("actions/TODO.md")
print("백업 목록:", backups)
sec_write_tools.restore_from_backup("actions/TODO.md", backups[0])
가장 최근 백업이 목록의 첫 번째입니다. 되돌리면 파일이 덮어쓰기 직전 상태로 돌아갑니다.
확인 필요 되돌리기는 현재 파일을 덮어씁니다. 이 교재의 데모에서는 직접 함수를 부르지만, 실제로 쓸 때는 이 동작도 승인 게이트를 지나게 해야 합니다.
테스트는 다음 차시에서
여기까지 하면 Agent 가 돕니다. 그런데 돌아간다는 것과 앞으로도 계속 옳게 돈다는 것은 다릅니다.
다음 차시에서 테스트 28개를 씁니다. 코드를 고친 뒤에도 승인 게이트와 백업이 살아 있는지 확인하는 장치입니다.
바꾼 뒤 반드시 다시 돌릴 것
자기 업무에 맞게 고칠 곳입니다.
| # | 위치 | 무엇을 |
|---|---|---|
| 1 | ACTION_SCHEMA | 뽑을 항목의 모양. 담당자·할 일·기한 말고 다른 것이 필요하면 |
| 2 | render_todo | 결과 문서의 형식 |
| 3 | plan_write 의 위험 판정 | 지금은 파일 존재 여부. 다른 기준이 필요하면 |
| 4 | _EXECUTED_ACTION_IDS | 메모리 집합을 durable 저장소로. 4번 트랙 7차시 |
고친 뒤 28개 테스트가 그대로 통과해야 합니다. 특히 세 가지를 확인합니다.
test_reject_leaves_the_file_untouched— 반려했는데 파일이 바뀌면 승인 게이트가 뚫린 것입니다test_approve_writes_and_backs_up— 백업 없이 덮어쓰면 되돌릴 수 없습니다test_same_input_executes_once— 중복 방어가 깨진 것입니다
여기까지 오면
빈 폴더에서 시작해 파일을 만들고 고치는 Agent 를 만들었습니다. 그 과정에서 여섯 트랙의 내용이 전부 쓰였습니다.
| 트랙 | 이 실습에서 |
|---|---|
| 1. 설치 | 격리 실행. 자료가 나가는 지점 확인 |
| 2. 입문 | State · Node · 조건부 Edge · Reducer · Interrupt |
| 3. 코드 구조화 | 파일 분리 · 규칙을 두 벌로 만들지 않기 |
| 4. 보안 특화 | 승인 게이트 · 멱등성 · 감사 로그 · 검증 범위 밝히기 |
| 5. 로컬 LLM | 회의록이 기기 밖으로 나가지 않음 |
| 6. 실습 | 이 전부를 한 번에 |
마지막으로 남길 것은 이것입니다. 구조로 막는다는 것은 공격을 막는다는 뜻이 아니라, 잘못될 수 있는 일이 넘을 수 없는 선을 코드로 그어 두는 것입니다.