[NOTICKET] Fix check skill: reply language + tabular inventory routing
Browse filesThe `check` intent path never applied reply-language detection and hardcoded
English strings, so inventory questions ("data apa saja yang aku punya?")
always answered in English regardless of the room's language. It also routed
generic words ("uploaded", "file") to the document-only branch, so a user with
only tabular (CSV/XLSX) sources got "nothing registered" — their data lives
under check_data, not check_knowledge.
- chat_handler: detect reply_language from the original message and pass it to
run_check (mirrors the structured_flow branch).
- check.py: localize fixed prose (no_match, helicopter labels, error) via an
EN/ID string bundle; thread reply_language through render helpers.
- check.py: drop "uploaded"/"file" from knowledge cues so bare inventory
questions fall through to the helicopter view (structured + documents).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/agents/chat_handler.py +5 -1
- src/agents/handlers/check.py +60 -20
|
@@ -443,7 +443,11 @@ class ChatHandler:
|
|
| 443 |
elif intent == "check":
|
| 444 |
try:
|
| 445 |
invoker = self._get_check_invoker(user_id)
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
except Exception as e:
|
| 448 |
logger.error("check route failed", user_id=user_id, error=str(e))
|
| 449 |
yield {"event": "error", "data": f"Lookup failed: {e}"}
|
|
|
|
| 443 |
elif intent == "check":
|
| 444 |
try:
|
| 445 |
invoker = self._get_check_invoker(user_id)
|
| 446 |
+
# Detect from the ORIGINAL message (not `rewritten`, which the
|
| 447 |
+
# router normalizes to English) so the deterministic check reply
|
| 448 |
+
# matches the user's language like the other paths.
|
| 449 |
+
reply_language = detect_reply_language(history, message=message)
|
| 450 |
+
text = await run_check(rewritten, invoker, reply_language)
|
| 451 |
except Exception as e:
|
| 452 |
logger.error("check route failed", user_id=user_id, error=str(e))
|
| 453 |
yield {"event": "error", "data": f"Lookup failed: {e}"}
|
|
@@ -21,16 +21,20 @@ from src.tools.contracts import ToolOutput
|
|
| 21 |
if TYPE_CHECKING:
|
| 22 |
from src.agents.slow_path.invoker import ToolInvoker
|
| 23 |
|
| 24 |
-
# Cues that point at documents rather than structured data.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
_KNOWLEDGE_CUES = (
|
| 26 |
"document",
|
| 27 |
"docs",
|
| 28 |
"doc ",
|
| 29 |
-
"file",
|
| 30 |
"pdf",
|
| 31 |
"docx",
|
| 32 |
".txt",
|
| 33 |
-
"uploaded",
|
| 34 |
"knowledge",
|
| 35 |
"dokumen",
|
| 36 |
)
|
|
@@ -50,6 +54,32 @@ _DATA_CUES = (
|
|
| 50 |
)
|
| 51 |
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
def _intent(message: str) -> str:
|
| 54 |
"""Return 'knowledge', 'data', or 'both' (helicopter view) from keyword cues."""
|
| 55 |
lowered = message.lower()
|
|
@@ -62,10 +92,10 @@ def _intent(message: str) -> str:
|
|
| 62 |
return "both"
|
| 63 |
|
| 64 |
|
| 65 |
-
def render_tool_output(out: ToolOutput) -> str:
|
| 66 |
"""Render a `check_*` ToolOutput table into a markdown string, or '' if empty."""
|
| 67 |
if out.kind == "error":
|
| 68 |
-
return
|
| 69 |
columns = out.columns or []
|
| 70 |
rows = out.rows or []
|
| 71 |
if not rows:
|
|
@@ -106,52 +136,62 @@ def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
|
|
| 106 |
return matched
|
| 107 |
|
| 108 |
|
| 109 |
-
def _render_helicopter(
|
|
|
|
|
|
|
| 110 |
"""Stitch structured + document inventory into one helicopter-view reply."""
|
|
|
|
| 111 |
parts: list[str] = []
|
| 112 |
|
| 113 |
-
data_table = render_tool_output(data_out)
|
| 114 |
if data_table:
|
| 115 |
-
parts.append(f"**
|
| 116 |
|
| 117 |
-
knowledge_table = render_tool_output(knowledge_out)
|
| 118 |
if knowledge_table:
|
| 119 |
-
parts.append(f"**
|
| 120 |
|
| 121 |
if not parts:
|
| 122 |
-
return "
|
| 123 |
|
| 124 |
return "\n\n".join(parts)
|
| 125 |
|
| 126 |
|
| 127 |
-
async def run_check(
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
intent = _intent(message)
|
| 130 |
|
| 131 |
-
_no_match = "
|
| 132 |
|
| 133 |
if intent == "knowledge":
|
| 134 |
out = await invoker.invoke("check_knowledge", {})
|
| 135 |
-
return render_tool_output(out) or _no_match
|
| 136 |
|
| 137 |
if intent == "data":
|
| 138 |
inventory = await invoker.invoke("check_data", {})
|
| 139 |
if inventory.kind == "error":
|
| 140 |
-
return render_tool_output(inventory)
|
| 141 |
# Drill down to the schema of each source the user named; if they named
|
| 142 |
# none, return the source listing.
|
| 143 |
source_ids = _matched_source_ids(message, inventory)
|
| 144 |
if not source_ids:
|
| 145 |
-
return render_tool_output(inventory) or _no_match
|
| 146 |
schemas = await asyncio.gather(
|
| 147 |
*(invoker.invoke("check_data", {"source_id": sid}) for sid in source_ids)
|
| 148 |
)
|
| 149 |
if len(schemas) == 1:
|
| 150 |
-
return render_tool_output(schemas[0]) or _no_match
|
| 151 |
# Multiple named sources → one labelled section per source.
|
| 152 |
sections: list[str] = []
|
| 153 |
for out in schemas:
|
| 154 |
-
table = render_tool_output(out)
|
| 155 |
if table:
|
| 156 |
label = (out.meta or {}).get("source_name") or "source"
|
| 157 |
sections.append(f"**{label}**\n{table}")
|
|
@@ -162,4 +202,4 @@ async def run_check(message: str, invoker: ToolInvoker) -> str:
|
|
| 162 |
invoker.invoke("check_data", {}),
|
| 163 |
invoker.invoke("check_knowledge", {}),
|
| 164 |
)
|
| 165 |
-
return _render_helicopter(data_out, knowledge_out)
|
|
|
|
| 21 |
if TYPE_CHECKING:
|
| 22 |
from src.agents.slow_path.invoker import ToolInvoker
|
| 23 |
|
| 24 |
+
# Cues that point at documents rather than structured data. Deliberately
|
| 25 |
+
# document-SPECIFIC: generic words like "file" and "uploaded" are excluded
|
| 26 |
+
# because users say "file/data yang aku upload" about tabular datasets too, and
|
| 27 |
+
# forcing those to the document-only branch made "what data have I uploaded?"
|
| 28 |
+
# answer "nothing" for a user whose data is tabular (lives under check_data).
|
| 29 |
+
# A bare inventory question with no specific cue falls through to the
|
| 30 |
+
# helicopter view (both structured + documents) instead.
|
| 31 |
_KNOWLEDGE_CUES = (
|
| 32 |
"document",
|
| 33 |
"docs",
|
| 34 |
"doc ",
|
|
|
|
| 35 |
"pdf",
|
| 36 |
"docx",
|
| 37 |
".txt",
|
|
|
|
| 38 |
"knowledge",
|
| 39 |
"dokumen",
|
| 40 |
)
|
|
|
|
| 54 |
)
|
| 55 |
|
| 56 |
|
| 57 |
+
# User-facing fixed strings, localized by reply language (detect_reply_language
|
| 58 |
+
# returns "Indonesian"/"English"). Table headers stay as the tool's technical
|
| 59 |
+
# column ids; only the prose the user reads is translated.
|
| 60 |
+
_STRINGS = {
|
| 61 |
+
"English": {
|
| 62 |
+
"no_match": "Nothing registered yet — I don't see any matching sources.",
|
| 63 |
+
"none": "Nothing registered yet — I don't see any sources or documents.",
|
| 64 |
+
"structured": "Structured data",
|
| 65 |
+
"documents": "Documents",
|
| 66 |
+
"lookup_error": "Sorry, I couldn't look that up: {error}",
|
| 67 |
+
},
|
| 68 |
+
"Indonesian": {
|
| 69 |
+
"no_match": "Belum ada yang terdaftar — aku tidak menemukan sumber data yang cocok.",
|
| 70 |
+
"none": "Belum ada yang terdaftar — aku tidak menemukan sumber data atau dokumen.",
|
| 71 |
+
"structured": "Data terstruktur",
|
| 72 |
+
"documents": "Dokumen",
|
| 73 |
+
"lookup_error": "Maaf, aku tidak bisa mencarinya: {error}",
|
| 74 |
+
},
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _s(reply_language: str) -> dict[str, str]:
|
| 79 |
+
"""Localized string bundle; defaults to English for an unknown language."""
|
| 80 |
+
return _STRINGS.get(reply_language, _STRINGS["English"])
|
| 81 |
+
|
| 82 |
+
|
| 83 |
def _intent(message: str) -> str:
|
| 84 |
"""Return 'knowledge', 'data', or 'both' (helicopter view) from keyword cues."""
|
| 85 |
lowered = message.lower()
|
|
|
|
| 92 |
return "both"
|
| 93 |
|
| 94 |
|
| 95 |
+
def render_tool_output(out: ToolOutput, reply_language: str = "English") -> str:
|
| 96 |
"""Render a `check_*` ToolOutput table into a markdown string, or '' if empty."""
|
| 97 |
if out.kind == "error":
|
| 98 |
+
return _s(reply_language)["lookup_error"].format(error=out.error)
|
| 99 |
columns = out.columns or []
|
| 100 |
rows = out.rows or []
|
| 101 |
if not rows:
|
|
|
|
| 136 |
return matched
|
| 137 |
|
| 138 |
|
| 139 |
+
def _render_helicopter(
|
| 140 |
+
data_out: ToolOutput, knowledge_out: ToolOutput, reply_language: str = "English"
|
| 141 |
+
) -> str:
|
| 142 |
"""Stitch structured + document inventory into one helicopter-view reply."""
|
| 143 |
+
strings = _s(reply_language)
|
| 144 |
parts: list[str] = []
|
| 145 |
|
| 146 |
+
data_table = render_tool_output(data_out, reply_language)
|
| 147 |
if data_table:
|
| 148 |
+
parts.append(f"**{strings['structured']}**\n{data_table}")
|
| 149 |
|
| 150 |
+
knowledge_table = render_tool_output(knowledge_out, reply_language)
|
| 151 |
if knowledge_table:
|
| 152 |
+
parts.append(f"**{strings['documents']}**\n{knowledge_table}")
|
| 153 |
|
| 154 |
if not parts:
|
| 155 |
+
return strings["none"]
|
| 156 |
|
| 157 |
return "\n\n".join(parts)
|
| 158 |
|
| 159 |
|
| 160 |
+
async def run_check(
|
| 161 |
+
message: str, invoker: ToolInvoker, reply_language: str = "English"
|
| 162 |
+
) -> str:
|
| 163 |
+
"""Route to check_data, check_knowledge, or both (helicopter view) based on cues.
|
| 164 |
+
|
| 165 |
+
`reply_language` ("Indonesian"/"English", from `detect_reply_language`)
|
| 166 |
+
localizes the fixed prose so this deterministic path answers in the user's
|
| 167 |
+
language like the LLM paths do.
|
| 168 |
+
"""
|
| 169 |
intent = _intent(message)
|
| 170 |
|
| 171 |
+
_no_match = _s(reply_language)["no_match"]
|
| 172 |
|
| 173 |
if intent == "knowledge":
|
| 174 |
out = await invoker.invoke("check_knowledge", {})
|
| 175 |
+
return render_tool_output(out, reply_language) or _no_match
|
| 176 |
|
| 177 |
if intent == "data":
|
| 178 |
inventory = await invoker.invoke("check_data", {})
|
| 179 |
if inventory.kind == "error":
|
| 180 |
+
return render_tool_output(inventory, reply_language)
|
| 181 |
# Drill down to the schema of each source the user named; if they named
|
| 182 |
# none, return the source listing.
|
| 183 |
source_ids = _matched_source_ids(message, inventory)
|
| 184 |
if not source_ids:
|
| 185 |
+
return render_tool_output(inventory, reply_language) or _no_match
|
| 186 |
schemas = await asyncio.gather(
|
| 187 |
*(invoker.invoke("check_data", {"source_id": sid}) for sid in source_ids)
|
| 188 |
)
|
| 189 |
if len(schemas) == 1:
|
| 190 |
+
return render_tool_output(schemas[0], reply_language) or _no_match
|
| 191 |
# Multiple named sources → one labelled section per source.
|
| 192 |
sections: list[str] = []
|
| 193 |
for out in schemas:
|
| 194 |
+
table = render_tool_output(out, reply_language)
|
| 195 |
if table:
|
| 196 |
label = (out.meta or {}).get("source_name") or "source"
|
| 197 |
sections.append(f"**{label}**\n{table}")
|
|
|
|
| 202 |
invoker.invoke("check_data", {}),
|
| 203 |
invoker.invoke("check_knowledge", {}),
|
| 204 |
)
|
| 205 |
+
return _render_helicopter(data_out, knowledge_out, reply_language)
|