From a6ccf04fb20a790339a59347abd75ab89bde3f71 Mon Sep 17 00:00:00 2001 From: jsahn Date: Wed, 2 Sep 2026 20:25:20 +0900 Subject: [PATCH] Reject CJK ideographs/kana leaking into Korean prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sebrief/llm/minimax.py | 45 +++++++++++++++++++++++++++++++++++++----- sebrief/llm/prompts.py | 6 ++++-- tests/test_llm.py | 28 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/sebrief/llm/minimax.py b/sebrief/llm/minimax.py index 701fd28..16d6a5e 100644 --- a/sebrief/llm/minimax.py +++ b/sebrief/llm/minimax.py @@ -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: diff --git a/sebrief/llm/prompts.py b/sebrief/llm/prompts.py index 9c98c38..aa1af84 100644 --- a/sebrief/llm/prompts.py +++ b/sebrief/llm/prompts.py @@ -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 스키마만 반환합니다. 스키마 밖의 텍스트, 마크다운 코드 펜스, 설명을 추가하지 않습니다. diff --git a/tests/test_llm.py b/tests/test_llm.py index 6bf3d91..02862df 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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"])