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
|
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:
|
def parse_model[T: BaseModel](raw: str, model_cls: type[T]) -> T:
|
||||||
"""Turn raw model content into a validated instance (raises ValueError / ValidationError)."""
|
"""Turn raw model content into a validated instance (raises ValueError / ValidationError)."""
|
||||||
text = extract_json_text(raw)
|
text = extract_json_text(raw)
|
||||||
try:
|
try:
|
||||||
data = json.loads(text)
|
data = json.loads(text)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return model_cls.model_validate_json(text) # let pydantic produce the canonical error
|
instance = model_cls.model_validate_json(text) # let pydantic produce the canonical error
|
||||||
data, coerced = coerce_model_data(data, model_cls)
|
else:
|
||||||
if coerced:
|
data, coerced = coerce_model_data(data, model_cls)
|
||||||
log.warning("coerced string -> list for %s fields: %s", model_cls.__name__, ", ".join(sorted(coerced)))
|
if coerced:
|
||||||
return model_cls.model_validate(data)
|
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:
|
def parse_digest(raw: str) -> Digest:
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ SYSTEM_PROMPT: str = f"""당신은 소프트웨어공학(SE) 연구자와 실무
|
|||||||
7. doi 필드는 논문 목록의 DOI를 한 글자도 바꾸지 않고 그대로 복사합니다. 목록에 없는 DOI나 논문을
|
7. doi 필드는 논문 목록의 DOI를 한 글자도 바꾸지 않고 그대로 복사합니다. 목록에 없는 DOI나 논문을
|
||||||
만들지 않습니다. title은 논문 원제 그대로(영문 유지) 씁니다.
|
만들지 않습니다. title은 논문 원제 그대로(영문 유지) 씁니다.
|
||||||
8. 그 밖의 모든 서술은 간결한 한국어 경어체(~습니다)로 작성합니다. 논문 제목·전문용어·고유명사는
|
8. 그 밖의 모든 서술은 간결한 한국어 경어체(~습니다)로 작성합니다. 논문 제목·전문용어·고유명사는
|
||||||
영문을 유지해도 됩니다. 중국어·일본어 문장을 섞지 않습니다.
|
영문을 유지해도 됩니다. 한자(简体·繁體·일본식 한자)와 일본어 가나를 절대 쓰지 않습니다 -
|
||||||
|
한자어는 반드시 한글로 표기합니다 (예: 実態 → 실태, 实证 → 실증, 软件 → 소프트웨어).
|
||||||
9. 초록이 없는 논문은 제목·저널·주제만으로 by_topic에 배치하고, note에 "초록 미제공"을 밝힙니다.
|
9. 초록이 없는 논문은 제목·저널·주제만으로 by_topic에 배치하고, note에 "초록 미제공"을 밝힙니다.
|
||||||
그런 논문은 highlights와 worth_reading으로 뽑지 않습니다.
|
그런 논문은 highlights와 worth_reading으로 뽑지 않습니다.
|
||||||
10. 수집 실패로 제공되지 않은 항목은 data_gaps에 그대로 적습니다.
|
10. 수집 실패로 제공되지 않은 항목은 data_gaps에 그대로 적습니다.
|
||||||
@@ -79,7 +80,8 @@ PAPER_SUMMARY_SYSTEM_PROMPT: str = f"""당신은 소프트웨어공학(SE) 논
|
|||||||
4. doi 필드는 논문 목록의 DOI를 한 글자도 바꾸지 않고 그대로 복사합니다. 목록의 모든 논문을
|
4. doi 필드는 논문 목록의 DOI를 한 글자도 바꾸지 않고 그대로 복사합니다. 목록의 모든 논문을
|
||||||
빠짐없이 하나씩 요약하고, 목록에 없는 논문을 만들지 않습니다.
|
빠짐없이 하나씩 요약하고, 목록에 없는 논문을 만들지 않습니다.
|
||||||
5. 간결한 한국어 경어체(~습니다)로 작성합니다. 논문 제목·전문용어·고유명사는 영문을 유지해도
|
5. 간결한 한국어 경어체(~습니다)로 작성합니다. 논문 제목·전문용어·고유명사는 영문을 유지해도
|
||||||
됩니다.
|
됩니다. 한자(简体·繁體·일본식 한자)와 일본어 가나를 절대 쓰지 않습니다 - 한자어는 반드시
|
||||||
|
한글로 표기합니다 (예: 実態 → 실태, 实证 → 실증, 软件 → 소프트웨어).
|
||||||
6. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을
|
6. 출력은 지정된 JSON 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을
|
||||||
추가하지 않습니다.
|
추가하지 않습니다.
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,34 @@ def test_digest_json_schema_is_closed_and_untitled():
|
|||||||
assert "overview" in schema["properties"]
|
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
|
# ------------------------------------------------------------------------------- prompts
|
||||||
def test_build_user_message_contains_papers_and_gaps():
|
def test_build_user_message_contains_papers_and_gaps():
|
||||||
payload = make_payload(gaps=["rss: down"])
|
payload = make_payload(gaps=["rss: down"])
|
||||||
|
|||||||
Reference in New Issue
Block a user