192 lines
5.5 KiB
Python
192 lines
5.5 KiB
Python
"""
|
|
Language detection utility for prompt selection.
|
|
|
|
Uses a two-tier strategy:
|
|
1. Fast path: Unicode character-range heuristic (CJK ideographs → zh)
|
|
2. Fallback: langdetect (for non-CJK ambiguous cases)
|
|
|
|
This avoids langdetect's known weakness with short Chinese text.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# CJK Unified Ideographs ranges
|
|
_CJK_RE = re.compile(
|
|
r'[\u4e00-\u9fff' # CJK Unified Ideographs
|
|
r'\u3400-\u4dbf' # CJK Extension A
|
|
r'\u2e80-\u2eff' # CJK Radicals Supplement
|
|
r'\uf900-\ufaff' # CJK Compatibility Ideographs
|
|
r'\ufe30-\ufe4f' # CJK Compatibility Forms
|
|
r'\U00020000-\U0002a6df' # CJK Extension B
|
|
r']'
|
|
)
|
|
|
|
_LEADING_ROLE_PREFIX_RE = re.compile(
|
|
r"^\s*(?:\[[^\]\n]{1,32}\]|user|assistant|system|tool)\s*:\s*",
|
|
re.IGNORECASE,
|
|
)
|
|
_LEADING_LANGUAGE_CHARS = 10
|
|
|
|
# Lazy-load langdetect
|
|
_langdetect_initialized = False
|
|
|
|
|
|
def _ensure_langdetect():
|
|
global _langdetect_initialized
|
|
if _langdetect_initialized:
|
|
return True
|
|
try:
|
|
from langdetect import DetectorFactory
|
|
DetectorFactory.seed = 0
|
|
_langdetect_initialized = True
|
|
return True
|
|
except ImportError:
|
|
logger.warning(
|
|
"langdetect not installed. Falling back to heuristic only. "
|
|
"Install with: pip install langdetect"
|
|
)
|
|
return False
|
|
|
|
|
|
def _chinese_char_ratio(text: str) -> float:
|
|
"""Return the ratio of CJK characters in text."""
|
|
if not text:
|
|
return 0.0
|
|
# Only count non-whitespace, non-punctuation chars
|
|
chars = re.sub(r'\s+', '', text)
|
|
if not chars:
|
|
return 0.0
|
|
cjk_count = len(_CJK_RE.findall(chars))
|
|
return cjk_count / len(chars)
|
|
|
|
|
|
def _leading_language_window(text: str, limit: int = _LEADING_LANGUAGE_CHARS) -> str:
|
|
"""Return the first language-bearing characters, ignoring a role prefix.
|
|
|
|
Agent traces often start with a short natural-language instruction followed
|
|
by long paths, code, JSON, or error output. Looking at the complete string
|
|
lets that machine text drown out the user's language. The leading window
|
|
captures the instruction instead, while skipping whitespace and punctuation.
|
|
"""
|
|
value = _LEADING_ROLE_PREFIX_RE.sub("", str(text or ""), count=1)
|
|
chars = [char for char in value if char.isalnum()]
|
|
return "".join(chars[:limit])
|
|
|
|
|
|
def detect_language(text: str) -> str:
|
|
"""
|
|
Detect the language of text content.
|
|
|
|
Strategy:
|
|
1. Inspect the first 10 language-bearing characters. Any CJK there → "zh";
|
|
a complete Latin/digit window → "en".
|
|
2. Otherwise, if the full-text CJK ratio >= 0.3 → "zh".
|
|
3. Use langdetect for the remaining ambiguous cases.
|
|
|
|
Returns:
|
|
"zh" for Chinese, "en" for English, or other ISO 639-1 codes.
|
|
Returns "en" as fallback if detection fails.
|
|
"""
|
|
if not text or not text.strip():
|
|
return "en"
|
|
|
|
text = text.strip()
|
|
|
|
leading = _leading_language_window(text)
|
|
if _CJK_RE.search(leading):
|
|
return "zh"
|
|
if len(leading) >= _LEADING_LANGUAGE_CHARS and leading.isascii():
|
|
return "en"
|
|
|
|
# Fast path: CJK character ratio
|
|
ratio = _chinese_char_ratio(text)
|
|
if ratio >= 0.3:
|
|
return "zh"
|
|
if ratio == 0:
|
|
# No CJK chars at all — likely English or other Latin-based
|
|
# Use langdetect for accuracy if available, else default "en"
|
|
if _ensure_langdetect():
|
|
try:
|
|
from langdetect import detect
|
|
lang = detect(text)
|
|
if lang.startswith("zh"):
|
|
return "zh"
|
|
return lang
|
|
except Exception:
|
|
pass
|
|
return "en"
|
|
|
|
# Mixed content (0 < ratio < 0.3): use langdetect
|
|
if _ensure_langdetect():
|
|
try:
|
|
from langdetect import detect
|
|
lang = detect(text)
|
|
if lang.startswith("zh"):
|
|
return "zh"
|
|
return lang
|
|
except Exception:
|
|
pass
|
|
|
|
# If langdetect unavailable and some CJK present, lean toward zh
|
|
return "zh" if ratio > 0.1 else "en"
|
|
|
|
|
|
def extract_content_for_detection(
|
|
content: str = "",
|
|
messages: Optional[List] = None,
|
|
) -> str:
|
|
"""
|
|
Extract pure content text for language detection.
|
|
|
|
For messages, only extracts the content field (not role/metadata)
|
|
to avoid 'user', 'assistant' etc. biasing the detection toward English.
|
|
|
|
Args:
|
|
content: plain text content
|
|
messages: list of {"role": ..., "content": ...} dicts or ChatMessage objects
|
|
|
|
Returns:
|
|
Concatenated content text suitable for language detection.
|
|
"""
|
|
if content and content.strip():
|
|
return content.strip()
|
|
|
|
if messages:
|
|
parts = []
|
|
for msg in messages:
|
|
if isinstance(msg, dict):
|
|
c = msg.get("content", "")
|
|
elif hasattr(msg, "content"):
|
|
c = msg.content
|
|
else:
|
|
continue
|
|
if c and c.strip():
|
|
parts.append(c.strip())
|
|
return "\n".join(parts)
|
|
|
|
return ""
|
|
|
|
|
|
def is_chinese(
|
|
content: str = "",
|
|
messages: Optional[List] = None,
|
|
) -> bool:
|
|
"""
|
|
Quick check: is the input content Chinese?
|
|
|
|
Args:
|
|
content: plain text content
|
|
messages: optional messages list (only content fields are used)
|
|
|
|
Returns:
|
|
True if detected language is Chinese.
|
|
"""
|
|
sample = extract_content_for_detection(content, messages)
|
|
if not sample:
|
|
return False
|
|
return detect_language(sample) == "zh"
|