MicroFish/backend/app/utils/cjk_sanitize.py

263 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
CJK Leak Sanitization Utility
==============================
Even with proper ``get_language_instruction()`` injected into LLM system prompts,
the model can still leak Chinese characters mid-sentence when generating persona
quotes (BI economists, ministry officials, Reddit commenters, etc.). The LLM
sometimes reaches back to its Chinese training data for fluent-sounding speech,
producing output like::
"BI economist said: Purbaya过于倾向财政扩张..."
This module provides :func:`sanitize_cjk_in_text` which:
1. Detects runs of CJK Unified Ideographs (U+4E00..U+9FFF) and CJK Symbols
& Punctuation (U+3000..U+303F) ≥ 2 characters in length
2. Batch-translates them via the configured LLM endpoint (reusing the same
``LLM_BASE_URL`` / ``LLM_API_KEY`` as the rest of MiroFish)
3. Replaces each run in-place, injecting spaces at ASCII boundaries so the
result reads naturally in the surrounding language
4. Iterates up to 3 passes so any fragments the LLM leaves in its first
response are caught in subsequent passes
The function is a no-op when the input is short of CJK characters, when the
target locale is Chinese (``locale == 'zh'``), or when no LLM API key is
configured. It is safe to call from any code path — failures are logged and
the original text is returned unchanged.
Configuration (env vars, all optional):
CJK_SANITIZE_ENABLED — ``"1"`` / ``"true"`` to force-enable; ``"0"`` to force-disable.
Default: auto-enable for non-zh locales.
CJK_SANITIZE_LANGS — comma-separated locales where sanitization runs.
Default: ``"en,es,fr,pt,ru,de,id"`` (any non-zh locale).
CJK_SANITIZE_MAX_PASSES — maximum retry passes (default ``3``).
"""
from __future__ import annotations
import json
import logging
import os
import re
import urllib.error
import urllib.request
from typing import Iterable, Optional
logger = logging.getLogger(__name__)
# Match runs of ≥2 CJK ideographs or CJK punctuation/symbols.
# Single-character matches (e.g. lone "" or "】") are usually false positives
# in mixed-language text; the 2-char floor filters most of those out.
_CJK_PATTERN = re.compile(r"[\u4e00-\u9fff\u3000-\u303f]{2,}")
# Default locales where sanitization is helpful. Chinese is intentionally
# excluded — Chinese reports can and should contain CJK characters.
_DEFAULT_TARGET_LOCALES = frozenset({"en", "es", "fr", "pt", "ru", "de", "id"})
def _extract_cjk_runs(text: str) -> list[str]:
"""Return unique CJK runs in order of first appearance."""
seen: set[str] = set()
out: list[str] = []
for m in _CJK_PATTERN.finditer(text):
s = m.group(0)
if s not in seen:
seen.add(s)
out.append(s)
return out
def _smart_replace(text: str, cjk_run: str, translation: str) -> str:
"""Replace a CJK run with its English translation, adding spaces at ASCII
boundaries so the result reads naturally in surrounding English/Latin text.
The LLM only sees the CJK snippet (not its surrounding context), so it can't
preserve the original spacing. We compensate with boundary lookbehind/ahead.
"""
out = text
# ASCII on both sides → " trans "
pat = re.compile(r"(?<=[A-Za-z0-9])" + re.escape(cjk_run) + r"(?=[A-Za-z0-9])")
out = pat.sub(" " + translation + " ", out)
# ASCII on left only (e.g. "Purbaya过于扩张,") → " trans"
pre = re.compile(r"(?<=[A-Za-z0-9])" + re.escape(cjk_run))
out = pre.sub(" " + translation, out)
# ASCII on right only (e.g. "中央银行officials") → "trans "
post = re.compile(re.escape(cjk_run) + r"(?=[A-Za-z0-9])")
out = post.sub(translation + " ", out)
# Surrounded by non-ASCII (e.g. fullwidth parens) → bare translation
out = out.replace(cjk_run, translation)
return out
def _batch_translate(
snippets: list[str],
api_key: str,
base_url: str,
model: str,
timeout: int = 60,
max_retries: int = 2,
) -> dict[str, str]:
"""Translate a list of CJK snippets to English via the configured LLM."""
if not snippets:
return {}
numbered = "\n".join(f"{i + 1}. {s}" for i, s in enumerate(snippets))
prompt = (
"You are a translator. The following are short Chinese fragments that "
"leaked into an otherwise English report. Translate each to natural "
"English. Output a JSON array of strings, one per input, in the same "
"order. Do not add commentary, numbering, or markdown fences — output "
f"only the JSON array.\n\n{numbered}"
)
body = json.dumps(
{
"model": model,
"max_tokens": 4096,
"temperature": 0.1,
"messages": [{"role": "user", "content": prompt}],
}
).encode()
url = base_url.rstrip("/") + "/chat/completions"
last_err: Optional[Exception] = None
for attempt in range(max_retries):
try:
req = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.loads(resp.read())
content = payload["choices"][0]["message"]["content"].strip()
# Strip markdown fences if the model added them despite instructions
content = re.sub(r"^```(?:json)?\s*", "", content)
content = re.sub(r"\s*```$", "", content)
arr = json.loads(content)
if not isinstance(arr, list) or len(arr) != len(snippets):
raise ValueError(
f"Got {len(arr) if isinstance(arr, list) else 'non-list'} "
f"translations for {len(snippets)} snippets"
)
return {orig: trans for orig, trans in zip(snippets, arr)}
except Exception as e: # any failure (network, JSON, schema) → retry
last_err = e
logger.warning(
"cjk_sanitize translation attempt %d/%d failed: %s",
attempt + 1,
max_retries,
e,
)
raise RuntimeError(f"Translation failed after {max_retries} attempts: {last_err}")
def is_enabled(locale: Optional[str] = None) -> bool:
"""Return True if sanitization should run for the given locale.
Honors the ``CJK_SANITIZE_ENABLED`` env var (force on/off). When unset,
enables for any locale in ``CJK_SANITIZE_LANGS`` (default: non-Chinese
supported locales).
"""
override = os.environ.get("CJK_SANITIZE_ENABLED", "").strip().lower()
if override in ("1", "true", "yes", "on"):
return True
if override in ("0", "false", "no", "off"):
return False
if not locale:
return False
target_langs_env = os.environ.get("CJK_SANITIZE_LANGS", "").strip()
if target_langs_env:
target = {x.strip().lower() for x in target_langs_env.split(",") if x.strip()}
else:
target = _DEFAULT_TARGET_LOCALES
return locale.lower() in target
def sanitize_cjk_in_text(
text: str,
locale: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model: Optional[str] = None,
) -> str:
"""Return ``text`` with CJK runs replaced by English translations.
No-op if:
- ``text`` is empty or contains no CJK runs
- ``locale`` is Chinese (``zh`` / ``zh-CN`` / ``zh-TW``)
- no LLM ``api_key`` is configured
- the LLM call fails (logs warning, returns original text)
Args:
text: The text to sanitize (typically a generated report or section).
locale: Target report locale (e.g. ``"en"``). Chinese is skipped.
api_key: LLM API key. Defaults to ``LLM_API_KEY`` env var.
base_url: LLM endpoint base URL. Defaults to ``LLM_BASE_URL`` env var.
model: LLM model name. Defaults to ``LLM_MODEL_NAME`` env var.
Returns:
Sanitized text. If sanitization is skipped or fails, returns the
original ``text`` unchanged.
"""
if not text:
return text
if locale and locale.lower().split("-")[0] == "zh":
return text # Chinese reports legitimately contain CJK
runs = _extract_cjk_runs(text)
if not runs:
return text
api_key = api_key or os.environ.get("LLM_API_KEY")
base_url = base_url or os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1")
model = model or os.environ.get("LLM_MODEL_NAME", "gpt-4o-mini")
if not api_key:
logger.warning(
"cjk_sanitize: %d CJK runs detected but no LLM_API_KEY; skipping",
len(runs),
)
return text
max_passes = int(os.environ.get("CJK_SANITIZE_MAX_PASSES", "3"))
out = text
n_total = 0
pass_n = 0
for pass_n in range(1, max_passes + 1):
runs = _extract_cjk_runs(out)
if not runs:
break
try:
translations = _batch_translate(runs, api_key, base_url, model)
except RuntimeError as e:
logger.warning("cjk_sanitize: %s — returning original text", e)
return text
n_replaced = 0
for orig, trans in translations.items():
if not trans or not trans.strip() or trans == orig:
continue
before = out.count(orig)
out = _smart_replace(out, orig, trans)
n_replaced += before
n_total += n_replaced
if n_replaced == 0:
break # LLM didn't translate anything new — stop iterating
leftover = _extract_cjk_runs(out)
if leftover:
logger.warning(
"cjk_sanitize: %d CJK runs still present after %d passes: %s",
len(leftover),
pass_n or 1,
leftover,
)
logger.info("cjk_sanitize: replaced %d CJK runs across %d passes", n_total, pass_n or 1)
return out