diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b0428ea5 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GITEA_TOKEN= diff --git a/.gitignore b/.gitignore index d3b3107f..b01809d3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,8 @@ wheels/ # macOS .DS_Store + +# Secrets / local env (never commit tokens) +.env +.env.* +!.env.example diff --git a/Case_02_Comparison_Research/YAML_Prompts/1. Stage_1/SKILL.md b/Case_02_Comparison_Research/YAML_Prompts/1. Stage_1/SKILL.md new file mode 100644 index 00000000..7ed34709 --- /dev/null +++ b/Case_02_Comparison_Research/YAML_Prompts/1. Stage_1/SKILL.md @@ -0,0 +1,1066 @@ +# 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, `L=OCALDOCS_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` | 유저당 저장 한도 | +| `OPENAI_API_KEY` | (선택) | OpenAI 기본 API 키 | +| `ANTHROPIC_API_KEY` | (선택) | Anthropic 기본 API 키 | +| `GOOGLE_API_KEY` | (선택) | Google AI Studio 기본 API 키 | + +### 0.5 API 키 설정 + +LLM provider 별 API 키는 **세 곳에서** 지정할 수 있으며, **task → stage → agent → 환경 변수** 순서로 우선순위가 결정된다. + +#### 1) 환경 변수 (기본, 권장) + +서버 컨테이너에 `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY` 가 설정되어 있으면 `Agent` 생성자가 자동으로 읽어 `api_keys` dict 를 만든다 (`src/agent.py` 의 `Agent.__init__` 의 env fallback 분기). + +```dockerfile +# docker-compose 예시 +environment: + - OPENAI_API_KEY=sk-... + - ANTHROPIC_API_KEY=sk-ant-... + - GOOGLE_API_KEY=AIza... +``` + +이 경우 yaml 안에 `api_key`/`api_keys` 를 적을 필요가 없다. + +#### 2) Agent 최상위 `api_keys` (provider 별) + +```yaml +Agent: + name: my-agent + description: ... + version: 1.0 + api_keys: + openai: $OPENAI_API_KEY # 환경 변수 참조 ($VAR 또는 ${VAR}) + anthropic: sk-ant-literal-... # 평문 (비권장) + google: ${GOOGLE_API_KEY} + Stages: [...] +``` + +값 형식 (`_resolve_api_key` 함수, `src/agent.py`): +- `$VAR` 또는 `${VAR}` → 해당 환경 변수 값으로 resolve (없으면 빈 문자열) +- 그 외 → 평문 그대로 사용 + +> ⚠️ **보안 주의**: `/sse/agent/.../start` endpoint 는 SSE 시작 시 `agent_config["api_keys"] = None` 으로 강제해 yaml 의 `api_keys` 를 무시하고 환경 변수 기본값을 사용한다 (`src/server.py` 의 SSE_START 핸들러). 즉 yaml 에 평문 키를 박아도 SSE 실행 경로에서는 적용되지 않는다. yaml 의 `api_keys` 는 직접 `Agent(...)` 를 인스턴스화하는 스크립트/테스트 용도에만 효과가 있다. + +#### 3) Stage / Task 레벨 `api_key` (단일 키 override) + +특정 stage 또는 task 가 다른 provider/계정 키를 써야 할 때: + +```yaml +Stages: + - name: stage_premium + llm_provider: anthropic + llm_model: claude-opus-4-8 + api_key: $PREMIUM_ANTHROPIC_KEY # 이 stage 만 다른 키 + tasks: + - task_name: task_with_dedicated_key + llm_provider: google + llm_model: gemini-3.1-pro-preview + api_key: ${SPECIAL_GOOGLE_KEY} # 이 task 만 다른 키 + prompts: [...] +``` + +해석 순서 (Task 기반 모드, `src/agent.py` 의 task config 해석 분기): + +```python +task_api_key = _resolve_api_key(config.get("api_key")) or self.api_keys.get(task_provider) +``` + +즉 task 의 `api_key` 가 있으면 그것을 쓰고, 없으면 stage/agent 의 provider 별 `api_keys` dict 에서 검색. + +#### 권장 + +- **production**: 환경 변수만 사용. yaml 에는 키 명시하지 않음 +- **dev/test**: yaml 에 `$VAR` 형태로 환경 변수 참조 (평문 금지) +- **다중 계정/quota 분리**: stage/task 레벨 `api_key` 로 override + +### 0.6 API 로 yaml 실행 + +Agent 를 HTTP 로 실행하는 두 가지 경로가 있으며, **둘 다 user_id 를 명시해야 이용자 계정 워크스페이스(`/mcp-localdocs/users//...`)에 접근**한다. `user_id` 를 생략하면 `_anonymous` 버킷으로 격리돼서 이용자 파일이 안 보인다. + +#### 경로 A — 두 단계 (권장, 재실행 가능) + +Agent 를 서버에 등록한 뒤 필요할 때마다 SSE 로 실행. 등록된 YAML 은 `/mcp-localdocs/users//.agents/.yaml` 에 암호화 저장돼 재시작에도 살아남는다. + +**Step 1 — YAML 등록** + +```bash +curl -X POST "https://legalpoc.eroomai.com/api/upload-agent?user_id=jsahn" \ + -F "file=@Stage_1_Part_2.yml" +``` + +응답: +```json +{ + "success": true, + "agent_name": "Liti-agent_Civil_Suit_Plaintiff_Stage_1_Task_C_BO", + "endpoint": "/agent/Liti-agent_...", + "description": "...", + "stages": 1 +} +``` + +**Step 2 — SSE 실행** + +```bash +curl -N -X POST "https://legalpoc.eroomai.com/api/sse/agent//start" \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "jsahn", + "workspace_id": "e1d0baed-cec9-44af-9989-b7aab09bcfd1", + "user_input": "", + "start_stage_index": 0 + }' +``` + +응답은 [SSE stream](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) — `session_started`, `stage_start`, `task_start`, `task_iteration`, `task_complete`, `execution_complete` 등 이벤트가 순차 emit 된다. 첫 이벤트에 `session_id` 가 포함되며 이후 `POST /api/sse/agent/{name}/cancel/{session_id}` 로 중단, `POST /api/sse/agent/{name}/reconnect/{session_id}` 로 재연결한다. + +**Python 예시** (`httpx-sse` 사용): + +```python +import httpx +from httpx_sse import aconnect_sse +import asyncio, json + +BASE = "https://legalpoc.eroomai.com/api" +USER_ID = "jsahn" +WORKSPACE_ID = "e1d0baed-cec9-44af-9989-b7aab09bcfd1" + +async def upload_and_run(yaml_path: str, user_input: str = ""): + async with httpx.AsyncClient(timeout=None) as client: + # 1) upload + with open(yaml_path, "rb") as f: + up = await client.post( + f"{BASE}/upload-agent", + params={"user_id": USER_ID}, + files={"file": (yaml_path, f, "application/x-yaml")}, + ) + up.raise_for_status() + agent_name = up.json()["agent_name"] + + # 2) SSE start + async with aconnect_sse( + client, "POST", f"{BASE}/sse/agent/{agent_name}/start", + json={"user_id": USER_ID, "workspace_id": WORKSPACE_ID, "user_input": user_input}, + ) as event_source: + async for sse in event_source.aiter_sse(): + event = json.loads(sse.data) + if event.get("type") == "execution_complete": + return event.get("final_output") + print(event["type"], event.get("task_name", "")) + +asyncio.run(upload_and_run("Stage_1_Part_2.yml", user_input="")) +``` + +#### 경로 B — 한 방에 (Instant Run) + +등록 없이 YAML 을 body/파일로 넘겨 즉시 실행 + SSE. 일회성 실행에 유용하지만 재실행 시 매번 YAML 을 다시 보내야 한다. + +```bash +curl -N -X POST "https://legalpoc.eroomai.com/api/run-agent" \ + -H "Content-Type: application/json" \ + -d @<(jq -Rs --arg u jsahn --arg w e1d0baed-cec9-44af-9989-b7aab09bcfd1 \ + '{yaml: ., user_id: $u, workspace_id: $w, user_input: ""}' \ + Stage_1_Part_2.yml) +``` + +또는 multipart: +```bash +curl -N -X POST "https://legalpoc.eroomai.com/api/run-agent" \ + -F "file=@Stage_1_Part_2.yml" \ + -F "user_id=jsahn" \ + -F "workspace_id=e1d0baed-cec9-44af-9989-b7aab09bcfd1" \ + -F "user_input=" +``` + +#### 이용자 계정 격리 원리 + +- `user_id` 는 SHA-256 으로 hash 되어 `/mcp-localdocs/users//` 하위에 workspace 폴더 (``) 를 만든다. Agent 안의 `list_docs` / `read_docs` / `write_file` 호출은 자동으로 이 경로로 라우팅된다. +- YAML 안의 code-executor / direct-mcp task 도 `{{__user_hash__}}` / `{{__workspace_hash__}}` 템플릿 변수로 이용자 컨텍스트를 전달해야 한다 (자세한 내용은 §5.2). +- 관리자가 다른 유저의 세션을 조회할 때는 `/history/*` 엔드포인트의 `user_id` 쿼리 파라미터로 대상 유저 지정 (관리자 권한 검증은 향후 추가 예정). + +#### 실행 모니터링 + +- **실행 중 이벤트 재수신**: `POST /api/sse/agent/{name}/reconnect/{session_id}` — 클라이언트가 새로고침해도 세션 유지. +- **중단**: `POST /api/sse/agent/{name}/cancel/{session_id}` — 진행 중이던 task_runs 는 SKIPPED 로 표시된다. +- **과거 세션 목록**: `GET /api/history/sessions?user_id=&agent_name=` — 그 이용자 계정의 과거 실행 기록. +- **iteration 상세**: `GET /api/history/task_iterations_by_hash?session_hash=&task_name=&user_id=` — 특정 task 의 모든 LLM iteration 입출력. + +--- + +## 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` 명시 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 00000000..83da3268 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,1066 @@ +# 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` | 유저당 저장 한도 | +| `OPENAI_API_KEY` | (선택) | OpenAI 기본 API 키 | +| `ANTHROPIC_API_KEY` | (선택) | Anthropic 기본 API 키 | +| `GOOGLE_API_KEY` | (선택) | Google AI Studio 기본 API 키 | + +### 0.5 API 키 설정 + +LLM provider 별 API 키는 **세 곳에서** 지정할 수 있으며, **task → stage → agent → 환경 변수** 순서로 우선순위가 결정된다. + +#### 1) 환경 변수 (기본, 권장) + +서버 컨테이너에 `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY` 가 설정되어 있으면 `Agent` 생성자가 자동으로 읽어 `api_keys` dict 를 만든다 (`src/agent.py` 의 `Agent.__init__` 의 env fallback 분기). + +```dockerfile +# docker-compose 예시 +environment: + - OPENAI_API_KEY=sk-... + - ANTHROPIC_API_KEY=sk-ant-... + - GOOGLE_API_KEY=AIza... +``` + +이 경우 yaml 안에 `api_key`/`api_keys` 를 적을 필요가 없다. + +#### 2) Agent 최상위 `api_keys` (provider 별) + +```yaml +Agent: + name: my-agent + description: ... + version: 1.0 + api_keys: + openai: $OPENAI_API_KEY # 환경 변수 참조 ($VAR 또는 ${VAR}) + anthropic: sk-ant-literal-... # 평문 (비권장) + google: ${GOOGLE_API_KEY} + Stages: [...] +``` + +값 형식 (`_resolve_api_key` 함수, `src/agent.py`): +- `$VAR` 또는 `${VAR}` → 해당 환경 변수 값으로 resolve (없으면 빈 문자열) +- 그 외 → 평문 그대로 사용 + +> ⚠️ **보안 주의**: `/sse/agent/.../start` endpoint 는 SSE 시작 시 `agent_config["api_keys"] = None` 으로 강제해 yaml 의 `api_keys` 를 무시하고 환경 변수 기본값을 사용한다 (`src/server.py` 의 SSE_START 핸들러). 즉 yaml 에 평문 키를 박아도 SSE 실행 경로에서는 적용되지 않는다. yaml 의 `api_keys` 는 직접 `Agent(...)` 를 인스턴스화하는 스크립트/테스트 용도에만 효과가 있다. + +#### 3) Stage / Task 레벨 `api_key` (단일 키 override) + +특정 stage 또는 task 가 다른 provider/계정 키를 써야 할 때: + +```yaml +Stages: + - name: stage_premium + llm_provider: anthropic + llm_model: claude-opus-4-8 + api_key: $PREMIUM_ANTHROPIC_KEY # 이 stage 만 다른 키 + tasks: + - task_name: task_with_dedicated_key + llm_provider: google + llm_model: gemini-3.1-pro-preview + api_key: ${SPECIAL_GOOGLE_KEY} # 이 task 만 다른 키 + prompts: [...] +``` + +해석 순서 (Task 기반 모드, `src/agent.py` 의 task config 해석 분기): + +```python +task_api_key = _resolve_api_key(config.get("api_key")) or self.api_keys.get(task_provider) +``` + +즉 task 의 `api_key` 가 있으면 그것을 쓰고, 없으면 stage/agent 의 provider 별 `api_keys` dict 에서 검색. + +#### 권장 + +- **production**: 환경 변수만 사용. yaml 에는 키 명시하지 않음 +- **dev/test**: yaml 에 `$VAR` 형태로 환경 변수 참조 (평문 금지) +- **다중 계정/quota 분리**: stage/task 레벨 `api_key` 로 override + +### 0.6 API 로 yaml 실행 + +Agent 를 HTTP 로 실행하는 두 가지 경로가 있으며, **둘 다 user_id 를 명시해야 이용자 계정 워크스페이스(`/mcp-localdocs/users//...`)에 접근**한다. `user_id` 를 생략하면 `_anonymous` 버킷으로 격리돼서 이용자 파일이 안 보인다. + +#### 경로 A — 두 단계 (권장, 재실행 가능) + +Agent 를 서버에 등록한 뒤 필요할 때마다 SSE 로 실행. 등록된 YAML 은 `/mcp-localdocs/users//.agents/.yaml` 에 암호화 저장돼 재시작에도 살아남는다. + +**Step 1 — YAML 등록** + +```bash +curl -X POST "https://legalpoc.eroomai.com/api/upload-agent?user_id=jsahn" \ + -F "file=@Stage_1_Part_2.yml" +``` + +응답: +```json +{ + "success": true, + "agent_name": "Liti-agent_Civil_Suit_Plaintiff_Stage_1_Task_C_BO", + "endpoint": "/agent/Liti-agent_...", + "description": "...", + "stages": 1 +} +``` + +**Step 2 — SSE 실행** + +```bash +curl -N -X POST "https://legalpoc.eroomai.com/api/sse/agent//start" \ + -H "Content-Type: application/json" \ + -d '{ + "user_id": "jsahn", + "workspace_id": "e1d0baed-cec9-44af-9989-b7aab09bcfd1", + "user_input": "", + "start_stage_index": 0 + }' +``` + +응답은 [SSE stream](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) — `session_started`, `stage_start`, `task_start`, `task_iteration`, `task_complete`, `execution_complete` 등 이벤트가 순차 emit 된다. 첫 이벤트에 `session_id` 가 포함되며 이후 `POST /api/sse/agent/{name}/cancel/{session_id}` 로 중단, `POST /api/sse/agent/{name}/reconnect/{session_id}` 로 재연결한다. + +**Python 예시** (`httpx-sse` 사용): + +```python +import httpx +from httpx_sse import aconnect_sse +import asyncio, json + +BASE = "https://legalpoc.eroomai.com/api" +USER_ID = "jsahn" +WORKSPACE_ID = "e1d0baed-cec9-44af-9989-b7aab09bcfd1" + +async def upload_and_run(yaml_path: str, user_input: str = ""): + async with httpx.AsyncClient(timeout=None) as client: + # 1) upload + with open(yaml_path, "rb") as f: + up = await client.post( + f"{BASE}/upload-agent", + params={"user_id": USER_ID}, + files={"file": (yaml_path, f, "application/x-yaml")}, + ) + up.raise_for_status() + agent_name = up.json()["agent_name"] + + # 2) SSE start + async with aconnect_sse( + client, "POST", f"{BASE}/sse/agent/{agent_name}/start", + json={"user_id": USER_ID, "workspace_id": WORKSPACE_ID, "user_input": user_input}, + ) as event_source: + async for sse in event_source.aiter_sse(): + event = json.loads(sse.data) + if event.get("type") == "execution_complete": + return event.get("final_output") + print(event["type"], event.get("task_name", "")) + +asyncio.run(upload_and_run("Stage_1_Part_2.yml", user_input="")) +``` + +#### 경로 B — 한 방에 (Instant Run) + +등록 없이 YAML 을 body/파일로 넘겨 즉시 실행 + SSE. 일회성 실행에 유용하지만 재실행 시 매번 YAML 을 다시 보내야 한다. + +```bash +curl -N -X POST "https://legalpoc.eroomai.com/api/run-agent" \ + -H "Content-Type: application/json" \ + -d @<(jq -Rs --arg u jsahn --arg w e1d0baed-cec9-44af-9989-b7aab09bcfd1 \ + '{yaml: ., user_id: $u, workspace_id: $w, user_input: ""}' \ + Stage_1_Part_2.yml) +``` + +또는 multipart: +```bash +curl -N -X POST "https://legalpoc.eroomai.com/api/run-agent" \ + -F "file=@Stage_1_Part_2.yml" \ + -F "user_id=jsahn" \ + -F "workspace_id=e1d0baed-cec9-44af-9989-b7aab09bcfd1" \ + -F "user_input=" +``` + +#### 이용자 계정 격리 원리 + +- `user_id` 는 SHA-256 으로 hash 되어 `/mcp-localdocs/users//` 하위에 workspace 폴더 (``) 를 만든다. Agent 안의 `list_docs` / `read_docs` / `write_file` 호출은 자동으로 이 경로로 라우팅된다. +- YAML 안의 code-executor / direct-mcp task 도 `{{__user_hash__}}` / `{{__workspace_hash__}}` 템플릿 변수로 이용자 컨텍스트를 전달해야 한다 (자세한 내용은 §5.2). +- 관리자가 다른 유저의 세션을 조회할 때는 `/history/*` 엔드포인트의 `user_id` 쿼리 파라미터로 대상 유저 지정 (관리자 권한 검증은 향후 추가 예정). + +#### 실행 모니터링 + +- **실행 중 이벤트 재수신**: `POST /api/sse/agent/{name}/reconnect/{session_id}` — 클라이언트가 새로고침해도 세션 유지. +- **중단**: `POST /api/sse/agent/{name}/cancel/{session_id}` — 진행 중이던 task_runs 는 SKIPPED 로 표시된다. +- **과거 세션 목록**: `GET /api/history/sessions?user_id=&agent_name=` — 그 이용자 계정의 과거 실행 기록. +- **iteration 상세**: `GET /api/history/task_iterations_by_hash?session_hash=&task_name=&user_id=` — 특정 task 의 모든 LLM iteration 입출력. + +--- + +## 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` 명시 diff --git a/TEST_WORKFLOW.md b/TEST_WORKFLOW.md new file mode 100644 index 00000000..14dc2517 --- /dev/null +++ b/TEST_WORKFLOW.md @@ -0,0 +1,144 @@ +# Agent YAML 실행 워크플로우 — 테스트 방법 + +지정한 Agent YAML 을 AgentBackend API 로 실행하는 Gitea Actions 워크플로우 +([.gitea/workflows/run-agent.yml](.gitea/workflows/run-agent.yml) + +[scripts/run_agent_api.py](scripts/run_agent_api.py)) 를 돌리는 방법 정리. + +실행 흐름 (SKILL.md §0.6 경로 A): +`workspaces/lookup`(이름→UUID) → `upload-agent` 등록 → SSE `start` → +`stage_complete` 자동 confirm → `execution_complete`. +전체 이벤트/`final_output`/`summary` 는 아티팩트로 저장된다. + +--- + +## 0. `.env` 설정 (토큰) + +> ⚠️ **`.env` 는 절대 커밋하지 말 것.** `.gitignore` 에 등록되어 있다 +> (`git check-ignore .env` 로 확인). 토큰 값은 이 문서에도 적지 않는다. + +프로젝트 루트에 `.env` 파일을 만들고 아래 형식으로 채운다 (값은 실제 토큰): + +```dotenv +GITEA_TOKEN= +``` + +- 토큰 발급: Gitea → Settings → Applications → Generate New Token +- 필요한 스코프: **`write:repository`** +- 사용 후에는 **폐기(revoke)** 권장. 만료를 짧게 설정할 것. +- 템플릿: [.env.example](.env.example) 참고 (빈 값, 커밋되어도 안전). + +--- + +## 방법 A — Gitea API 로 트리거 (설계 의도: 러너가 실행) + +`.env` 의 토큰을 읽어 `workflow_dispatch` 를 호출한다. **토큰을 화면에 출력하지 말 것.** + +```bash +cd /Users/jsahn/Works/Liti-agent-Development +set -a; . ./.env; set +a # .env 로드 ($GITEA_TOKEN) + +curl -s -X POST \ + "https://git.eroomai.com/api/v1/repos/jhogyu/Liti-agent-Development/actions/workflows/run-agent.yml/dispatches" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "ref": "main", + "inputs": { + "yaml_path": "Case_02_Comparison_Research/YAML_Prompts/1. Stage_1/v.5/Stage_1_Part_1.yml", + "workspace_name": "팬아웃 테스트", + "user_id": "jsahn" + } + }' +# 성공 시 HTTP 204 (본문 없음) +``` + +### 실행 상태 폴링 + +```bash +# 최근 run 목록 +curl -s -H "Authorization: token $GITEA_TOKEN" \ + "https://git.eroomai.com/api/v1/repos/jhogyu/Liti-agent-Development/actions/tasks?limit=5" \ + | python3 -m json.tool +``` + +또는 웹 UI: `https://git.eroomai.com/jhogyu/Liti-agent-Development/actions` 의 +run 상세 페이지에서 로그 확인. 완료 후 하단 **Artifacts** 에서 +`agent-run-<번호>` (events.jsonl / final_output.txt / summary.md) 다운로드. + +--- + +## 방법 B — Gitea 웹 UI 에서 실행 + +1. Actions 탭 → **Run Agent YAML via API** 선택 +2. **Run workflow** 클릭 (⚠️ 실패한 run 의 **Re-run 은 금지** — 옛 커밋의 워크플로우가 실행됨) +3. 입력 채우기: + - `yaml_path`: 실행할 YAML 의 repo 상대경로 + - `workspace_name`: 작업실 이름 (예: `팬아웃 테스트`) — UUID 자동 해석 + - (또는 `workspace_id` 에 UUID 직접 입력 — override) +4. 실행 → run 페이지에서 로그/아티팩트 확인 + +--- + +## 방법 C — 로컬에서 스크립트 직접 실행 (CI 안 거침) + +backend 에 네트워크로 직접 붙어 실행. `httpx`, `httpx-sse` 필요. + +```bash +pip install httpx httpx-sse +python scripts/run_agent_api.py \ + --yaml-path="Case_02_Comparison_Research/YAML_Prompts/1. Stage_1/v.5/Stage_1_Part_1.yml" \ + --workspace-name="팬아웃 테스트" \ + --user-id="jsahn" \ + --api-base="http://100.93.221.71:8800" \ + --output-dir=agent_run_output +``` + +- 러너에서 돌 때의 `api_base` 는 `http://agent-backend:8000` (컨테이너 네트워크). +- 이 머신(Tailscale) 에서 직접 검증할 때는 `http://100.93.221.71:8800` + (= `eroomaiserver.tailabbfd5.ts.net`, 실제 데이터가 있는 backend). + +--- + +## 워크플로우 입력 레퍼런스 + +| 입력 | 필수 | 기본값 | 설명 | +|------|------|--------|------| +| `yaml_path` | ✅ | — | 실행할 Agent YAML 의 repo 상대경로 | +| `workspace_name` | △ | `""` | 작업실 이름 → `GET /workspaces/lookup` 으로 UUID 해석 | +| `workspace_id` | △ | `""` | 작업실 UUID 직접 지정(override). 비우면 name 으로 해석 | +| `user_id` | | `jsahn` | AgentBackend user_id | +| `user_input` | | `""` | Agent 에 전달할 user_input | +| `start_stage_index` | | `0` | 시작 stage 인덱스 | +| `api_base` | | `http://agent-backend:8000` | 내부 컨테이너 주소 (/api 접두사 없음) | +| `max_runtime_seconds` | | `9000` | 총 실행 상한(초). 3h infra 상한보다 짧게 | + +> `workspace_name` 또는 `workspace_id` **중 하나는 반드시** 지정 (둘 다 비우면 실패). + +작업실 이름↔UUID 목록 확인: +```bash +curl -s "http://100.93.221.71:8800/workspaces/lookup?user_id=jsahn" | python3 -m json.tool +``` + +--- + +## 전제 조건 (서버 인프라) + +| 항목 | 설정 | 이유 | +|------|------|------| +| act_runner 네트워크 | `config.yaml` 의 `container.network` = 백엔드 도커 네트워크 | job 컨테이너가 `agent-backend:8000` 을 DNS 로 찾으려면 같은 네트워크여야 함 | +| 동시 실행 | `config.yaml` 의 `runner.capacity` 상향 (예: 20) | agent job 은 SSE 대기(I/O 바운드)라 슬롯을 오래 점유 → capacity 낮으면 큐 적체 | +| 아티팩트 | `actions/upload-artifact@v3` (v4 아님) | Gitea 는 아티팩트 v3 프로토콜만 지원 (v4 는 GHESNotSupportedError) | +| 실행 시간 | `max_runtime`(150m) < `timeout-minutes`(175m) < 3h | Gitea/act_runner 3h 강제종료 전에 스스로 정리 | + +--- + +## 트러블슈팅 (겪었던 이슈) + +| 증상 | 원인 | 해결 | +|------|------|------| +| `HTTP 302` → Google OAuth 로 리다이렉트 | 공개 URL(legalpoc.eroomai.com)이 OAuth 프록시 뒤 | `api_base` 를 내부 주소로 | +| 고쳤는데도 계속 302 | 실패 run 을 **Re-run** (옛 커밋 실행) | **Run workflow** 로 새로 실행 | +| `Name or service not known` | job 컨테이너가 백엔드 네트워크 밖 | act_runner `container.network` 설정 | +| job 이 pending 에서 대기 | `capacity=1` 로 슬롯 부족 | `runner.capacity` 상향 | +| `GHESNotSupportedError` (아티팩트) | Gitea 는 v4 미지원 | `upload-artifact@v3` | +| `/workspaces` 빈 배열 | `eroom-server`(다른 머신, 빈 DB) 로 요청 | `eroomaiserver`(100.93.221.71) 로, 경로는 `/workspaces/lookup` |