742 lines
24 KiB
Markdown
742 lines
24 KiB
Markdown
# 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: "<URL>"
|
|
description: <설명>
|
|
headers: # 선택
|
|
Authorization: Bearer <token>
|
|
|
|
# 둘 중 하나 선택: map_reduce 또는 task_procedure
|
|
map_reduce: { ... }
|
|
# 또는
|
|
task_procedure: { ... }
|
|
tasks: [ ... ]
|
|
```
|
|
|
|
### 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]
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Task 유형
|
|
|
|
### 3.1 LLM Task
|
|
|
|
```yaml
|
|
- task_name: analyze
|
|
llm_provider: google # google | anthropic | openai
|
|
llm_model: gemini-3.1-pro-preview
|
|
llm_reasoning: high # low | medium | high (Google만)
|
|
llm_verbosity: medium # low | medium | high
|
|
use_tools: ["localdocs"] # LLM이 사용할 MCP 서버
|
|
cache_control: # 선택 (Gemini/Anthropic)
|
|
mode: auto
|
|
ttl: 5m
|
|
prompts:
|
|
- role: system
|
|
content: |
|
|
<system_role>...</system_role>
|
|
- role: user
|
|
content: |
|
|
{{prev.load_data.result}}
|
|
```
|
|
|
|
**주의:**
|
|
- `use_tools`를 주면 LLM이 도구 호출 가능 → 프롬프트에서 **허용 범위를 명시**해야 함
|
|
- `llm_reasoning`은 Google native SDK 경로에서만 작동 (cache_control 없이도 가능)
|
|
- `cache_control` + `tools`는 Gemini에서 동시 사용 불가 (400 에러)
|
|
|
|
### 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 | 현재 반복 아이템의 필드 |
|
|
| `{{item_json}}` | map task | 현재 아이템 전체 JSON 문자열 |
|
|
| `{{prev.task_name}}` | 모든 task | 이전 task의 전체 출력 |
|
|
| `{{prev.task_name.field}}` | 모든 task | 이전 task 출력의 특정 필드 |
|
|
| `{{prev.task_name.nested.field}}` | 모든 task | 중첩 필드 접근 |
|
|
| `{{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로 로드한 데이터 |
|
|
|
|
---
|
|
|
|
## 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": "<task_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": "<task_name>",
|
|
"version": "1.0",
|
|
"user_id": "<SHA-256 hashed user_id>", # 필수
|
|
"workspace_id": "<SHA-256 hashed 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": "<task_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` + `tools` | ❌ 400 에러 (Gemini API 제약) |
|
|
| `reasoning` only (no `cache_control`) | ✅ native SDK, ThinkingConfig 적용 |
|
|
| `reasoning` + `cache_control` | ✅ 작동 (캐시 + thinking) |
|
|
| `reasoning` + `cache_control` + `tools` | ❌ 400 에러 |
|
|
|
|
### 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: |
|
|
<COMMON_CACHE_PREFIX_V0>
|
|
당신은 대한민국 민사소송 실무를 지원하는 ...
|
|
(원본 전체 내용)
|
|
</TASK_B_DYNAMIC_TAIL_V5>
|
|
```
|
|
|
|
### 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: |
|
|
<system_role>역할 정의</system_role>
|
|
|
|
<input_output_policy>
|
|
IN: 입력 파일/데이터 명시
|
|
OUT: 출력 형식 명시 (파일 저장 금지 등)
|
|
</input_output_policy>
|
|
|
|
<constraints>
|
|
[제약1]: 설명
|
|
[제약2]: 설명
|
|
</constraints>
|
|
|
|
<algorithm_to_perform>
|
|
[Step 1]: ...
|
|
[Step 2]: ...
|
|
</algorithm_to_perform>
|
|
|
|
<output_contract>
|
|
JSON Schema 또는 Markdown 템플릿
|
|
</output_contract>
|
|
|
|
- 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의 각 `<hp:p>` 태그는 `paraPrIDRef`로 header.xml의 `<hh:paraPr>` 를 참조한다:
|
|
|
|
```xml
|
|
<!-- section0.xml -->
|
|
<hp:p paraPrIDRef="47" ...>
|
|
<hp:run charPrIDRef="0"><hp:t>(1) 원고 우방캐피탈...</hp:t></hp:run>
|
|
</hp:p>
|
|
|
|
<!-- header.xml -->
|
|
<hh:paraPr id="47" ...>
|
|
<hh:margin>
|
|
<hc:left value="4000" unit="HWPUNIT"/> <!-- 40pt 들여쓰기 -->
|
|
</hh:margin>
|
|
</hh:paraPr>
|
|
```
|
|
|
|
**새 paraPr 추가 시 필수:**
|
|
- 기존 최대 ID + 1로 순차 ID 할당
|
|
- `<hh:paraProperties itemCnt="N">` 의 `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** 포함
|
|
- [ ] 템플릿 변수를 Python에 삽입할 때 **`r"""..."""`** 사용
|
|
- [ ] reduce에서 map_results는 **`{{map_results_b64}}`** + base64 디코드
|
|
- [ ] `use_tools`를 주는 LLM task → 프롬프트에 **허용/금지 범위 명시**
|
|
- [ ] Weaviate 검색 결과 파싱: `r"""..."""` + JSON 제어문자 escape 처리
|
|
- [ ] LLM 출력: 코드블록 래핑 대비 파싱 로직
|
|
- [ ] `map_results` 아이템 구조: `item.item.claim_id` 형태 확인
|
|
- [ ] Gemini: `cache_control` + `tools` 동시 사용 금지
|
|
- [ ] 파일 병합 시 프롬프트/코드를 **placeholder나 요약 주석으로 생략하지 않았는지** 확인
|
|
- [ ] 한글 파일명: NFC 정규화 또는 영문명 사용
|
|
- [ ] 바이너리 파일: `read_binary_doc` / `write_binary_file` 사용 (텍스트 도구와 혼용 금지)
|
|
- [ ] HWPX 수정 시: header.xml `itemCnt` 동기화 필수
|