Reject CJK ideographs/kana leaking into Korean prose
MiniMax occasionally mixes hanja/kana into Korean text (実態, 软件, かな). Parsed outputs are now scanned (verbatim fields exempt: title/doi/claim) and offending replies flow through the existing repair retry; prompts spell the rule out with examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+40
-5
@@ -176,17 +176,52 @@ def coerce_model_data(data: Any, model_cls: type[BaseModel]) -> tuple[Any, list[
|
||||
return data, coerced
|
||||
|
||||
|
||||
# MiniMax가 한국어 산문에 한자(简体/일본식)·가나를 섞는 누수 방지. 발견 시 스키마 오류처럼 취급해
|
||||
# repair 재시도로 흘려보낸다. 원문을 그대로 옮겨야 하는 필드(제목, DOI, 검증 인용)는 제외.
|
||||
_CJK_RE = re.compile(r"[-ヿ㐀-䶿一-鿿豈-]")
|
||||
_CJK_EXEMPT_KEYS = frozenset({"title", "doi", "claim"})
|
||||
|
||||
|
||||
def _find_cjk(node: Any, key: str | None = None, out: list[str] | None = None) -> list[str]:
|
||||
"""Paths/snippets of CJK ideographs or kana in the (dumped) model data, exempt keys skipped."""
|
||||
if out is None:
|
||||
out = []
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if k not in _CJK_EXEMPT_KEYS:
|
||||
_find_cjk(v, k, out)
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
_find_cjk(v, key, out)
|
||||
elif isinstance(node, str):
|
||||
m = _CJK_RE.search(node)
|
||||
if m:
|
||||
start = max(m.start() - 15, 0)
|
||||
out.append(f"{key or '?'}: \"…{node[start : m.end() + 15]}…\"")
|
||||
return out
|
||||
|
||||
|
||||
def parse_model[T: BaseModel](raw: str, model_cls: type[T]) -> T:
|
||||
"""Turn raw model content into a validated instance (raises ValueError / ValidationError)."""
|
||||
text = extract_json_text(raw)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return model_cls.model_validate_json(text) # let pydantic produce the canonical error
|
||||
data, coerced = coerce_model_data(data, model_cls)
|
||||
if coerced:
|
||||
log.warning("coerced string -> list for %s fields: %s", model_cls.__name__, ", ".join(sorted(coerced)))
|
||||
return model_cls.model_validate(data)
|
||||
instance = model_cls.model_validate_json(text) # let pydantic produce the canonical error
|
||||
else:
|
||||
data, coerced = coerce_model_data(data, model_cls)
|
||||
if coerced:
|
||||
log.warning(
|
||||
"coerced string -> list for %s fields: %s", model_cls.__name__, ", ".join(sorted(coerced))
|
||||
)
|
||||
instance = model_cls.model_validate(data)
|
||||
offending = _find_cjk(instance.model_dump(mode="json"))
|
||||
if offending:
|
||||
raise ValueError(
|
||||
"한국어 필드에 한자·가나 문자가 포함되었습니다. 해당 단어를 모두 한국어(한글)로 바꿔 "
|
||||
"다시 출력하세요: " + " | ".join(offending[:5])
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
def parse_digest(raw: str) -> Digest:
|
||||
|
||||
@@ -48,7 +48,8 @@ SYSTEM_PROMPT: str = f"""당신은 소프트웨어공학(SE) 연구자와 실무
|
||||
7. doi 필드는 논문 목록의 DOI를 한 글자도 바꾸지 않고 그대로 복사합니다. 목록에 없는 DOI나 논문을
|
||||
만들지 않습니다. title은 논문 원제 그대로(영문 유지) 씁니다.
|
||||
8. 그 밖의 모든 서술은 간결한 한국어 경어체(~습니다)로 작성합니다. 논문 제목·전문용어·고유명사는
|
||||
영문을 유지해도 됩니다. 중국어·일본어 문장을 섞지 않습니다.
|
||||
영문을 유지해도 됩니다. 한자(简体·繁體·일본식 한자)와 일본어 가나를 절대 쓰지 않습니다 -
|
||||
한자어는 반드시 한글로 표기합니다 (예: 実態 → 실태, 实证 → 실증, 软件 → 소프트웨어).
|
||||
9. 초록이 없는 논문은 제목·저널·주제만으로 by_topic에 배치하고, note에 "초록 미제공"을 밝힙니다.
|
||||
그런 논문은 highlights와 worth_reading으로 뽑지 않습니다.
|
||||
10. 수집 실패로 제공되지 않은 항목은 data_gaps에 그대로 적습니다.
|
||||
@@ -79,7 +80,8 @@ PAPER_SUMMARY_SYSTEM_PROMPT: str = f"""당신은 소프트웨어공학(SE) 논
|
||||
4. doi 필드는 논문 목록의 DOI를 한 글자도 바꾸지 않고 그대로 복사합니다. 목록의 모든 논문을
|
||||
빠짐없이 하나씩 요약하고, 목록에 없는 논문을 만들지 않습니다.
|
||||
5. 간결한 한국어 경어체(~습니다)로 작성합니다. 논문 제목·전문용어·고유명사는 영문을 유지해도
|
||||
됩니다.
|
||||
됩니다. 한자(简体·繁體·일본식 한자)와 일본어 가나를 절대 쓰지 않습니다 - 한자어는 반드시
|
||||
한글로 표기합니다 (예: 実態 → 실태, 实证 → 실증, 软件 → 소프트웨어).
|
||||
6. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을
|
||||
추가하지 않습니다.
|
||||
|
||||
|
||||
@@ -68,6 +68,34 @@ def test_digest_json_schema_is_closed_and_untitled():
|
||||
assert "overview" in schema["properties"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------- CJK guard
|
||||
def test_parse_rejects_hanja_and_kana_in_prose():
|
||||
data = json.loads(VALID_DIGEST_JSON)
|
||||
data["overview"] = "이번 주는 実態 조사가 활발했습니다."
|
||||
with pytest.raises(ValueError, match="한자·가나"):
|
||||
parse_digest(json.dumps(data, ensure_ascii=False))
|
||||
data["overview"] = "일본어 かな 누수입니다."
|
||||
with pytest.raises(ValueError, match="한자·가나"):
|
||||
parse_digest(json.dumps(data, ensure_ascii=False))
|
||||
|
||||
|
||||
def test_parse_allows_cjk_in_verbatim_title():
|
||||
data = json.loads(VALID_DIGEST_JSON)
|
||||
data["highlights"][0]["title"] = "Understanding 中文 Identifiers in Code"
|
||||
digest = parse_digest(json.dumps(data, ensure_ascii=False))
|
||||
assert "中文" in digest.highlights[0].title
|
||||
|
||||
|
||||
def test_hanja_reply_is_repaired_on_the_same_model():
|
||||
dirty = json.loads(VALID_DIGEST_JSON)
|
||||
dirty["overview"] = "소프트웨어 実態 분석 주간입니다."
|
||||
provider, client = make_provider([json.dumps(dirty, ensure_ascii=False), VALID_DIGEST_JSON])
|
||||
digest, model = provider.summarize(make_payload())
|
||||
assert model == "m1" and "実態" not in digest.overview
|
||||
repair = client.calls[1]["messages"][-1]["content"]
|
||||
assert "한자·가나" in repair and "実態" in repair
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------- prompts
|
||||
def test_build_user_message_contains_papers_and_gaps():
|
||||
payload = make_payload(gaps=["rss: down"])
|
||||
|
||||
Reference in New Issue
Block a user