Skip to content

Commit 897ee96

Browse files
authored
Merge pull request #19 from 2026-KUSITMS-GLIT/fix/#18-tagging-json-parse-prefix
fix: tagging JSON 파싱 prefix 흡수 (assistant prefill + 슬라이스 fallback)
2 parents 8062bea + faf0730 commit 897ee96

2 files changed

Lines changed: 109 additions & 19 deletions

File tree

app/services/tagging/v2_postscore.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,16 @@
6161
)
6262

6363

64-
_MAX_TOKENS = 250
65-
"""v1 의 200 보다 +50 — 후보가 5~7개라 output 토큰 약간 증가."""
64+
_MAX_TOKENS = 350
65+
"""5~7개 후보 + JSON 오버헤드 + 도입어/공백 prefix 흡수 마진. 운영 실측 (issue #18)
66+
에서 1건이 250 으로 잘려 ``stop_reason=max_tokens`` 발생 → 안전 마진 추가."""
67+
68+
69+
_ASSISTANT_PREFILL = "{"
70+
"""1차/2차 호출 모두 assistant turn 으로 ``{`` 를 prefill 해 응답 첫 글자를 강제한다.
71+
Haiku 가 ``다음과 같습니다:`` 같은 도입어를 자주 붙여 ``char 0`` JSON 파싱 실패를
72+
유발하던 운영 이슈 (issue #18 — 24h 검증 실패 164회 중 95%) 대응. 응답 텍스트에
73+
``_ASSISTANT_PREFILL + raw`` 로 다시 합쳐 파싱한다."""
6674

6775

6876
_TOKEN_RE = re.compile(r"\{(jobRole|primaryCategory|situationTask|action|result)\}")
@@ -94,6 +102,24 @@ def _strip_code_fence(text: str) -> str:
94102
return text
95103

96104

105+
def _extract_json_payload(text: str) -> str:
106+
"""LLM 응답에서 JSON object 본문만 추출.
107+
108+
1) 백틱 코드 펜스 (1~3개) 가 둘러쌌으면 벗긴다.
109+
2) 결과에서 첫 ``{`` 부터 마지막 ``}`` 까지 슬라이스해 반환. Haiku 가 응답 앞뒤에
110+
도입어 (``다음과 같습니다:``) · markdown 헤딩 · 공백 같은 prefix/suffix 를 자주
111+
붙여 ``^{`` 만 허용하면 운영 실패율이 과도하게 높다 (issue #18 — 24h 검증 실패
112+
164회 중 95% 가 ``char 0`` JSON 파싱 실패).
113+
3) 어느 단계도 매치 안 되면 원본 그대로 반환 — downstream parser 가 raise.
114+
"""
115+
text = _strip_code_fence(text)
116+
start = text.find("{")
117+
end = text.rfind("}")
118+
if start != -1 and end > start:
119+
return text[start : end + 1]
120+
return text
121+
122+
97123
def _parse_and_validate(raw: str) -> list[str]:
98124
"""후보 검증 — 갯수 :data:`_CANDIDATE_MIN`~:data:`_CANDIDATE_MAX`, 풀 내, 중복 X.
99125
@@ -102,7 +128,7 @@ def _parse_and_validate(raw: str) -> list[str]:
102128
프롬프트 명세("카테고리 무관, 풀 전체 열려 있다") 와 일치하도록 카테고리
103129
좁힘은 적용하지 않는다.
104130
"""
105-
text = _strip_code_fence(raw)
131+
text = _extract_json_payload(raw)
106132
try:
107133
payload = orjson.loads(text)
108134
except orjson.JSONDecodeError as exc:
@@ -169,17 +195,25 @@ async def run(req: TaggingRequest, llm: LLMClient, model: str) -> TaggingRespons
169195
workload=WorkloadType.TAGGING,
170196
model=model,
171197
system=system,
172-
messages=[{"role": "user", "content": _USER_TRIGGER}],
198+
messages=[
199+
{"role": "user", "content": _USER_TRIGGER},
200+
{"role": "assistant", "content": _ASSISTANT_PREFILL},
201+
],
173202
max_tokens=_MAX_TOKENS,
174203
)
175-
first_raw = _extract_text(first_msg.content)
204+
first_raw = _ASSISTANT_PREFILL + _extract_text(first_msg.content)
176205

