sofhiaazzhr Claude Opus 4.8 commited on
Commit
2bdda8f
·
1 Parent(s): 23cc207

[NOTICKET] Humanize check output: hide UUIDs, add lead-in, list format

Browse files

Turn the deterministic check reply into something that reads like an answer
instead of a raw catalog dump:

- Hide internal id columns (source_id/table_id/column_id) from rendered output.
- Humanize + localize (EN/ID) headers and the source_type enum
(tabular -> "File tabular"/"Tabular file", schema -> "Database", etc.).
- Add localized lead-in sentences ("Berikut data yang kamu punya:", "Kamu
punya N sumber data terstruktur:", "Berikut isi X:").
- Render the source *listing* as a grouped bullet list (structured vs
documents) rather than a table; the column-level schema drill-down stays a
table since that data is genuinely tabular.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (1) hide show
  1. src/agents/handlers/check.py +136 -15
src/agents/handlers/check.py CHANGED
@@ -54,9 +54,40 @@ _DATA_CUES = (
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.",
@@ -80,6 +111,31 @@ def _s(reply_language: str) -> dict[str, str]:
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()
@@ -100,14 +156,67 @@ def render_tool_output(out: ToolOutput, reply_language: str = "English") -> str:
100
  rows = out.rows or []
101
  if not rows:
102
  return ""
103
- header = "| " + " | ".join(columns) + " |"
104
- separator = "| " + " | ".join("---" for _ in columns) + " |"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  body = "\n".join(
106
- "| " + " | ".join(str(cell) for cell in row) + " |" for row in rows
107
  )
108
  return f"{header}\n{separator}\n{body}"
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
112
  """All source_ids whose name appears as a whole word in the message.
113
 
@@ -143,18 +252,19 @@ def _render_helicopter(
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(
@@ -172,7 +282,10 @@ async def run_check(
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", {})
@@ -182,12 +295,20 @@ async def run_check(
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:
 
54
  )
55
 
56
 
57
+ # Internal id columns — meaningful to the system (drill-down keys off them via
58
+ # the ToolOutput rows), but noise in a user-facing answer. Hidden from the
59
+ # rendered table; the raw ToolOutput still carries them for _matched_source_ids.
60
+ # `table_count` is kept: always 1 for a tabular file, but the real table count
61
+ # for a database, which is worth showing.
62
+ _HIDDEN_COLUMNS = frozenset({"source_id", "table_id", "column_id"})
63
+
64
+ # Friendly, localized labels for the catalog column ids the tools emit. Any
65
+ # column not listed falls back to its raw name.
66
+ _HEADER_LABELS = {
67
+ "English": {
68
+ "name": "Name", "source_type": "Type", "table_count": "Tables",
69
+ "table_name": "Table", "table_row_count": "Rows",
70
+ "column_name": "Column", "data_type": "Data type",
71
+ "nullable": "Nullable", "pii_flag": "PII",
72
+ },
73
+ "Indonesian": {
74
+ "name": "Nama", "source_type": "Jenis", "table_count": "Jumlah tabel",
75
+ "table_name": "Tabel", "table_row_count": "Jumlah baris",
76
+ "column_name": "Kolom", "data_type": "Tipe data",
77
+ "nullable": "Boleh kosong", "pii_flag": "PII",
78
+ },
79
+ }
80
+
81
+ # Friendly values for the raw `source_type` enum ("schema"/"tabular"/"unstructured").
82
+ _SOURCE_TYPE_LABELS = {
83
+ "English": {"tabular": "Tabular file", "schema": "Database", "unstructured": "Document"},
84
+ "Indonesian": {"tabular": "File tabular", "schema": "Database", "unstructured": "Dokumen"},
85
+ }
86
+
87
+
88
  # User-facing fixed strings, localized by reply language (detect_reply_language
89
+ # returns "Indonesian"/"English"). Table headers/values are humanized via the
90
+ # maps above; only the prose the user reads is translated here.
91
  _STRINGS = {
92
  "English": {
93
  "no_match": "Nothing registered yet — I don't see any matching sources.",
 
111
  return _STRINGS.get(reply_language, _STRINGS["English"])
112
 
113
 
114
+ # Natural-language lead-ins so a check reply reads like an answer, not a raw
115
+ # table dump. `{n}` = item count, `{s}` = English plural suffix, `{name}` = source.
116
+ _LEADS = {
117
+ "English": {
118
+ "opener": "Here's what you have:",
119
+ "structured": "You have {n} structured data source{s}:",
120
+ "documents": "You have {n} document{s}:",
121
+ "schema": "Here's what's inside {name}:",
122
+ },
123
+ "Indonesian": {
124
+ "opener": "Berikut data yang kamu punya:",
125
+ "structured": "Kamu punya {n} sumber data terstruktur:",
126
+ "documents": "Kamu punya {n} dokumen:",
127
+ "schema": "Berikut isi {name}:",
128
+ },
129
+ }
130
+
131
+
132
+ def _lead(kind: str, reply_language: str, n: int = 0, name: str = "") -> str:
133
+ """Build a localized lead-in sentence (English pluralizes on {n})."""
134
+ bundle = _LEADS.get(reply_language, _LEADS["English"])
135
+ suffix = "s" if (reply_language == "English" and n != 1) else ""
136
+ return bundle[kind].format(n=n, s=suffix, name=name)
137
+
138
+
139
  def _intent(message: str) -> str:
140
  """Return 'knowledge', 'data', or 'both' (helicopter view) from keyword cues."""
141
  lowered = message.lower()
 
156
  rows = out.rows or []
157
  if not rows:
158
  return ""
159
+
160
+ # Drop internal id columns, humanize the rest of the headers, and translate
161
+ # the source_type enum — the user sees names, not UUIDs and raw enums.
162
+ keep = [i for i, c in enumerate(columns) if c not in _HIDDEN_COLUMNS] or list(
163
+ range(len(columns))
164
+ )
165
+ labels = _HEADER_LABELS.get(reply_language, _HEADER_LABELS["English"])
166
+ type_labels = _SOURCE_TYPE_LABELS.get(reply_language, _SOURCE_TYPE_LABELS["English"])
167
+ type_idx = columns.index("source_type") if "source_type" in columns else -1
168
+
169
+ def _cell(i: int, row: list) -> str:
170
+ if i == type_idx:
171
+ return type_labels.get(str(row[i]), str(row[i]))
172
+ return str(row[i])
173
+
174
+ header = "| " + " | ".join(labels.get(columns[i], columns[i]) for i in keep) + " |"
175
+ separator = "| " + " | ".join("---" for _ in keep) + " |"
176
  body = "\n".join(
177
+ "| " + " | ".join(_cell(i, row) for i in keep) + " |" for row in rows
178
  )
179
  return f"{header}\n{separator}\n{body}"
180
 
181
 
182
+ def _render_source_list(out: ToolOutput, reply_language: str) -> str:
183
+ """Render a check_data/check_knowledge *listing* as a bullet list, not a table.
184
+
185
+ One bullet per source: `- name — Type (N tables)`. The type + table-count
186
+ annotation is only added for structured sources (file vs database); documents
187
+ are all "unstructured", so the section header already says so — just the name.
188
+ Returns '' when there are no rows. (The column-level schema drill-down still
189
+ renders as a table via `render_tool_output` — that data is genuinely tabular.)
190
+ """
191
+ if out.kind == "error":
192
+ return _s(reply_language)["lookup_error"].format(error=out.error)
193
+ columns = out.columns or []
194
+ rows = out.rows or []
195
+ if not rows:
196
+ return ""
197
+
198
+ idx = {c: i for i, c in enumerate(columns)}
199
+ type_labels = _SOURCE_TYPE_LABELS.get(reply_language, _SOURCE_TYPE_LABELS["English"])
200
+
201
+ def _table_word(n: int) -> str:
202
+ if reply_language == "English":
203
+ return "table" if n == 1 else "tables"
204
+ return "tabel"
205
+
206
+ items: list[str] = []
207
+ for row in rows:
208
+ name = str(row[idx["name"]]) if "name" in idx else ""
209
+ st = str(row[idx["source_type"]]) if "source_type" in idx else ""
210
+ annotation = ""
211
+ if st and st != "unstructured":
212
+ annotation = type_labels.get(st, st)
213
+ if "table_count" in idx:
214
+ tc = row[idx["table_count"]]
215
+ annotation += f" ({tc} {_table_word(int(tc))})"
216
+ items.append(f"- {name} — {annotation}" if annotation else f"- {name}")
217
+ return "\n".join(items)
218
+
219
+
220
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
221
  """All source_ids whose name appears as a whole word in the message.
222
 
 
252
  strings = _s(reply_language)
253
  parts: list[str] = []
254
 
255
+ data_list = _render_source_list(data_out, reply_language)
256
+ if data_list:
257
+ parts.append(f"{strings['structured']}:\n{data_list}")
258
 
259
+ knowledge_list = _render_source_list(knowledge_out, reply_language)
260
+ if knowledge_list:
261
+ parts.append(f"{strings['documents']}:\n{knowledge_list}")
262
 
263
  if not parts:
264
  return strings["none"]
265
 
266
+ opener = _lead("opener", reply_language)
267
+ return opener + "\n\n" + "\n\n".join(parts)
268
 
269
 
270
  async def run_check(
 
282
 
283
  if intent == "knowledge":
284
  out = await invoker.invoke("check_knowledge", {})
285
+ listing = _render_source_list(out, reply_language)
286
+ if not listing:
287
+ return _no_match
288
+ return _lead("documents", reply_language, len(out.rows or [])) + "\n\n" + listing
289
 
290
  if intent == "data":
291
  inventory = await invoker.invoke("check_data", {})
 
295
  # none, return the source listing.
296
  source_ids = _matched_source_ids(message, inventory)
297
  if not source_ids:
298
+ listing = _render_source_list(inventory, reply_language)
299
+ if not listing:
300
+ return _no_match
301
+ n = len(inventory.rows or [])
302
+ return _lead("structured", reply_language, n) + "\n\n" + listing
303
  schemas = await asyncio.gather(
304
  *(invoker.invoke("check_data", {"source_id": sid}) for sid in source_ids)
305
  )
306
  if len(schemas) == 1:
307
+ table = render_tool_output(schemas[0], reply_language)
308
+ if not table:
309
+ return _no_match
310
+ name = (schemas[0].meta or {}).get("source_name") or ""
311
+ return _lead("schema", reply_language, name=name) + "\n\n" + table
312
  # Multiple named sources → one labelled section per source.
313
  sections: list[str] = []
314
  for out in schemas: