ishaq101's picture
/fix validator and report (#10)
0e5fdb5
Raw
History Blame Contribute Delete
3.73 kB
"""Deterministic, LLM-free reply-language detection shared across agents.
The user-facing agents (help playbook + the analysis answer composer) must reply
in the user's language. Detection is marker-word based over the user's turn, and
the result is injected into the prompt as a hard `[Reply language]` directive so
replying in that language is mandatory — not a soft hint an English system prompt
+ English data context can override.
Signal priority (first hit wins):
1. the current turn (`message`);
2. the most recent human turn in `history` — covers the button path (no
`message`) AND acts as a tiebreaker when the current turn is too short to
carry a signal (e.g. "2025 vs 2026"), so a bilingual user's ambiguous turn
inherits their previous turn's language instead of snapping to the default;
3. the user-authored goal (`objective` + `business_questions`);
4. the team default (Indonesian).
"""
from __future__ import annotations
import re
from langchain_core.messages import BaseMessage
FALLBACK_LANGUAGE = "Indonesian" # team default when nothing yields a signal
# Function words + common chat shorthand/abbreviations. Content words (nouns,
# domain terms) are deliberately excluded — they're often shared across both
# languages (e.g. "data", "revenue") and would add noise.
_ID_MARKERS = frozenset({
"yang", "dan", "apa", "gimana", "bagaimana", "kenapa", "mengapa", "aku", "saya",
"tolong", "ini", "itu", "nih", "dong", "kah", "untuk", "dengan", "pada", "adalah",
"tidak", "enggak", "nggak", "bisa", "mau", "buat", "dari", "kamu", "ya",
"berapa", "kapan", "siapa", "dimana", "juga", "sudah", "belum", "akan",
# abbreviations / chat shorthand
"brp", "gmn", "yg", "gt", "gitu", "gini", "dgn", "utk", "tdk", "sdh", "blm",
"aja", "dah", "kalo", "klo", "knp", "jd", "jgn", "krn", "udah", "udh",
"ga", "gak", "gk", "engga", "trus", "trs", "sm", "kayak", "kek",
})
_EN_MARKERS = frozenset({
"the", "what", "how", "why", "please", "this", "that", "is", "are", "can", "could",
"should", "for", "with", "of", "and", "you", "do", "does", "when", "where",
"who", "which", "my", "me", "your", "have", "has", "want", "next",
})
def _last_human_text(history: list[BaseMessage] | None) -> str:
"""Return the text of the most recent human turn in history, or '' if none."""
for msg in reversed(history or []):
if getattr(msg, "type", None) == "human":
content = msg.content
return content if isinstance(content, str) else str(content)
return ""
def _score_language(text: str) -> str | None:
"""Return "Indonesian"/"English" from marker-word counts, or None if no signal."""
tokens = re.findall(r"[a-z']+", text.lower())
id_hits = sum(1 for t in tokens if t in _ID_MARKERS)
en_hits = sum(1 for t in tokens if t in _EN_MARKERS)
if en_hits > id_hits:
return "English"
if id_hits > en_hits:
return "Indonesian"
return None
def detect_reply_language(
history: list[BaseMessage] | None,
message: str | None = None,
goal_texts: list[str] | None = None,
) -> str:
"""Detect the reply language deterministically (no LLM), by signal priority.
See the module docstring for the priority order. Returns "Indonesian" or
"English".
"""
if message:
lang = _score_language(message)
if lang:
return lang
prev = _last_human_text(history)
if prev:
lang = _score_language(prev)
if lang:
return lang
goal = " ".join(t for t in (goal_texts or []) if t).strip()
if goal:
lang = _score_language(goal)
if lang:
return lang
return FALLBACK_LANGUAGE