177206
try:
178207
candidates = _parse_and_validate(first_raw)
179208
except TaggingValidationError as first_err:
180209
logger.warning(
181210
"tagging.validation_failed",
182-
extra={**log_ctx, "attempt": 1, "reason": str(first_err)},
211+
extra={
212+
**log_ctx,
213+
"attempt": 1,
214+
"reason": str(first_err),
215+
"raw_head": first_raw[:80],
216+
},
183217
)
184218
retry_msg = await llm.create_message(
185219
workload=WorkloadType.TAGGING,
@@ -189,16 +223,22 @@ async def run(req: TaggingRequest, llm: LLMClient, model: str) -> TaggingRespons
189223
{"role": "user", "content": _USER_TRIGGER},
190224
{"role": "assistant", "content": first_raw},
191225
{"role": "user", "content": _build_corrective_user_message(str(first_err))},
226+
{"role": "assistant", "content": _ASSISTANT_PREFILL},
192227
],
193228
max_tokens=_MAX_TOKENS,
194229
)
195-
retry_raw = _extract_text(retry_msg.content)
230+
retry_raw = _ASSISTANT_PREFILL + _extract_text(retry_msg.content)
196231
try:
197232
candidates = _parse_and_validate(retry_raw)
198233
except TaggingValidationError as retry_err:
199234
logger.warning(
200235
"tagging.validation_failed",
201-
extra={**log_ctx, "attempt": 2, "reason": str(retry_err)},
236+
extra={
237+
**log_ctx,
238+
"attempt": 2,
239+
"reason": str(retry_err),
240+
"raw_head": retry_raw[:80],
241+
},
202242
)
203243
raise
204244

tests/test_tagging.py

Lines changed: 61 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
LLMUpstreamUnavailableError,
3030
)
3131
from app.services.tagging.exceptions import TaggingValidationError
32+
from app.services.tagging.v2_postscore import (
33+
_extract_json_payload as _v2_extract_json_payload,
34+
)
3235
from app.services.tagging.v2_postscore import (
3336
_parse_and_validate as _v2_parse_and_validate,
3437
)
@@ -51,9 +54,11 @@
5154

5255
_PII_MARKERS = ("MAGICPII-ST-1234", "MAGICPII-A-5678", "MAGICPII-R-9012")
5356

54-
# v2_postscore happy LLM 응답 — 5개 후보(모두 풀 내·중복 없음). DEVELOPER 가중치로
55-
# 5개 모두 High(3) 동점 → tie-break 입력 순서 top3 = ["#원인분석","#검증및테스트","#반복개선"].
56-
_HAPPY_LLM_RAW = '{"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결","#디버깅"]}'
57+
# v2_postscore happy LLM 응답 — assistant prefill('{') 적용 가정 (issue #18).
58+
# 코드가 prefill + raw 로 합쳐 `{"detailTags": [...]}` JSON 을 완성한다.
59+
# 5개 후보 모두 풀 내·중복 없음. DEVELOPER 가중치로 5개 모두 High(3) 동점 →
60+
# tie-break 입력 순서 top3 = ["#원인분석","#검증및테스트","#반복개선"].
61+
_HAPPY_LLM_RAW = '"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결","#디버깅"]}'
5762
_HAPPY_TOP3 = ["#원인분석", "#검증및테스트", "#반복개선"]
5863

5964

