# Agent Script YAML 작성 스킬 가이드 > 민사소송 원고 대리 에이전트 스크립트 작성 경험에서 축적된 패턴, 규칙, 주의사항 > 실행 백엔드: AgentBackend (FastAPI) --- ## 0. 백엔드 아키텍처 & 파일 저장 경로 ### 0.1 user_id / workspace_id 기반 파일 격리 AgentBackend는 `user_id`와 `workspace_id`를 통해 **사용자/워크스페이스별 파일 격리**를 수행한다. **저장 경로 패턴:** ``` {MCP_LOCALDOCS_PATH}/users/{SHA256(user_id)}/{SHA256(workspace_id)}/ ``` | 조건 | 경로 | |------|------| | user_id + workspace_id 둘 다 있음 | `/mcp-localdocs/users/{hash_uid}/{hash_wid}/` | | user_id만 있음 | `/mcp-localdocs/users/{hash_uid}/` | | 둘 다 없음 (레거시) | `/mcp-localdocs/` (루트, 하위호환) | - 경로 결정: `server.py::_resolve_base_dir(user_id, workspace_id)` - user_id와 workspace_id는 SHA-256으로 해싱하여 디렉토리명으로 사용 - 파일 암호화: AES-256-GCM, `LOCALDOCS_MASTER_SECRET`에서 per-user 키 유도 ### 0.2 user_id / workspace_id 전파 경로 ``` 클라이언트 요청 (user_id, workspace_id) ↓ server.py (REST/WebSocket/SSE 엔드포인트) ↓ Agent.run_with_confirmation(user_id, workspace_id) ↓ Stage.run() → stage.user_id, stage.workspace_id 설정 ↓ MCPClientManager.from_dict(tools_config, user_id=..., workspace_id=...) ↓ MCP 서버 (localdocs) → _resolve_user(ctx) → 해시된 경로에서 파일 R/W ``` **핵심:** 에이전트 스크립트(YAML)에서는 user_id/workspace_id를 직접 다루지 않는다. 백엔드가 자동으로 전파한다. code-executor에서 직접 localdocs MCP를 호출할 때도 **세션 초기화 시 자동으로 user/workspace 컨텍스트가 바인딩된다.** ### 0.3 Default_Agent 폴더 `Default_Agent/` 폴더는 워크스페이스 초기화 시 `pre_loads/`에서 자동 복사되는 **공용 규칙 파일** 저장소다. | 파일 | 용도 | |------|------| | `Keywords_Criteria.txt` | 판례 검색 키워드 기준 | | `Mapping_Rule_Case_DB.txt` | 판례 DB 매핑 규칙 | | `청구취지작성규칙_Mapping_Table.md` | case_kind → 작성규칙 매핑 | | `complaint_template.hwpx` | 소장 hwpx 템플릿 | | `Global_Rules.md` | 글로벌 규칙 | ### 0.4 환경 변수 | 변수 | 기본값 | 설명 | |------|--------|------| | `MCP_LOCALDOCS_PATH` | `/mcp-localdocs` | localdocs 루트 경로 | | `LOCALDOCS_MASTER_SECRET` | (필수) | 파일 암호화 마스터 시크릿 | | `USER_STORAGE_LIMIT_MB` | `500` | 유저당 저장 한도 | --- ## 1. 전체 구조 ```yaml Agent: name: <에이전트명> description: <설명> version: <버전> Stages: - name: <스테이지명> description: <설명> tools: mcpServers: <서버명>: type: streamable-http url: "" description: <설명> headers: # 선택 Authorization: Bearer # 둘 중 하나 선택: map_reduce 또는 task_procedure map_reduce: { ... } # 또는 task_procedure: { ... } tasks: [ ... ] ``` ### Stage 옵션 | 필드 | 타입 | 기본값 | 설명 | |------|------|--------|------| | `skip_confirm` | bool | `false` | `true`로 설정하면 실행 확인 없이 바로 실행 (스킵 방지) | ```yaml - name: stage_build description: 전처리 파일 생성 skip_confirm: true # 확인 없이 바로 실행 prevs: [] nexts: [stage_main] ``` ### Stage 간 의존성 (DAG) ```yaml Stages: - name: stage_A nexts: [stage_B, stage_C] - name: stage_B prevs: [stage_A] nexts: [stage_D] - name: stage_D prevs: [stage_B, stage_C] # 모두 완료되어야 실행 ``` --- ## 2. 실행 모드 ### 2.1 MapReduce — 동일 작업을 다수 아이템에 병렬 실행 ```yaml map_reduce: max_concurrency: 12 source: mcp: server: localdocs tool_name: read_docs parameters: doc_names: ["claims_identified.json"] key: ["identified_claims"] # JSON 배열 경로 shared_context: # 선택: 1회 로드, 모든 task에서 {{shared.X}} - name: keywords_criteria mcp: server: localdocs tool_name: read_docs parameters: { doc_names: ["Keywords_Criteria.txt"] } map: tasks: - task_name: step1 ... - task_name: step2 ... # 아이템당 순차 실행 reduce: - task_name: aggregate ... # map 완료 후 1회 실행 ``` **핵심:** - `max_concurrency`개 아이템이 동시에 map 파이프라인 실행 - 각 아이템 내 task는 순차 실행 - reduce는 모든 map 완료 후 1회 실행 - Stage 레벨 `llm_provider`/`llm_model`은 map_reduce에서 **불필요** (각 task가 자체 지정) ### 2.2 Task Procedure — DAG 기반 분기/합류 ```yaml task_procedure: IN: nexts: [Task_A] Task_A: nexts: - task: Task_B when: "output.score >= 80" - task: Task_C when: "output.score < 80" Task_B: nexts: [OUT] wait_until: [Task_A] Task_C: nexts: [OUT] wait_until: [Task_A] OUT: nexts: [] wait_until: [Task_B, Task_C] ``` ### 2.3 Wildcard Fan-out — 런타임 동적 인스턴스 생성 부모 task 의 `stdout` JSON 에 `{"dynamic_fanout": [item0, item1, ...]}` 가 들어 있으면, `task_procedure` 의 `Task_X_*` (별 `*` 접미사) 패턴이 `Task_X_0`, `Task_X_1`, ... 로 런타임 확장된다. 각 인스턴스는 자기 `item` 을 `{{item.*}}` 로 받는다. (`agent.py:1304-1351`, `_is_wildcard_name` / `_expand_wildcard`) **Fan-out 토큰 3종** | 토큰 | 의미 | 사용처 | |------|------|--------| | `Task_X_*` | wildcard 정의. 인스턴스로 확장될 task template | `tasks[].task_name`, `task_procedure` key | | `Task_Y_{same_ordinal}` | 자기 인덱스 (ordinal) 로 치환되는 cross-fan-out 참조 | `nexts` / `wait_until` 항목 | | `all Task_X_*` | 모든 인스턴스 완료를 한 번에 대기하는 집계 토큰 | `wait_until` 항목 | **전형적 fan-out DAG** ```yaml task_procedure: IN: nexts: [Task_Planner] wait_until: [] Task_Planner: nexts: [Task_B1_map_doc_*] wait_until: [IN] Task_B1_map_doc_*: # 부모의 dynamic_fanout 길이만큼 확장 nexts: - "Task_B2_map_events_{same_ordinal}" # 같은 ordinal 의 B2 가 wait - "Task_B1_quality_gate" # 정적 집계자 wait_until: [Task_Planner] Task_B2_map_events_*: nexts: [Task_B2_quality_gate] wait_until: - "Task_B1_map_doc_{same_ordinal}" # cross-fan-out 동일 ordinal 대기 Task_B1_quality_gate: nexts: [Task_B2_quality_gate] wait_until: ["all Task_B1_map_doc_*"] # 전체 인스턴스 완료 대기 Task_B2_quality_gate: nexts: [OUT] wait_until: - "all Task_B2_map_events_*" - Task_B1_quality_gate ``` **부모(planner) task 의 stdout 규약** `Task_Planner` (code-executor 또는 LLM task) 가 다음 JSON 한 줄을 stdout 으로 출력해야 wildcard 확장이 발동된다: ```python print(json.dumps({ "dynamic_fanout": [ {"assigned_ordinal": 1, "ordinal_label": "001", "evidence_index_candidate": "E-001", "shard_doc_path": "evidence_shards/E-001.json", ...}, {"assigned_ordinal": 2, "ordinal_label": "002", ...}, ... ], "planner_status": "READY", "mapper_execution_allowed": True }, ensure_ascii=False)) ``` 각 인스턴스의 prompt / code 에서 `{{item.assigned_ordinal}}`, `{{item.shard_doc_path}}` 등으로 접근. **Wildcard task 의 task_config** ```yaml - task_name: Task_B1_map_doc_* max_concurrency: 12 # 인스턴스 병렬 상한 (없으면 무제한) preflight_files: # 인스턴스마다 다른 path 가능 - evidence_shard_plan.json # 정적 (모든 인스턴스 공통, cache 됨) - "{{item.shard_doc_path}}" # 동적 (인스턴스별) - client_meeting.md llm_provider: google llm_model: gemini-3.1-flash-lite-preview prompts: - role: user content: |- {{item.assigned_ordinal}} 본 task 는 `{{item.shard_doc_path}}` 1건만 처리한다. 출력 경로: evidence_indexed_parts/E-{{item.ordinal_label}}.json ``` **주의:** - `max_concurrency: N` → semaphore 로 N 개 instance 만 동시 실행. 외부 API rate limit (예: Gemini 분당 토큰 한도) 회피용 - wildcard task 의 `task_name` 은 반드시 `_*` 로 끝나야 함 (`_is_wildcard_name`) - cross-fan-out 참조 시 `{same_ordinal}` 은 같은 ordinal 끼리만 연결. 다른 ordinal 의 동일 task type 은 독립. - aggregate token `all Task_X_*` 은 모든 instance 완료를 한 번에 대기. quality gate 패턴의 표준 형태. - 자세한 동작 흐름: `agent.py::TaskProcedureExecutor._maybe_expand_wildcards` --- ## 3. Task 유형 ### 3.1 LLM Task ```yaml - task_name: analyze llm_provider: google # google | anthropic | openai | "https://" (OpenAI-compatible) llm_model: gemini-3.1-pro-preview llm_reasoning: high # low | medium | high llm_verbosity: medium # low | medium | high llm_endpoint: completions # "completions" (default) | "responses" use_tools: ["localdocs"] # LLM이 사용할 MCP 서버 cache_control: # 선택 (Gemini/Anthropic) mode: auto # "auto" | "explicit" ttl: 1h # 5m / 10m / 1h / 2h ... (Pro 모델 long-running 은 1h+ 권장) preflight: true # default true. 프롬프트의 파일명을 미리 read_docs (LLM-based task 만 적용) preflight_files: # 선택. 명시 시 regex 스캔 대신 이 화이트리스트만 preflight - evidence_shard_plan.json # glob (*, ?) 지원 - "evidence_indexed_parts/E-*.json" - client_meeting.md llm_history_continuous: prev_task_name # 선택. 이전 task/stage 의 response_id 로 체이닝 (Responses API) prompts: - role: system content: | ... - role: user content: | {{prev.load_data.result}} ``` **주의:** - `use_tools` 를 주면 LLM 이 도구 호출 가능 → 프롬프트에서 **허용 범위를 명시**해야 함 - `llm_reasoning` 은 Google native SDK 경로에서만 작동 (cache_control 없이도 가능) - `preflight: false` 명시하면 prompt 본문 regex 스캔과 자동 read_docs 모두 비활성화 (forbidden-files 정책이 강한 task 에서 권장) - `preflight_files` 의 entry 에 `{{item.*}}` 같은 동적 path 가 있으면 fan-out 인스턴스별로 다르게 로드. 정적 path 만으로 구성된 entry 는 instance-invariant 로 판단되어 Gemini cache 의 user_prefix 영역에 inline 됨 (다른 인스턴스/task 와 cache 공유) ### 3.2 Direct MCP Task (LLM 없이 도구 직접 호출) ```yaml - task_name: search_weaviate mcp: weaviate tool_name: search_hybrid parameters: collection_name: "{{prev.generate_query.search_params.collection_name}}" tenant: "{{prev.generate_query.search_params.tenant}}" query: "{{prev.generate_query.search_params.query}}" alpha: "{{prev.generate_query.search_params.alpha}}" limit: "{{prev.generate_query.search_params.limit}}" ``` ### 3.3 Code-Executor Task (인라인 Python) ```yaml - task_name: save_results mcp: code-executor tool_name: run_code parameters: language: python requirements: "httpx" network: "agent-network" timeout: 60 code: | #!/usr/bin/env python3 import json, sys # ... 코드 print(json.dumps({"status": "ok"})) # stdout = task output ``` ### 3.4 조건부 Task (map 전용) ```yaml - task_name: search_if_ready when: "prev.generate_query.query_generated == true" mcp: weaviate tool_name: search_hybrid parameters: { ... } ``` --- ## 4. 템플릿 변수 | 변수 | 사용 위치 | 설명 | |------|----------|------| | `{{item.X}}` | map task / wildcard fan-out instance | 현재 반복/인스턴스 아이템의 필드 (e.g., `{{item.assigned_ordinal}}`) | | `{{item}}` | map / fan-out | 항목 전체 (dict/list 면 자동 JSON 직렬화) | | `{{item_json}}` | map / fan-out | 항목 전체 JSON 문자열 | | `{{item_index}}` | map / fan-out | 0-based 인덱스 | | `{{ordinal}}` | wildcard fan-out instance | 인스턴스 ordinal (0-based, `item_index` 와 동일) | | `{{prev.task_name}}` | 모든 task | 이전 task 의 전체 출력 (stdout JSON 자동 파싱) | | `{{prev.task_name.field}}` | 모든 task | 이전 task 출력의 특정 필드 | | `{{prev.task_name.nested.field}}` | 모든 task | 중첩 필드 접근 (dot-notation) | | `{{stages.}}` | stage 간 | 이전 stage 의 output 전체 (`apply_stage_context`) | | `{{stages..field}}` | stage 간 | 이전 stage output 의 특정 필드 | | `{{map_results}}` | reduce task | map 전체 결과 (JSON 문자열) | | `{{map_results_b64}}` | reduce task | map 전체 결과 (base64 인코딩) | | `{{map_results_count}}` | reduce task | map 결과 건수 | | `{{shared.name}}` | map/reduce | shared_context 로 로드한 데이터 | | `{{__user_hash__}}` | code-executor task | 현재 user_id 의 SHA-256 (clientInfo 주입용, 자동 substitute) | | `{{__workspace_hash__}}` | code-executor task | 현재 workspace_id 의 SHA-256 | **fan-out 전용 토큰** (task_procedure 안의 `nexts` / `wait_until` 항목에서만 의미): | 토큰 | 위치 | 설명 | |------|------|------| | `Task_X_*` | task_procedure key, nexts/wait_until | wildcard task template — 부모의 `dynamic_fanout` 길이만큼 인스턴스 확장 | | `Task_X_{same_ordinal}` | nexts/wait_until 항목 | 자기 인스턴스 ordinal 로 치환되어 cross-fan-out 동일 ordinal 끼리 연결 | | `all Task_X_*` | wait_until 항목 | wildcard 의 모든 인스턴스 완료를 한 번에 대기 (집계자 / quality gate 패턴) --- ## 5. MCP Localdocs 보일러플레이트 code-executor에서 localdocs를 사용하는 모든 Python 코드에 필요한 공통 패턴: ```python #!/usr/bin/env python3 import itertools, json, sys import httpx LOCALDOCS_URL = "http://mcp-localdocs:8012/mcp" MCP_HEADERS = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} CLIENT = httpx.Client(timeout=60) MSG_ID_COUNTER = itertools.count(10) def next_msg_id(): return next(MSG_ID_COUNTER) def _init(): r = CLIENT.post(LOCALDOCS_URL, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "", "version": "1.0", "user_id": "{{__user_hash__}}", "workspace_id": "{{__workspace_hash__}}"}} }, headers=MCP_HEADERS) r.raise_for_status() sid = r.headers.get("mcp-session-id") if sid: MCP_HEADERS["mcp-session-id"] = sid CLIENT.post(LOCALDOCS_URL, json={"jsonrpc": "2.0", "method": "notifications/initialized"}, headers=MCP_HEADERS).raise_for_status() def _parse_mcp(text): for line in text.strip().split("\n"): if line.startswith("data: "): try: return json.loads(line[6:]) except: return None try: return json.loads(text) except: return None def _call(name, args, mid): r = CLIENT.post(LOCALDOCS_URL, json={ "jsonrpc": "2.0", "id": mid, "method": "tools/call", "params": {"name": name, "arguments": args} }, headers=MCP_HEADERS) r.raise_for_status() p = _parse_mcp(r.text) if not p or "result" not in p: raise RuntimeError(f"MCP {name} failed") return p def unwrap_text(p, name): text = (p["result"].get("content") or [{}])[0].get("text", "") if not text: raise RuntimeError(f"Empty response: {name}") try: outer = json.loads(text) except: return text if isinstance(outer, dict) and "results" in outer: r0 = (outer.get("results") or [{}])[0] inner = r0.get("content") or r0.get("text") or "" if not inner: raise RuntimeError(f"Empty content: {name}") return inner if isinstance(inner, str) else json.dumps(inner, ensure_ascii=False) return json.dumps(outer, ensure_ascii=False) if not isinstance(outer, str) else outer def read_text(name): return unwrap_text(_call("read_docs", {"doc_names": [name]}, next_msg_id()), name) def read_json(name): raw = read_text(name) return json.loads(raw) if isinstance(raw, str) else raw def write_doc(path, content): _call("write_file", {"path": path, "content": content, "overwrite": True}, next_msg_id()) _init() # ... 작업 수행 ... print(json.dumps({"status": "ok", ...})) ``` ### 5.1 바이너리 파일 읽기/쓰기 localdocs는 텍스트와 바이너리를 별도 MCP tool로 처리한다: | 도구 | 파라미터 | 용도 | |------|---------|------| | `read_docs` | `{"doc_names": ["file.json"]}` | 텍스트 파일 읽기 | | `read_binary_doc` | `{"doc_name": "file.hwpx"}` | 바이너리 파일 읽기 | | `write_file` | `{"path": "file.json", "content": "...", "overwrite": true}` | 텍스트 파일 저장 | | `write_binary_file` | `{"path": "file.hwpx", "content_base64": "...", "overwrite": true}` | 바이너리 파일 저장 | | `list_docs` | `{"path": "folder", "pattern": "*"}` | 파일 목록 조회 | | `list_folders` | `{}` | 폴더 목록 조회 | **바이너리 읽기 응답 구조:** ```python # read_binary_doc 응답의 text 필드가 JSON envelope envelope = json.loads(data_str) binary_bytes = base64.b64decode(envelope["content_base64"]) ``` **바이너리 쓰기:** ```python encoded = base64.b64encode(data).decode("ascii") _call("write_binary_file", { "path": "소장.hwpx", "content_base64": encoded, "overwrite": True, }, next_msg_id()) ``` --- ## 5.2 Direct MCP에서 user_id / workspace_id 컨텍스트 전달 ### 문제: code-executor에서 localdocs를 직접 호출하면 파일이 안 보인다 AgentBackend가 Stage를 실행할 때 MCPClientManager를 통해 localdocs에 연결하면, `clientInfo`에 SHA-256 해시된 `user_id`와 `workspace_id`가 자동으로 포함된다. localdocs 서버는 이 값으로 저장 경로를 결정한다. **그러나** code-executor task 안의 Python 코드가 localdocs에 직접 HTTP 요청을 보내면, `clientInfo`에 user/workspace 정보가 없으므로 **루트 경로(DOCS_DIR)**에 접근하게 된다. 이 경우 사용자의 파일이 보이지 않는다. ### MCPClientManager가 전달하는 방식 (자동) ```python # llm_bridge/client.py — MCPClientManager가 세션 초기화 시 자동 수행 hashed_uid = hashlib.sha256(user_id.encode("utf-8")).hexdigest() hashed_wid = hashlib.sha256(workspace_id.encode("utf-8")).hexdigest() client_info = Implementation( name="eroom-agent", version="1.0", user_id=hashed_uid, workspace_id=hashed_wid, ) session = ClientSession(*stream_context, client_info=client_info) ``` ### localdocs 서버가 읽는 방식 ```python # mcp_localdocs_server.py::_resolve_user(ctx) hashed_uid = getattr(client_info, 'user_id', None) # SHA-256 hex cid = getattr(client_info, 'workspace_id', None) # SHA-256 hex # → {DOCS_DIR}/users/{hashed_uid}/{cid}/ ``` ### code-executor에서 직접 호출할 때의 경로 | clientInfo에 user_id 포함 | 접근 경로 | |--------------------------|----------| | ❌ 미포함 (현재 보일러플레이트) | `/mcp-localdocs/` (루트 — 다른 사용자 파일과 혼재) | | ✅ 포함 | `/mcp-localdocs/users/{hash_uid}/{hash_wid}/` (격리됨) | ### 해결: code-executor 보일러플레이트에 user/workspace 전달 현재 code-executor 내 Python 코드는 JSON-RPC `initialize` 메서드의 `clientInfo`에 `user_id`/`workspace_id`를 넣지 않는다. 이를 전달하려면: ```python # ✅ user_id/workspace_id를 clientInfo에 포함시키는 방식 def _init(): r = CLIENT.post(LOCALDOCS_URL, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "", "version": "1.0", "user_id": "", # 필수 "workspace_id": "" # 선택 } } }, headers=MCP_HEADERS) ... ``` **그러나** code-executor의 Python 코드에서는 원본 `user_id`를 알 수 없다. 이 값은 AgentBackend가 관리하며, YAML 스크립트에 노출되지 않는다. ### 해결: `{{__user_hash__}}` / `{{__workspace_hash__}}` 템플릿 변수 AgentBackend가 `{{__user_hash__}}`와 `{{__workspace_hash__}}`를 자동으로 SHA-256 해시값으로 렌더링한다. code-executor 코드의 `clientInfo`에 넣으면 localdocs가 user 경로에서 파일을 읽고 쓴다. ```python # ✅ clientInfo에 user_id/workspace_id를 반드시 포함 def _init(): r = CLIENT.post(LOCALDOCS_URL, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "", "version": "1.0", "user_id": "{{__user_hash__}}", "workspace_id": "{{__workspace_hash__}}" } } }, headers=MCP_HEADERS) ``` **`clientInfo`에 `user_id`/`workspace_id`가 없으면 localdocs는 루트 경로에서 파일을 찾으므로, 멀티유저 환경에서 파일을 못 읽거나 다른 사용자 파일에 접근하는 문제가 발생한다.** ### localdocs read_docs 응답의 Extra data 문제 localdocs `read_docs` 응답에서 `content` 필드 뒤에 `"path": "파일명"` 등 추가 데이터가 붙어 `json.loads`가 `Extra data` 에러로 실패하는 경우가 있다. ```python # ❌ BAD — Extra data로 실패 가능 def read_json(name): raw = _unwrap(_call("read_docs", {"doc_names": [name]}), name) return json.loads(raw) # ✅ GOOD — raw_decode fallback으로 첫 번째 JSON 객체만 파싱 def read_json(name): raw = _unwrap(_call("read_docs", {"doc_names": [name]}), name) if not isinstance(raw, str): return raw try: return json.loads(raw) except json.JSONDecodeError: obj, _ = json.JSONDecoder().raw_decode(raw.strip()) return obj ``` 이 패턴은 `_extract_json_from_read_docs_result` 같은 래퍼에도 동일하게 적용해야 한다: ```python # inner가 JSON 문자열일 때 try: return json.loads(inner) except json.JSONDecodeError: try: obj, _ = json.JSONDecoder().raw_decode(inner.strip()) return obj except json.JSONDecodeError as exc: raise RuntimeError(f"Content of {doc_name} is not valid JSON: {inner[:300]}") from exc ``` --- ## 6. 주의사항 & 함정 (Pitfalls) ### 6.1 Python 문자열 리터럴에 템플릿 삽입 ```python # ❌ BAD — Python이 \n, \" 등을 escape 처리하여 JSON 파괴 _RAW = """{{prev.search_weaviate}}""" # ✅ GOOD — raw string으로 escape 처리 방지 _RAW = r"""{{prev.search_weaviate}}""" ``` **이유:** 템플릿 확장 후 Python이 `"""..."""` 를 파싱할 때 `\n` → 줄바꿈, `\"` → `"` 변환 발생. raw string `r"""..."""`은 이를 방지. ### 6.2 Reduce에서 map_results 처리 ```python # ❌ BAD — 큰 결과에서 raw string 삽입 시 깨질 수 있음 _RAW = r"""{{map_results}}""" # ✅ GOOD — base64 디코딩 import base64 _RAW = base64.b64decode("{{map_results_b64}}").decode("utf-8") results = json.loads(_RAW) ``` ### 6.3 map_results 구조 reduce에서 받는 `map_results`의 각 아이템 구조: ```json { "item_index": 0, "item": { "claim_id": "C-001", ... }, "task_results": { "task_name_1": "output...", "task_name_2": "output..." } } ``` **claim_id 추출 패턴:** ```python def extract_claim_id(item): if "item" in item and isinstance(item["item"], dict): return item["item"].get("claim_id") return item.get("claim_id") ``` ### 6.4 LLM이 도구를 오용하는 경우 `use_tools`를 주면 LLM이 **write_file까지 호출**할 수 있음: ```yaml # ❌ LLM이 파일 저장까지 수행하고 "Successfully saved..." 반환 use_tools: ["localdocs"] # 프롬프트에 제약 없음 # ✅ 프롬프트에 명시적 제약 추가 # "도구 호출은 오직 read_docs만 허용. write_file 금지." ``` ### 6.5 Gemini 제약 | 조합 | 결과 | |------|------| | `cache_control` only | ✅ native SDK | | `reasoning` only (no `cache_control`) | ✅ native SDK, ThinkingConfig 적용 | | `reasoning` + `cache_control` | ✅ 작동 (캐시 + thinking) | | `cache_control` + `tools` | ✅ 작동 (llm_bridge 가 tools 도 cache 에 저장, request 에서 omit) | | `reasoning` + `cache_control` + `tools` | ✅ 작동 (위와 동일 경로) | **참고**: Gemini API 자체는 cached_content 사용 시 같은 request 에서 system_instruction/tools/tool_config 를 다시 보낼 수 없다. llm_bridge 의 `_ensure_gemini_cache` 가 tools 도 cache 안에 함께 저장하고 generate request 에서는 omit 하므로 yaml 작성자는 cache + tools 를 자유롭게 같이 쓸 수 있다 (`bridge.py:309-316`). ### 6.5.1 Cache TTL 만료 처리 Pro 모델 (`gemini-3.1-pro-preview` 등) 의 long-running 호출이 TTL 을 넘기면 다음 호출에서 `403 PERMISSION_DENIED. CachedContent not found` 가 발생한다. llm_bridge 는 이를 감지해 cache 를 자동 무효화하고 1회 재시도한다. yaml 작성 시: - Pro 모델 + reasoning=high 조합은 `ttl: 1h` 이상 권장 (`5m` / `10m` 은 짧다) - Flash / Flash-lite + 짧은 prompt 는 `ttl: 10m` 정도로도 충분 ### 6.6 파일 병합/결합 시 내용 생략 금지 여러 입력 파일을 하나의 combined YAML로 합칠 때, 긴 프롬프트나 코드 블록을 **절대 placeholder로 대체하지 말라.** ```yaml # ❌ BAD — 내용을 placeholder 주석으로 생략 prompts: - role: user content: | [Task_B prompt content preserved from file 1 - lines 1068-1631] This is a comprehensive LLM task for claims identification. # ✅ GOOD — 원본 내용 전체를 그대로 삽입 prompts: - role: user content: | 당신은 대한민국 민사소송 실무를 지원하는 ... (원본 전체 내용) ``` ### 6.7 LLM 출력 파싱 LLM이 JSON을 마크다운 코드블록으로 감쌀 수 있음: ```python def parse_llm_json(raw): s = raw.strip() if s.startswith("```"): parts = s.split("```") for p in parts: p = p.strip() if p.startswith("json"): p = p[4:].strip() if p.startswith("{") or p.startswith("["): s = p; break try: return json.loads(s) except json.JSONDecodeError: pass fixed = s.replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') try: return json.loads(fixed) except json.JSONDecodeError as e: raise RuntimeError(f"Parse failed: {e} | {repr(s[:150])}") ``` ### 6.8 한글 파일명 유니코드 정규화 macOS는 NFD, Linux/서버는 NFC를 사용한다. localdocs에서 한글 파일명이 "not found"가 되는 주요 원인: ```python # ✅ 한글 파일명은 NFC 정규화 후 사용 import unicodedata doc_name = unicodedata.normalize("NFC", "청구취지작성규칙_대여금청구.md") # ✅ 또는 영문 파일명 사용 (가장 안전) TEMPLATE_DOC = "Default_Agent/complaint_template.hwpx" ``` --- ## 7. 파일 네이밍 컨벤션 | 패턴 | 예시 | 설명 | |------|------|------| | `C-###` | C-001 | 청구권 ID | | `F-###` | F-012 | 사실(Fact) ID | | `E-###` | E-005 | 증거(Evidence) ID | | `C-###_<설명>.json` | C-001_claim_description.json | 청구권별 데이터 | | `C-###_<설명>.md` | C-001_defense_rebuttal.md | 청구권별 분석 결과 | --- ## 8. 프롬프트 구조 패턴 ```yaml prompts: - role: system content: | 역할 정의 IN: 입력 파일/데이터 명시 OUT: 출력 형식 명시 (파일 저장 금지 등) [제약1]: 설명 [제약2]: 설명 [Step 1]: ... [Step 2]: ... JSON Schema 또는 Markdown 템플릿 - role: user content: | ## 처리할 데이터: {{prev.load_data.content}} ``` --- ## 9. 자주 사용하는 MCP 서버 | 서버 | URL | 주요 도구 | |------|-----|----------| | localdocs | `http://mcp-localdocs:8012/mcp` | `read_docs`, `read_binary_doc`, `write_file`, `write_binary_file`, `list_docs`, `list_folders` | | weaviate | `https://weaviate.eroomai.com/mcp` | `search_hybrid` | | code-executor | `https://code-executor.mcp.eroomai.com/mcp` | `run_code` | --- ## 10. HWPX 소장 생성 패턴 ### 10.1 HWPX 파일 구조 hwpx는 ZIP 아카이브이며 내부에 XML 파일들이 있다: | 파일 | 역할 | |------|------| | `Contents/header.xml` | 문단 속성 정의 (`hh:paraPr` — 글꼴, 여백, 줄간격 등) | | `Contents/section0.xml` | 실제 문단 내용 (`hp:p` — 텍스트, 테이블 등) | ### 10.2 문단 들여쓰기 (paraPr) section0.xml의 각 `` 태그는 `paraPrIDRef`로 header.xml의 `` 를 참조한다: ```xml (1) 원고 우방캐피탈... ``` **새 paraPr 추가 시 필수:** - 기존 최대 ID + 1로 순차 ID 할당 - `` 의 `itemCnt` 값 업데이트 (N → N+1) - `itemCnt`가 실제 항목 수와 불일치하면 **뷰어가 추가 항목을 무시함** ### 10.3 HWPX 단위 | HWPUNIT | 환산 | |---------|------| | 100 | 1pt | | 7200 | 1inch | | ~283.5 | 1mm | --- ## 11. 체크리스트: 새 스크립트 작성 시 - [ ] Stage 레벨 `llm_provider`/`llm_model`: map_reduce에서는 **불필요** - [ ] 각 LLM task에 provider/model/reasoning 개별 지정 - [ ] code-executor에서 localdocs 사용 시 **full boilerplate** 포함 + `clientInfo` 에 `{{__user_hash__}}`/`{{__workspace_hash__}}` 필수 - [ ] 템플릿 변수를 Python에 삽입할 때 **`r"""..."""`** 사용 - [ ] reduce에서 map_results는 **`{{map_results_b64}}`** + base64 디코드 - [ ] `use_tools`를 주는 LLM task → 프롬프트에 **허용/금지 범위 명시** - [ ] Weaviate 검색 결과 파싱: `r"""..."""` + JSON 제어문자 escape 처리 - [ ] LLM 출력: 코드블록 래핑 대비 파싱 로직 - [ ] `map_results` 아이템 구조: `item.item.claim_id` 형태 확인 - [ ] Pro 모델 + reasoning=high → `cache_control.ttl: 1h` 이상 (만료 시 자동 재시도되지만 cache 없는 호출은 비용↑) - [ ] 파일 병합 시 프롬프트/코드를 **placeholder나 요약 주석으로 생략하지 않았는지** 확인 - [ ] 한글 파일명: NFC 정규화 또는 영문명 사용 - [ ] 바이너리 파일: `read_binary_doc` / `write_binary_file` 사용 (텍스트 도구와 혼용 금지) - [ ] HWPX 수정 시: header.xml `itemCnt` 동기화 필수 - [ ] 확인 없이 실행해야 하는 Stage: `skip_confirm: true` 설정 - [ ] Wildcard fan-out task: 이름이 `_*` 로 끝남 + 부모가 stdout 으로 `{"dynamic_fanout": [...]}` JSON 출력 - [ ] Fan-out 인스턴스의 `wait_until` 에 cross-fan-out 동일 ordinal 참조 시 `Task_X_{same_ordinal}` 사용 - [ ] Quality gate 처럼 fan-out 전체 완료를 대기하는 집계자는 `wait_until: ["all Task_X_*"]` - [ ] Fan-out 인스턴스의 외부 API rate limit 방지: task 에 `max_concurrency: N` - [ ] Fan-out 인스턴스의 `preflight_files` 항목 중 정적 path 는 자동으로 cache 공유 영역에 inline 됨 (인스턴스별 path 는 `{{item.*}}` 사용) - [ ] LLM-based task 에 외부 도구 호출 금지 정책이 있으면 `preflight: false` 명시