Commit ·
3e1772d
1
Parent(s): 0bbc27b
[NOTICKET] fix(agents): lock analysis answer language to the user's turn
Browse filesThe 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 +7 -0
- src/agents/handlers/help.py +9 -67
- src/agents/language.py +92 -0
- src/config/prompts/chatbot_system.md +9 -0
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 |
-
|
| 62 |
-
|
| 63 |
-
#
|
| 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.
|