Skip to content

Commit 3483064

Browse files
SonAIengineclaude
andcommitted
fix: fill_form UX — 실측 LLM 실패 패턴 기반 description/SYSTEM 강화
Ollama qwen2.5:14b / qwen3.5:4b 매트릭스 실험에서 관찰된 실패 모드를 반영. 관찰된 문제: - qwen3.5:4b (작은 모델) 가 복잡한 시나리오에서 tool call 대신 응답 텍스트에 JSON 코드블록 작성 (```json {"fill_form": {...}}```) — 실행 안 됨. - LLM 들이 output_path 를 불필요하게 명시 (path 와 동일하게) — 기본 동작 설명 부족. - direction 선택 기준이 모호 — "예시값 있는 양식은 right" 가이드 부족. tools.py 의 fill_form description 강화: - direction 선택 기준을 "빈 양식 vs 예시값 있는 양식" 으로 명확 구분 (a/b 분기) - output_path: "생략 시 원본 파일에 덮어쓰기 (대부분 경우 생략 권장)" 명시 - dot-path 예시 포함: `{'피해자.금액': '...', '지급정지.금액': '...'}` - ambiguous 반환에 context/hint 필드 언급 SYSTEM 프롬프트 (claude_api_example / ollama_example / ollama_scenarios): - "⚠ 반드시 tools API 로 호출. 응답 텍스트에 JSON 코드블록이나 함수 호출 문법을 직접 적지 마세요" — 작은 모델의 JSON hallucination 방지 - direction 분기 규칙을 워크플로우 단계 3 으로 명시화 - dot-path 재호출 예시 1 줄로 간결히 검증 (description 개선 후 재실험): - Before: qwen3.5:4b 시나리오 C (dot-path 해소) 에서 **tool call 실패** — JSON 만 text 에 뱉음. - After: qwen3.5:4b 시나리오 C **68 초에 fill_form + dot-path 성공** (피해자/ 지급정지 각각 금액·금융회사 4 라벨 모두 채움). - qwen2.5:14b 도 시나리오 A/B/C 전부 의도대로 동작 유지. xgen-workflow tools.py description 도 동일 개선 반영 (별도 커밋). 41 smoke 전부 그린. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8b4fa2d commit 3483064

4 files changed

Lines changed: 298 additions & 21 deletions

File tree

document_adapter/tools.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,25 @@
177177
"description": (
178178
"라벨 이름으로 값 셀을 자동 탐지해 **일괄 채우기**. 좌표 (table_index, row, col) "
179179
"계산 없이 '접수번호', '성명' 같은 라벨 key-value dict 로 양식 채움. "
180-
"auto 모드: 라벨 셀 오른쪽 → 아래 → 같은 셀 순으로 값 셀 탐색. "
181-
"오른쪽/아래 셀이 사용자 요청 라벨 중 하나이면 (서로 라벨 공간 보호) skip 후 다음 시도. "
182-
"같은 셀로 fallback 시 append_to_cell 로 라벨 뒤에 값 덧붙임. "
183-
"**팁**: 한 양식의 관련 라벨을 함께 넘기면 라벨끼리 서로 보호하여 덮어쓰기 방지. "
184-
"반환: {filled:[...], not_found:[...], ambiguous:[...]}."
180+
"\n"
181+
"**direction 선택 기준 (중요)**: "
182+
"(a) `auto` (기본, 보수적): 기존 값이 있는 셀은 다른 라벨로 간주하고 skip → 같은 "
183+
"셀에 append_to_cell. **값 셀이 비어있는 양식**에 적합 (HWPX 공공 서식 등). "
184+
"(b) `right` / `below` (명시): 라벨 오른쪽/아래 셀을 **덮어쓰기**. **기존에 "
185+
"예시값이 채워져 있는 양식**(PPTX 템플릿 등) 에는 반드시 direction='right' 명시. "
186+
"\n"
187+
"**Dot-path 섹션 지정**: 같은 라벨이 여러 섹션에 있어 ambiguous 로 반환되면 "
188+
"`{'피해자.금액': '...', '지급정지요청계좌.금액': '...'}` 처럼 섹션힌트.라벨 "
189+
"형태로 재호출. ambiguous 반환의 hint 필드에 예시 제공됨. "
190+
"\n"
191+
"**output_path**: 생략 시 **원본 파일에 덮어쓰기** (대부분의 경우 생략 권장). "
192+
"다른 위치에 저장이 필요할 때만 지정. "
193+
"\n"
194+
"**팁**: 한 양식의 관련 라벨을 **한 번에 dict 로** 넘기면 라벨끼리 서로 보호되어 "
195+
"인접 라벨 오염이 방지됩니다. "
196+
"\n"
197+
"반환: `{filled: [...], not_found: [...], ambiguous: [...]}`. "
198+
"ambiguous candidates 각각에 `context` 필드 포함 (어느 섹션인지 확인)."
185199
),
186200
"input_schema": {
187201
"type": "object",

examples/claude_api_example.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,21 @@
2828

2929
SYSTEM = """당신은 DOCX / PPTX / HWPX 양식 문서를 편집하는 에이전트입니다.
3030
31+
⚠ **반드시 tools API 로 호출**하세요. 응답 텍스트에 JSON 코드블록이나 함수
32+
호출 문법을 직접 작성하지 마세요 — 그건 호출되지 않습니다.
33+
3134
워크플로우:
32-
1. **먼저 inspect_document 로 구조 파악** — placeholders, 표의 preview, 병합 셀,
35+
1. **먼저 inspect_document 로 구조 파악** — placeholders, preview, 병합 셀,
3336
column_widths_cm / row_heights_cm (오버플로 방지 힌트).
34-
2. **여러 셀을 라벨로 채우는 경우 fill_form 1 회 호출을 우선**. set_cell 반복보다
35-
iteration 효율이 높고 라벨 오염이 덜함. direction 기본 auto 는 보수적이라
36-
예시값 덮어쓰기가 목적이면 direction="right" 명시.
37-
3. 같은 라벨이 여러 섹션에 있어 ambiguous 반환 받으면 `"피해자.금액"` 같은
38-
dot-path 로 재호출.
39-
4. 셀 크기 (width_cm, char_count) 를 보고 좁은 셀에는 짧은 값만.
37+
2. **fill_form 1 회 호출로 여러 셀을 한 번에 채우는 것을 우선**. set_cell 반복보다
38+
iteration 효율이 높고 라벨 오염이 덜함.
39+
3. direction 선택:
40+
- **값 셀이 비어있는 양식** (HWPX 공공 서식) → direction 생략 (auto).
41+
- **기존 예시값이 있는 양식** (PPTX 템플릿) → direction="right" 명시.
42+
4. 같은 라벨이 여러 섹션에 있어 ambiguous 반환받으면 dot-path 로 재호출:
43+
fill_form({"피해자.금액": "1,000,000", "지급정지.금액": "2,000,000"})
44+
5. output_path 는 생략 (원본에 덮어쓰기). 별도 저장 필요할 때만 지정.
45+
6. 셀 크기 (width_cm, char_count) 를 보고 좁은 셀에는 짧은 값만.
4046
4147
**중요**:
4248
- inspect_document 는 세션당 1 회면 충분 (구조는 편집 후 변하지 않음).

examples/ollama_example.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,24 @@
3131

3232
SYSTEM = """당신은 DOCX / PPTX / HWPX 양식 문서를 편집하는 에이전트입니다.
3333
34+
⚠ **반드시 tools API 로 호출**하세요. 응답 텍스트에 JSON 코드블록이나 함수 호출
35+
문법 (```json {"fill_form": {...}}``` 같은 것) 을 직접 적지 마세요. 그것은
36+
호출되지 않습니다.
37+
3438
워크플로우:
35-
1. 먼저 inspect_document 로 구조 파악 — placeholders, 표 preview, 병합 셀,
39+
1. 먼저 `inspect_document` 로 구조 파악 — placeholders, 표 preview, 병합 셀,
3640
column_widths_cm / row_heights_cm (오버플로 방지 힌트).
37-
2. 여러 셀을 라벨로 채우는 경우 fill_form 1 회 호출을 우선. set_cell 반복보다
38-
효율적이고 라벨 오염이 덜함. direction 기본 auto 는 보수적이라 예시값
39-
덮어쓰기가 목적이면 direction="right" 명시.
40-
3. 같은 라벨이 여러 섹션에 있어 ambiguous 반환받으면 "피해자.금액" 같은
41-
dot-path 로 재호출.
42-
4. 셀 크기 (width_cm, char_count) 를 보고 좁은 셀에는 짧은 값만 넣기.
43-
44-
중요: inspect_document 는 세션당 1 회면 충분. 편집 도구 반환 문자열로 성공/실패
41+
2. 여러 셀을 라벨로 채우는 경우 `fill_form` 1 회 호출을 우선. set_cell 반복보다
42+
효율적.
43+
3. direction 선택:
44+
- **값 셀이 비어있는 양식** (HWPX 공공 서식 등) → direction 생략 (auto).
45+
- **기존 예시값이 있는 양식** (PPTX 템플릿 등) → direction="right" 명시.
46+
4. 같은 라벨이 여러 섹션에 있어 `ambiguous` 반환받으면 dot-path 로 재호출:
47+
fill_form({{"피해자.금액": "1,000,000", "지급정지.금액": "2,000,000"}})
48+
5. output_path 는 기본적으로 생략 (원본에 덮어쓰기). 별도 저장 필요할 때만 지정.
49+
6. 셀 크기 (width_cm, char_count) 를 보고 좁은 셀에는 짧은 값만 넣기.
50+
51+
⚠ inspect_document 는 세션당 1 회면 충분. 편집 도구 반환 문자열로 성공/실패
4552
판단하고 재확인 목적 inspect 호출 금지.
4653
"""
4754

scripts/ollama_scenarios.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""Ollama 로 여러 모델 × 여러 시나리오 매트릭스 실험.
2+
3+
UX 개선을 위한 실측 데이터 수집 — LLM 이 fill_form / direction / dot-path 를
4+
어떻게 사용하는지 관찰.
5+
6+
실행:
7+
ssh -L 11434:localhost:11434 -N home &
8+
python scripts/ollama_scenarios.py
9+
"""
10+
from __future__ import annotations
11+
12+
import json
13+
import shutil
14+
import sys
15+
import tempfile
16+
import time
17+
from dataclasses import dataclass, field
18+
from pathlib import Path
19+
20+
ROOT = Path(__file__).resolve().parent.parent
21+
sys.path.insert(0, str(ROOT))
22+
23+
from ollama import Client
24+
25+
from document_adapter.tools import TOOL_DEFINITIONS, call_tool
26+
27+
28+
MODELS = ["qwen2.5:14b", "qwen3.5:4b"]
29+
30+
SYSTEM = """당신은 DOCX / PPTX / HWPX 양식 문서를 편집하는 에이전트입니다.
31+
32+
⚠ **반드시 tools API 로 호출**하세요. 응답 텍스트에 JSON 코드블록이나 함수 호출
33+
문법을 직접 적지 마세요 — 그건 호출되지 않습니다.
34+
35+
워크플로우:
36+
1. `inspect_document` 로 구조 파악.
37+
2. `fill_form` 1 회 호출을 우선 — 여러 셀을 한 번에 채움.
38+
3. direction 선택:
39+
- 값 셀이 비어있는 양식 → direction 생략 (auto).
40+
- 기존 예시값이 있는 양식 → direction="right" 명시.
41+
4. 같은 라벨이 여러 섹션에 있어 ambiguous 반환받으면 dot-path 재호출:
42+
fill_form({"피해자.금액": "1,000,000", "지급정지.금액": "2,000,000"})
43+
5. output_path 는 생략 (원본 덮어쓰기).
44+
"""
45+
46+
47+
SCENARIOS = [
48+
{
49+
"name": "A. HWPX blank 양식 — auto 보수적 append 기대",
50+
"fixture": "tests/fixtures/hwpx/real/stop_payment_blank.hwpx",
51+
"instruction": (
52+
"이 양식을 다음 정보로 채워줘: "
53+
"접수번호=2026-0001, 접수일자=2026-04-17, "
54+
"성명=홍길동, 주소=서울시 강남구"
55+
),
56+
},
57+
{
58+
"name": "B. PPTX 예시값 있는 양식 — direction='right' 사용 기대",
59+
"fixture": "tests/fixtures/pptx/real/ai_plan_small.pptx",
60+
"instruction": (
61+
"이 보고서 양식의 기존 예시값을 다음으로 교체해줘: "
62+
"보고일자=2026-04-17, 작성자=홍길동, 담당부서=개발팀. "
63+
"기존 값은 예시일 뿐이라 덮어써야 해."
64+
),
65+
},
66+
{
67+
"name": "C. HWPX 복잡 양식 — ambiguous 해소 기대 (dot-path)",
68+
"fixture": "tests/fixtures/hwpx/real/stop_payment_blank.hwpx",
69+
"instruction": (
70+
"이 양식의 피해자 정보 섹션과 지급정지요청계좌 섹션 각각에 "
71+
"금융회사=국민은행, 금액=1,000,000원 을 채워줘. "
72+
"두 섹션에 같은 라벨이 있으니 구분이 필요해."
73+
),
74+
},
75+
]
76+
77+
78+
@dataclass
79+
class Run:
80+
model: str
81+
scenario: str
82+
turns: int = 0
83+
elapsed_s: float = 0.0
84+
prompt_tokens: int = 0
85+
completion_tokens: int = 0
86+
tool_sequence: list[str] = field(default_factory=list)
87+
used_fill_form: bool = False
88+
used_direction_right: bool = False
89+
used_dot_path: bool = False
90+
errors: list[str] = field(default_factory=list)
91+
final_text: str = ""
92+
93+
94+
def to_openai_tools(tool_defs):
95+
return [
96+
{
97+
"type": "function",
98+
"function": {
99+
"name": t["name"],
100+
"description": t["description"],
101+
"parameters": t["input_schema"],
102+
},
103+
}
104+
for t in tool_defs
105+
]
106+
107+
108+
def run_one(model: str, scenario: dict, verbose: bool = True) -> Run:
109+
run = Run(model=model, scenario=scenario["name"])
110+
src = ROOT / scenario["fixture"]
111+
112+
# fixture 를 임시 파일에 copy — 원본 보호
113+
suffix = src.suffix
114+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
115+
shutil.copy2(src, tmp.name)
116+
working_path = tmp.name
117+
118+
try:
119+
client = Client(host="http://localhost:11434")
120+
tools = to_openai_tools(TOOL_DEFINITIONS)
121+
messages = [
122+
{"role": "system", "content": SYSTEM},
123+
{
124+
"role": "user",
125+
"content": f"문서: {working_path}\n\n요청: {scenario['instruction']}",
126+
},
127+
]
128+
129+
t_start = time.time()
130+
for turn in range(8):
131+
resp = client.chat(
132+
model=model,
133+
messages=messages,
134+
tools=tools,
135+
options={"num_predict": 1024, "temperature": 0.2},
136+
)
137+
msg = resp.message
138+
content = getattr(msg, "content", None) or ""
139+
tool_calls = getattr(msg, "tool_calls", None) or []
140+
run.turns = turn + 1
141+
run.prompt_tokens = max(run.prompt_tokens, getattr(resp, "prompt_eval_count", 0) or 0)
142+
run.completion_tokens += getattr(resp, "eval_count", 0) or 0
143+
144+
if verbose:
145+
print(f" turn {turn+1}: calls={len(tool_calls)} content_len={len(content)}")
146+
147+
if not tool_calls:
148+
run.final_text = content
149+
break
150+
151+
messages.append({
152+
"role": "assistant",
153+
"content": content,
154+
"tool_calls": [
155+
{
156+
"function": {
157+
"name": tc.function.name,
158+
"arguments": tc.function.arguments or {},
159+
}
160+
}
161+
for tc in tool_calls
162+
],
163+
})
164+
for tc in tool_calls:
165+
name = tc.function.name
166+
args = tc.function.arguments or {}
167+
if isinstance(args, str):
168+
try:
169+
args = json.loads(args)
170+
except json.JSONDecodeError:
171+
args = {}
172+
run.tool_sequence.append(name)
173+
if name == "fill_form":
174+
run.used_fill_form = True
175+
if args.get("direction") == "right":
176+
run.used_direction_right = True
177+
data = args.get("data", {})
178+
if any("." in str(k) for k in data.keys()):
179+
run.used_dot_path = True
180+
try:
181+
result = call_tool(name, args)
182+
except Exception as e:
183+
run.errors.append(f"{name}: {type(e).__name__}: {e}")
184+
result = {"error": str(e)}
185+
messages.append({
186+
"role": "tool",
187+
"content": json.dumps(result, ensure_ascii=False),
188+
})
189+
run.elapsed_s = time.time() - t_start
190+
finally:
191+
try:
192+
Path(working_path).unlink()
193+
except OSError:
194+
pass
195+
return run
196+
197+
198+
def print_run(r: Run) -> None:
199+
print(f"\n [{r.model}] tool seq: {r.tool_sequence}")
200+
print(
201+
f" turns={r.turns} elapsed={r.elapsed_s:.1f}s "
202+
f"prompt={r.prompt_tokens} completion={r.completion_tokens}"
203+
)
204+
flags = []
205+
if r.used_fill_form:
206+
flags.append("fill_form ✓")
207+
else:
208+
flags.append("fill_form ✗")
209+
if r.used_direction_right:
210+
flags.append("direction=right ✓")
211+
if r.used_dot_path:
212+
flags.append("dot-path ✓")
213+
print(f" flags: {', '.join(flags)}")
214+
if r.errors:
215+
print(f" errors: {r.errors[:2]}")
216+
if r.final_text:
217+
print(f" final: {r.final_text[:200]}")
218+
219+
220+
def main() -> int:
221+
results: list[Run] = []
222+
for scenario in SCENARIOS:
223+
print(f"\n{'=' * 72}\n{scenario['name']}\n{'=' * 72}")
224+
for model in MODELS:
225+
print(f"\n{model}")
226+
try:
227+
r = run_one(model, scenario, verbose=True)
228+
except Exception as e:
229+
r = Run(model=model, scenario=scenario["name"])
230+
r.errors.append(f"run crashed: {type(e).__name__}: {e}")
231+
results.append(r)
232+
print_run(r)
233+
234+
# 매트릭스 요약
235+
print(f"\n{'=' * 72}\n요약 매트릭스\n{'=' * 72}")
236+
print(f"{'Model':<14} {'Scen':<5} {'Turns':>5} {'Time':>7} {'fill_form':>10} {'right':>6} {'dot-path':>9}")
237+
print("-" * 72)
238+
scen_ids = ["A", "B", "C"]
239+
for r in results:
240+
scen_id = next((s for s in scen_ids if r.scenario.startswith(s)), "?")
241+
print(
242+
f"{r.model:<14} {scen_id:<5} {r.turns:>5} {r.elapsed_s:>6.1f}s "
243+
f"{str(r.used_fill_form):>10} {str(r.used_direction_right):>6} "
244+
f"{str(r.used_dot_path):>9}"
245+
)
246+
return 0
247+
248+
249+
if __name__ == "__main__":
250+
sys.exit(main())

0 commit comments

Comments
 (0)