feat(study): Phase 4-A explanation_md 길이 cap + prompt 강화
운영 데이터에서 ready 박힌 풀이가 793/838/866자 — 권장 200~400 대비 큰 편.
1차 운영 후 결과 화면 가독성 + 토큰 사용량 통제 위해 prompt 강화 + 저장 전 cap.
Prompt (study_explanation_envelope.txt):
- explanation_md 권장 300~600자, 최대 900자 명시
- 핵심 개념 + 정답 근거 + 헷갈리는 1~2개 오답만 — 모든 오답 풀이 X
- explanation_md 안 줄바꿈 최소화 (parse_json fix 와 결합 — invalid escape 줄임)
- LaTeX 수식 자제 — \\circ/\\text/\\, 매크로 가능하면 평문 ('0°C', 'C')
- 출력은 raw JSON 한 객체만 — 코드 펜스/thinking/메타 X 강조
Worker (study_explanation_worker.py):
- _cap_explanation_md(text, max_chars=1200) 헬퍼 신규
· 1200자 이하 passthrough
· 초과 시 마지막 200자 안에서 \\n\\n / \\n / '. ' / '다.' / '요.' 경계 탐색
· 경계에서 자르기 + '…' (단어 중간 자르기 회피)
· 경계 못 찾으면 단순 자르기 + '…'
- save 전 cap 적용. ai_explanation_status='ready' 유지 (cap 됐다고 failed X)
- payload 에 운영 분석 metadata: explanation_len_original / _saved / capped 플래그
검증:
- tests/test_explanation_cap.py (6 케이스)
· short passthrough / exact at limit / paragraph boundary / sentence boundary
· no boundary fallback / empty input
- scripts/phase4_health.sql 섹션 8/9 추가
· ai_explanation 길이 p50/p95/max (study_questions.ready)
· cap 작동 빈도 (job.payload 의 explanation_capped/_original/_saved)
cap 1200 = 800 (4-B summary_md) 보다 여유 — 기사시험 풀이는 공식+오답+개념 묶이면
800 빡빡함. 운영 후 800~1000 으로 조정 검토.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,11 @@
|
|||||||
- 사용자 정답이 정해진 객관식 문제이며, 너의 역할은 그 정답을 풀이하는 것이지 정답 자체를 바꾸는 것이 아니다.
|
- 사용자 정답이 정해진 객관식 문제이며, 너의 역할은 그 정답을 풀이하는 것이지 정답 자체를 바꾸는 것이 아니다.
|
||||||
- 자료 근거가 사용자 정답과 다르게 보여도 explanation_md 안에 "자료 근거에 따르면 다른 해석도 가능하다" 라고 짧게 명시할 뿐, answer_choice 는 사용자 정답 그대로 유지한다.
|
- 자료 근거가 사용자 정답과 다르게 보여도 explanation_md 안에 "자료 근거에 따르면 다른 해석도 가능하다" 라고 짧게 명시할 뿐, answer_choice 는 사용자 정답 그대로 유지한다.
|
||||||
- 정답을 추측하거나 다른 번호로 바꾸는 것은 환각 가드에 차단된다.
|
- 정답을 추측하거나 다른 번호로 바꾸는 것은 환각 가드에 차단된다.
|
||||||
8. explanation_md 는 사용자 정답이 왜 맞는지 풀이. 한국어 200~400자. 마크다운(굵게·리스트) 사용 가능.
|
8. explanation_md 는 사용자 정답이 왜 맞는지 풀이. 한국어 **300~600자 권장, 최대 900자**. 굵게·리스트 가능.
|
||||||
|
- 핵심 개념과 정답 근거 위주. 모든 오답 보기를 다 풀이할 필요 없고 가장 헷갈리는 1~2개만.
|
||||||
|
- **explanation_md 안에서 줄바꿈은 최소화** (꼭 필요한 단락 분리만).
|
||||||
|
- **LaTeX 수식 사용 자제**. 쓰더라도 짧은 인라인 (`$...$`) 만, `\circ`/`\text{}`/`\,` 같은 매크로는 가능하면 평문으로 ("0°C", "C", " ").
|
||||||
|
9. **출력은 raw JSON 한 객체만**. 메타 설명·인사·코드 펜스 (` ``` `)·thinking 텍스트 없이.
|
||||||
|
|
||||||
【출력 형식 — 반드시 아래 JSON 만 출력. 메타 설명·인사·코드 펜스 없이 raw JSON 한 객체.】
|
【출력 형식】
|
||||||
{{"answer_choice": <1|2|3|4>, "explanation_md": "<풀이 본문 마크다운>", "confidence": "<high|medium|low>"}}
|
{{"answer_choice": <1|2|3|4>, "explanation_md": "<풀이 본문 마크다운>", "confidence": "<high|medium|low>"}}
|
||||||
|
|||||||
@@ -38,10 +38,41 @@ logger = logging.getLogger(__name__)
|
|||||||
# PR-3 LLM_TIMEOUT_S 와 동일 안전 마진 (26B 평균 ~10s, gate 직렬화 고려)
|
# PR-3 LLM_TIMEOUT_S 와 동일 안전 마진 (26B 평균 ~10s, gate 직렬화 고려)
|
||||||
LLM_TIMEOUT_S = 30.0
|
LLM_TIMEOUT_S = 30.0
|
||||||
|
|
||||||
|
# explanation_md hard cap — 운영 데이터 793/838/866자 사례에서 1200 으로 시작
|
||||||
|
# (800 은 공식·오답·핵심개념 묶이는 기사시험 풀이에 빡빡함). 1차 운영 후 조정.
|
||||||
|
EXPLANATION_MAX_CHARS = 1200
|
||||||
|
# cap 시 문장 경계 탐색 — 마지막 N자 안에서 줄바꿈 / 마침표 찾기
|
||||||
|
_BOUNDARY_LOOKBACK = 200
|
||||||
|
|
||||||
_ENVELOPE_PROMPT_FILE = "study_explanation_envelope.txt"
|
_ENVELOPE_PROMPT_FILE = "study_explanation_envelope.txt"
|
||||||
_envelope_template_cache: str | None = None
|
_envelope_template_cache: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _cap_explanation_md(text: str, max_chars: int = EXPLANATION_MAX_CHARS) -> str:
|
||||||
|
"""길이 cap. 가능하면 문장 경계에서 자르고 "…" 추가. 안 잘리면 원문 그대로.
|
||||||
|
|
||||||
|
Phase 4-A 보강: 모델이 1200자 넘기는 경우 잦음 (LaTeX/긴 풀이). cap 됐다고
|
||||||
|
실패 처리하지 않고 ready 유지 — 학습 가치 보존이 중요.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return text
|
||||||
|
# 1) 마지막 _BOUNDARY_LOOKBACK 자 안에서 "\n\n" → "\n" → ". " → "다.\n" 순으로 찾기
|
||||||
|
head = text[:max_chars]
|
||||||
|
lookback_start = max(0, max_chars - _BOUNDARY_LOOKBACK)
|
||||||
|
boundary = -1
|
||||||
|
for marker in ("\n\n", "\n", ". ", "다.", "요.", "다 ", "요 "):
|
||||||
|
pos = head.rfind(marker, lookback_start)
|
||||||
|
if pos > 0:
|
||||||
|
boundary = pos + len(marker)
|
||||||
|
break
|
||||||
|
if boundary > 0:
|
||||||
|
return head[:boundary].rstrip() + "…"
|
||||||
|
# 2) 경계 못 찾으면 단순 자르기
|
||||||
|
return head.rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
def _load_envelope_prompt() -> str:
|
def _load_envelope_prompt() -> str:
|
||||||
global _envelope_template_cache
|
global _envelope_template_cache
|
||||||
if _envelope_template_cache is None:
|
if _envelope_template_cache is None:
|
||||||
@@ -177,9 +208,16 @@ async def run_explanation_job(session: AsyncSession, job: StudyQuestionJob) -> N
|
|||||||
return
|
return
|
||||||
|
|
||||||
# 5. 성공 — confidence 는 1차 통과 (Phase 4-B 임계 결정).
|
# 5. 성공 — confidence 는 1차 통과 (Phase 4-B 임계 결정).
|
||||||
# 운영 분석 자산으로 payload 에 confidence 보존.
|
# 길이 hard cap (Phase 4-A 후속) — 1200자 초과 시 문장 경계에서 자르고 ready 유지.
|
||||||
|
original_len = len(explanation_md)
|
||||||
|
explanation_md = _cap_explanation_md(explanation_md)
|
||||||
|
|
||||||
|
# 운영 분석 자산으로 payload 에 confidence + 길이 cap 정보 보존.
|
||||||
job_payload = dict(job.payload or {})
|
job_payload = dict(job.payload or {})
|
||||||
job_payload["confidence"] = confidence
|
job_payload["confidence"] = confidence
|
||||||
|
job_payload["explanation_len_original"] = original_len
|
||||||
|
job_payload["explanation_len_saved"] = len(explanation_md)
|
||||||
|
job_payload["explanation_capped"] = original_len > len(explanation_md)
|
||||||
job.payload = job_payload
|
job.payload = job_payload
|
||||||
|
|
||||||
question.ai_explanation = explanation_md
|
question.ai_explanation = explanation_md
|
||||||
|
|||||||
@@ -117,3 +117,31 @@ FROM study_quiz_session_jobs
|
|||||||
WHERE error_code IN ('guard_fail', 'parse_fail', 'llm_timeout', 'unknown')
|
WHERE error_code IN ('guard_fail', 'parse_fail', 'llm_timeout', 'unknown')
|
||||||
GROUP BY error_code
|
GROUP BY error_code
|
||||||
ORDER BY cnt DESC;
|
ORDER BY cnt DESC;
|
||||||
|
|
||||||
|
\echo ''
|
||||||
|
\echo '── 8. 4-A explanation_md 길이 분포 (cap 적용 효과) ──'
|
||||||
|
\echo ' _original = 모델 raw 응답 길이 / _saved = cap 후 저장 길이'
|
||||||
|
SELECT
|
||||||
|
'ai_explanation len p50/p95/max (study_questions.ready)' AS metric,
|
||||||
|
ROUND((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY LENGTH(ai_explanation)))::numeric, 0) AS p50,
|
||||||
|
ROUND((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY LENGTH(ai_explanation)))::numeric, 0) AS p95,
|
||||||
|
MAX(LENGTH(ai_explanation)) AS max_len,
|
||||||
|
COUNT(*) AS n_ready
|
||||||
|
FROM study_questions
|
||||||
|
WHERE ai_explanation_status = 'ready'
|
||||||
|
AND ai_explanation IS NOT NULL;
|
||||||
|
|
||||||
|
\echo ''
|
||||||
|
\echo '── 9. 4-A cap 작동 빈도 (job.payload 의 explanation_capped) ──'
|
||||||
|
SELECT
|
||||||
|
payload->>'explanation_capped' AS capped,
|
||||||
|
COUNT(*) AS cnt,
|
||||||
|
ROUND(AVG((payload->>'explanation_len_original')::int)::numeric, 0) AS avg_original_len,
|
||||||
|
ROUND(AVG((payload->>'explanation_len_saved')::int)::numeric, 0) AS avg_saved_len,
|
||||||
|
MAX((payload->>'explanation_len_original')::int) AS max_original
|
||||||
|
FROM study_question_jobs
|
||||||
|
WHERE status = 'completed'
|
||||||
|
AND payload IS NOT NULL
|
||||||
|
AND payload ? 'explanation_len_original'
|
||||||
|
GROUP BY capped
|
||||||
|
ORDER BY capped;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Phase 4-A 후속 — explanation_md 길이 cap 단위 테스트.
|
||||||
|
|
||||||
|
worker 의 _cap_explanation_md 헬퍼가 plan 의도대로 동작하는지 검증.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "app"))
|
||||||
|
|
||||||
|
from workers.study_explanation_worker import ( # noqa: E402
|
||||||
|
EXPLANATION_MAX_CHARS,
|
||||||
|
_cap_explanation_md,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_short_text_passthrough():
|
||||||
|
"""1200자 이하는 변경 없이 통과."""
|
||||||
|
text = "짧은 풀이." * 50 # ~300자
|
||||||
|
assert _cap_explanation_md(text) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_exactly_at_limit():
|
||||||
|
"""딱 1200자는 그대로."""
|
||||||
|
text = "ㄱ" * EXPLANATION_MAX_CHARS
|
||||||
|
assert len(_cap_explanation_md(text)) == EXPLANATION_MAX_CHARS
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_long_text_with_paragraph_boundary():
|
||||||
|
"""1500자 + 마지막 200자 안에 \\n\\n 있으면 거기서 자르기 + …"""
|
||||||
|
head = "정답 풀이 본문. " * 60 # ~1200자
|
||||||
|
boundary_pos = 1100
|
||||||
|
text = head[:boundary_pos] + "\n\n" + "추가 단락 본문." * 50 # 1500자+
|
||||||
|
assert len(text) > EXPLANATION_MAX_CHARS
|
||||||
|
capped = _cap_explanation_md(text)
|
||||||
|
assert len(capped) <= EXPLANATION_MAX_CHARS + 1 # + "…"
|
||||||
|
assert capped.endswith("…")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_long_text_with_sentence_boundary():
|
||||||
|
"""\\n\\n 이 없으면 마침표에서 자르기."""
|
||||||
|
parts = ["문장 한 개가 약간 깁니다. " for _ in range(100)]
|
||||||
|
text = "".join(parts) # 약 2400자
|
||||||
|
assert len(text) > EXPLANATION_MAX_CHARS
|
||||||
|
capped = _cap_explanation_md(text)
|
||||||
|
assert len(capped) <= EXPLANATION_MAX_CHARS + 1
|
||||||
|
assert capped.endswith("…")
|
||||||
|
# 자른 위치가 마침표/공백 직후여야 — 단어 중간 X
|
||||||
|
body_no_ellipsis = capped[:-1]
|
||||||
|
assert body_no_ellipsis[-1] in (".", " ", "다", "요"), \
|
||||||
|
f"last char before ellipsis: {body_no_ellipsis[-1]!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_no_boundary_falls_back_to_plain_cut():
|
||||||
|
"""경계 마커 (\\n, ., 다., 요.) 가 전혀 없으면 단순 자르기."""
|
||||||
|
text = "ㅁ" * 2000 # 한글 한 글자만 반복, 경계 0
|
||||||
|
capped = _cap_explanation_md(text)
|
||||||
|
assert capped == "ㅁ" * EXPLANATION_MAX_CHARS + "…"
|
||||||
|
assert len(capped) == EXPLANATION_MAX_CHARS + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cap_empty_input():
|
||||||
|
assert _cap_explanation_md("") == ""
|
||||||
|
assert _cap_explanation_md(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_cap_short_text_passthrough()
|
||||||
|
test_cap_exactly_at_limit()
|
||||||
|
test_cap_long_text_with_paragraph_boundary()
|
||||||
|
test_cap_long_text_with_sentence_boundary()
|
||||||
|
test_cap_no_boundary_falls_back_to_plain_cut()
|
||||||
|
test_cap_empty_input()
|
||||||
|
print("OK")
|
||||||
Reference in New Issue
Block a user