sofhiaazzhr Claude Opus 4.8 commited on
Commit
1367f41
·
1 Parent(s): 0e5fdb5

[NOTICKET] Fix check skill: reply language + tabular inventory routing

Browse files

The `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 CHANGED
@@ -443,7 +443,11 @@ class ChatHandler:
443
  elif intent == "check":
444
  try:
445
  invoker = self._get_check_invoker(user_id)
446
- text = await run_check(rewritten, invoker)
 
 
 
 
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}"}
src/agents/handlers/check.py CHANGED
@@ -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 f"Sorry, I couldn't look that up: {out.error}"
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(data_out: ToolOutput, knowledge_out: ToolOutput) -> str:
 
 
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"**Data terstruktur**\n{data_table}")
116
 
117
- knowledge_table = render_tool_output(knowledge_out)
118
  if knowledge_table:
119
- parts.append(f"**Dokumen**\n{knowledge_table}")
120
 
121
  if not parts:
122
- return "Nothing registered yet — I don't see any sources or documents."
123
 
124
  return "\n\n".join(parts)
125
 
126
 
127
- async def run_check(message: str, invoker: ToolInvoker) -> str:
128
- """Route to check_data, check_knowledge, or both (helicopter view) based on cues."""
 
 
 
 
 
 
 
129
  intent = _intent(message)
130
 
131
- _no_match = "Nothing registered yet — I don't see any matching sources."
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)