sofhiaazzhr commited on
Commit
3e1772d
·
1 Parent(s): 0bbc27b

[NOTICKET] fix(agents): lock analysis answer language to the user's turn

Browse files

The analysis answer composer (ChatbotAgent / chatbot_system.md) had no reply-language rule, so it drifted to English even for Indonesian questions (the data context — column names, rows — is English). Extract the deterministic detector from help.py into a shared src/agents/language.py, enrich the Indonesian markers with common chat abbreviations (brp, gmn, yg, ...), and add a history fallback so a short/ambiguous turn inherits the conversation language instead of the team default. ChatbotAgent now computes the language per turn and injects a [Reply language] hard directive; chatbot_system.md gained the matching hard rule (mirrors help.md). help.py re-exports the moved names, so its call sites + tests are unchanged.

src/agents/chatbot.py CHANGED
@@ -25,6 +25,7 @@ from langchain_openai import AzureChatOpenAI
25
  from src.middlewares.logging import get_logger
26
 
27
  from ..query.executor.base import QueryResult
 
28
 
29
  logger = get_logger("chatbot")
30
 
@@ -126,6 +127,9 @@ def _build_default_chain() -> Runnable:
126
  prompt = ChatPromptTemplate.from_messages(
127
  [
128
  ("system", _load_system_prompt()),
 
 
 
129
  MessagesPlaceholder(variable_name="history", optional=True),
130
  ("human", "{message}"),
131
  ("system", "Data context for this turn:\n\n{context}"),
@@ -168,6 +172,9 @@ class ChatbotAgent:
168
  "message": message,
169
  "history": history or [],
170
  "context": _build_context_block(query_result, chunks),
 
 
 
171
  }
172
  if callbacks:
173
  async for token in chain.astream(payload, config={"callbacks": callbacks}):
 
25
  from src.middlewares.logging import get_logger
26
 
27
  from ..query.executor.base import QueryResult
28
+ from .language import detect_reply_language
29
 
30
  logger = get_logger("chatbot")
31
 
 
127
  prompt = ChatPromptTemplate.from_messages(
128
  [
129
  ("system", _load_system_prompt()),
130
+ # Hard reply-language directive — the system prompt's language rule
131
+ # points at this. Deterministic + mandatory, mirrors the help path.
132
+ ("system", "[Reply language]\nRespond ONLY in: {reply_language}"),
133
  MessagesPlaceholder(variable_name="history", optional=True),
134
  ("human", "{message}"),
135
  ("system", "Data context for this turn:\n\n{context}"),
 
172
  "message": message,
173
  "history": history or [],
174
  "context": _build_context_block(query_result, chunks),
175
+ # Deterministic reply-language lock: current turn, else last human turn
176
+ # (so a short/ambiguous turn inherits the conversation language).
177
+ "reply_language": detect_reply_language(history, message=message),
178
  }
179
  if callbacks:
180
  async for token in chain.astream(payload, config={"callbacks": callbacks}):
src/agents/handlers/help.py CHANGED
@@ -29,7 +29,6 @@ SEAMS:
29
 
30
  from __future__ import annotations
31
 
32
- import re
33
  from collections.abc import AsyncIterator
34
  from dataclasses import dataclass, field
35
  from pathlib import Path
@@ -42,6 +41,12 @@ from langchain_core.runnables import Runnable
42
  from langchain_openai import AzureChatOpenAI
43
 
44
  from src.agents.gate import AnalysisState
 
 
 
 
 
 
45
  from src.middlewares.logging import get_logger
46
 
47
  logger = get_logger("help")
@@ -58,72 +63,9 @@ _DEFAULT_TRIGGERS = {
58
  "Indonesian": "Apa yang sebaiknya saya lakukan selanjutnya?",
59
  "English": "What should I do next?",
60
  }
61
- _FALLBACK_LANGUAGE = "Indonesian" # team default when no human turn exists yet
62
-
63
- # Lightweight, LLM-free language detection over the last human turn. The result is LOCKED
64
- # into the prompt via a `[Reply language]` directive (see `_build_context_block`), so
65
- # replying in the user's language is deterministic/mandatory — not a soft prompt hint that
66
- # an English system prompt + English default trigger can override.
67
- _ID_MARKERS = frozenset({
68
- "yang", "dan", "apa", "gimana", "bagaimana", "kenapa", "mengapa", "aku", "saya",
69
- "tolong", "ini", "itu", "nih", "dong", "kah", "untuk", "dengan", "pada", "adalah",
70
- "tidak", "enggak", "nggak", "bisa", "mau", "buat", "dari", "kamu", "ya",
71
- "berapa", "kapan", "siapa", "dimana", "juga", "sudah", "belum", "akan",
72
- })
73
- _EN_MARKERS = frozenset({
74
- "the", "what", "how", "why", "please", "this", "that", "is", "are", "can", "could",
75
- "should", "for", "with", "of", "and", "you", "do", "does", "when", "where",
76
- "who", "which", "my", "me", "your", "have", "has", "want", "next",
77
- })
78
-
79
-
80
- def _last_human_text(history: list[BaseMessage] | None) -> str:
81
- """Return the text of the most recent human turn in history, or '' if none."""
82
- for msg in reversed(history or []):
83
- if getattr(msg, "type", None) == "human":
84
- content = msg.content
85
- return content if isinstance(content, str) else str(content)
86
- return ""
87
-
88
-
89
- def _score_language(text: str) -> str | None:
90
- """Return "Indonesian"/"English" from marker-word counts, or None if no signal."""
91
- tokens = re.findall(r"[a-z']+", text.lower())
92
- id_hits = sum(1 for t in tokens if t in _ID_MARKERS)
93
- en_hits = sum(1 for t in tokens if t in _EN_MARKERS)
94
- if en_hits > id_hits:
95
- return "English"
96
- if id_hits > en_hits:
97
- return "Indonesian"
98
- return None
99
-
100
-
101
- def _detect_reply_language(
102
- history: list[BaseMessage] | None,
103
- message: str | None = None,
104
- goal_texts: list[str] | None = None,
105
- ) -> str:
106
- """Detect the reply language deterministically (no LLM), by signal priority.
107
-
108
- 1. the user's turn — an explicit `message` (intent path) or the last human turn in
109
- `history` (button path, where `message` is None);
110
- 2. the user-authored goal (`objective` + `business_questions`) — required at
111
- onboarding, so it's always present and is a reliable signal on a fresh analysis
112
- that has no chat yet;
113
- 3. the team default (Indonesian) — a safety net only, for a stub/legacy/empty-goal
114
- state where nothing above yields a signal.
115
-
116
- Returns "Indonesian" or "English".
117
- """
118
- primary = (message or _last_human_text(history)).strip()
119
- lang = _score_language(primary) if primary else None
120
- if lang:
121
- return lang
122
- goal = " ".join(t for t in (goal_texts or []) if t).strip()
123
- lang = _score_language(goal) if goal else None
124
- if lang:
125
- return lang
126
- return _FALLBACK_LANGUAGE
127
 
128
 
129
  @dataclass
 
29
 
30
  from __future__ import annotations
31
 
 
32
  from collections.abc import AsyncIterator
33
  from dataclasses import dataclass, field
34
  from pathlib import Path
 
41
  from langchain_openai import AzureChatOpenAI
42
 
43
  from src.agents.gate import AnalysisState
44
+ from src.agents.language import (
45
+ FALLBACK_LANGUAGE as _FALLBACK_LANGUAGE,
46
+ )
47
+ from src.agents.language import (
48
+ detect_reply_language as _detect_reply_language,
49
+ )
50
  from src.middlewares.logging import get_logger
51
 
52
  logger = get_logger("help")
 
63
  "Indonesian": "Apa yang sebaiknya saya lakukan selanjutnya?",
64
  "English": "What should I do next?",
65
  }
66
+ # Reply-language detection now lives in `src/agents/language.py` (shared with the
67
+ # analysis answer composer). `_detect_reply_language` / `_FALLBACK_LANGUAGE` are
68
+ # re-exported via the imports above so this module's call sites + tests are unchanged.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
 
71
  @dataclass
src/agents/language.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, LLM-free reply-language detection shared across agents.
2
+
3
+ The user-facing agents (help playbook + the analysis answer composer) must reply
4
+ in the user's language. Detection is marker-word based over the user's turn, and
5
+ the result is injected into the prompt as a hard `[Reply language]` directive so
6
+ replying in that language is mandatory — not a soft hint an English system prompt
7
+ + English data context can override.
8
+
9
+ Signal priority (first hit wins):
10
+ 1. the current turn (`message`);
11
+ 2. the most recent human turn in `history` — covers the button path (no
12
+ `message`) AND acts as a tiebreaker when the current turn is too short to
13
+ carry a signal (e.g. "2025 vs 2026"), so a bilingual user's ambiguous turn
14
+ inherits their previous turn's language instead of snapping to the default;
15
+ 3. the user-authored goal (`objective` + `business_questions`);
16
+ 4. the team default (Indonesian).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ from langchain_core.messages import BaseMessage
24
+
25
+ FALLBACK_LANGUAGE = "Indonesian" # team default when nothing yields a signal
26
+
27
+ # Function words + common chat shorthand/abbreviations. Content words (nouns,
28
+ # domain terms) are deliberately excluded — they're often shared across both
29
+ # languages (e.g. "data", "revenue") and would add noise.
30
+ _ID_MARKERS = frozenset({
31
+ "yang", "dan", "apa", "gimana", "bagaimana", "kenapa", "mengapa", "aku", "saya",
32
+ "tolong", "ini", "itu", "nih", "dong", "kah", "untuk", "dengan", "pada", "adalah",
33
+ "tidak", "enggak", "nggak", "bisa", "mau", "buat", "dari", "kamu", "ya",
34
+ "berapa", "kapan", "siapa", "dimana", "juga", "sudah", "belum", "akan",
35
+ # abbreviations / chat shorthand
36
+ "brp", "gmn", "yg", "gt", "gitu", "gini", "dgn", "utk", "tdk", "sdh", "blm",
37
+ "aja", "dah", "kalo", "klo", "knp", "jd", "jgn", "krn", "udah", "udh",
38
+ "ga", "gak", "gk", "engga", "trus", "trs", "sm", "kayak", "kek",
39
+ })
40
+ _EN_MARKERS = frozenset({
41
+ "the", "what", "how", "why", "please", "this", "that", "is", "are", "can", "could",
42
+ "should", "for", "with", "of", "and", "you", "do", "does", "when", "where",
43
+ "who", "which", "my", "me", "your", "have", "has", "want", "next",
44
+ })
45
+
46
+
47
+ def _last_human_text(history: list[BaseMessage] | None) -> str:
48
+ """Return the text of the most recent human turn in history, or '' if none."""
49
+ for msg in reversed(history or []):
50
+ if getattr(msg, "type", None) == "human":
51
+ content = msg.content
52
+ return content if isinstance(content, str) else str(content)
53
+ return ""
54
+
55
+
56
+ def _score_language(text: str) -> str | None:
57
+ """Return "Indonesian"/"English" from marker-word counts, or None if no signal."""
58
+ tokens = re.findall(r"[a-z']+", text.lower())
59
+ id_hits = sum(1 for t in tokens if t in _ID_MARKERS)
60
+ en_hits = sum(1 for t in tokens if t in _EN_MARKERS)
61
+ if en_hits > id_hits:
62
+ return "English"
63
+ if id_hits > en_hits:
64
+ return "Indonesian"
65
+ return None
66
+
67
+
68
+ def detect_reply_language(
69
+ history: list[BaseMessage] | None,
70
+ message: str | None = None,
71
+ goal_texts: list[str] | None = None,
72
+ ) -> str:
73
+ """Detect the reply language deterministically (no LLM), by signal priority.
74
+
75
+ See the module docstring for the priority order. Returns "Indonesian" or
76
+ "English".
77
+ """
78
+ if message:
79
+ lang = _score_language(message)
80
+ if lang:
81
+ return lang
82
+ prev = _last_human_text(history)
83
+ if prev:
84
+ lang = _score_language(prev)
85
+ if lang:
86
+ return lang
87
+ goal = " ".join(t for t in (goal_texts or []) if t).strip()
88
+ if goal:
89
+ lang = _score_language(goal)
90
+ if lang:
91
+ return lang
92
+ return FALLBACK_LANGUAGE
src/config/prompts/chatbot_system.md CHANGED
@@ -1,5 +1,14 @@
1
  You are a friendly, precise data assistant for a user who has registered databases and uploaded files. Your job is to answer the user's questions using **only** the data context provided to you in this turn.
2
 
 
 
 
 
 
 
 
 
 
3
  ## Rules
4
 
5
  1. **Ground every claim in the provided context.** If the context doesn't contain the answer, say so plainly — do not guess. Never invent numbers, dates, or facts that aren't in the result rows or document chunks.
 
1
  You are a friendly, precise data assistant for a user who has registered databases and uploaded files. Your job is to answer the user's questions using **only** the data context provided to you in this turn.
2
 
3
+ > **Reply language.** **Default** to the language named in `[Reply language]` (detected from
4
+ > the user's turn). The data context — column/table names and rows — is often English; do
5
+ > **not** let it pull your reply toward English. The user's language wins.
6
+ > **Exception — explicit request overrides.** If the user explicitly asks to reply in a
7
+ > particular language (e.g. "jawab dalam bahasa Inggris", "please answer in Indonesian"),
8
+ > honor that request instead — an explicit instruction beats `[Reply language]`, and it
9
+ > stays in effect for later turns until the user changes it.
10
+ > Never mix languages or switch mid-reply. Proper nouns and column/table names may stay as-is.
11
+
12
  ## Rules
13
 
14
  1. **Ground every claim in the provided context.** If the context doesn't contain the answer, say so plainly — do not guess. Never invent numbers, dates, or facts that aren't in the result rows or document chunks.