@@ -137,9 +142,10 @@ def test_v1_tagging_happy_path(client: TestClient, token: str) -> None:
137142
@respx.mock
138143
def test_v1_tagging_corrective_retry_recovers(client: TestClient, token: str) -> None:
139144
# 1차: 풀-외 + 갯수 미달 → 검증 실패. 2차: 풀 내 5개 → top3 자르고 응답.
145+
# mock 응답은 모두 assistant prefill('{') 가정 (issue #18).
140146
route = respx.post(ANTHROPIC_URL).mock(
141147
side_effect=[
142-
Response(200, json=_anthropic_msg('{"detailTags": ["#존재하지않는태그"]}')),
148+
Response(200, json=_anthropic_msg('"detailTags": ["#존재하지않는태그"]}')),
143149
Response(200, json=_anthropic_msg(_HAPPY_LLM_RAW)),
144150
]
145151
)
@@ -156,8 +162,8 @@ def test_v1_tagging_corrective_retry_recovers(client: TestClient, token: str) ->
156162
def test_v1_tagging_double_validation_failure_returns_422(client: TestClient, token: str) -> None:
157163
route = respx.post(ANTHROPIC_URL).mock(
158164
side_effect=[
159-
Response(200, json=_anthropic_msg('{"detailTags": ["#없는태그A"]}')),
160-
Response(200, json=_anthropic_msg('{"detailTags": ["#없는태그B"]}')),
165+
Response(200, json=_anthropic_msg('"detailTags": ["#없는태그A"]}')),
166+
Response(200, json=_anthropic_msg('"detailTags": ["#없는태그B"]}')),
161167
]
162168
)
163169
r = client.post("/v1/tagging", json=_PAYLOAD, headers={"X-Internal-Token": token})
@@ -173,9 +179,9 @@ def test_v1_tagging_double_validation_failure_returns_422(client: TestClient, to
173179
@pytest.mark.parametrize(
174180
"bad_response",
175181
[
176-
'{"detailTags": []}',
177-
'{"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결"]}',
178-
'{"detailTags": ["#A","#B","#C","#D","#E","#F","#G","#H"]}',
182+
'"detailTags": []}',
183+
'"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결"]}',
184+
'"detailTags": ["#A","#B","#C","#D","#E","#F","#G","#H"]}',
179185
],
180186
ids=["empty_under_min", "four_tags_under_min", "eight_tags_over_max"],
181187
)
@@ -266,6 +272,50 @@ def test_v2_postscore_strips_all_backtick_widths(wrapped: str) -> None:
266272
assert _v2_strip_code_fence(wrapped) == '{"detailTags": ["#문제해결"]}'
267273

268274

275+
@pytest.mark.parametrize(
276+
("raw", "expected"),
277+
[
278+
('{"detailTags": ["#문제해결"]}', '{"detailTags": ["#문제해결"]}'),
279+
('다음과 같습니다: {"detailTags": ["#문제해결"]}', '{"detailTags": ["#문제해결"]}'),
280+
(
281+
'# 결과\n{"detailTags": ["#문제해결"]}\n참고: ...',
282+
'{"detailTags": ["#문제해결"]}',
283+
),
284+
(' \n{"detailTags": ["#문제해결"]} ', '{"detailTags": ["#문제해결"]}'),
285+
(
286+
'```json\n{"detailTags": ["#문제해결"]}\n```',
287+
'{"detailTags": ["#문제해결"]}',
288+
),
289+
("브레이스없음", "브레이스없음"),
290+
],
291+
ids=[
292+
"clean",
293+
"korean_prefix",
294+
"markdown_prefix_and_suffix",
295+
"whitespace",
296+
"code_fence",
297+
"no_braces_passthrough",
298+
],
299+
)
300+
def test_v2_postscore_extracts_json_payload_from_messy_responses(raw: str, expected: str) -> None:
301+
"""도입어 / 헤딩 / 공백 / 코드펜스 prefix·suffix 를 흡수해 JSON object 본문만 슬라이스.
302+
303+
issue #18 — Haiku 가 ``다음과 같습니다:`` 같은 도입어를 자주 붙여 ``^{`` 만 허용하면
304+
운영 실패율이 과도하게 높아짐. ``{`` ~ ``}`` 슬라이스 fallback 으로 흡수.
305+
"""
306+
assert _v2_extract_json_payload(raw) == expected
307+
308+
309+
def test_v2_postscore_parse_and_validate_handles_intro_prefix() -> None:
310+
"""도입어가 붙은 응답도 정상 파싱 — _extract_json_payload 가 prefix 흡수."""
311+
raw = (
312+
"다음과 같습니다: "
313+
'{"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결","#디버깅"]}'
314+
)
315+
tags = _v2_parse_and_validate(raw)
316+
assert tags == ["#원인분석", "#검증및테스트", "#반복개선", "#문제해결", "#디버깅"]
317+
318+
269319
def test_v2_postscore_parse_accepts_5_to_7_candidates() -> None:
270320
"""후보 갯수 5~7 범위는 통과."""
271321
five = '{"detailTags": ["#원인분석","#검증및테스트","#반복개선","#문제해결","#디버깅"]}'
@@ -349,8 +399,8 @@ def test_v1_tagging_does_not_log_star_body_on_validation_failure(
349399
) -> None:
350400
respx.post(ANTHROPIC_URL).mock(
351401
side_effect=[
352-
Response(200, json=_anthropic_msg('{"detailTags": ["#없는1"]}')),
353-
Response(200, json=_anthropic_msg('{"detailTags": ["#없는2"]}')),
402+
Response(200, json=_anthropic_msg('"detailTags": ["#없는1"]}')),
403+
Response(200, json=_anthropic_msg('"detailTags": ["#없는2"]}')),
354404
]
355405
)
356406
caplog.set_level(logging.INFO)

0 commit comments

Comments
 (0)