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

[NOTICKET] check: schema-detail view for column/type questions

Browse files

"kolom apa aja + tipe datanya?" now resolves straight to the data — no
filename required — instead of a bare source listing. A schema cue
(kolom/tipe/struktur/field) drills into the named source(s), or ALL structured
sources when none is named (documents excluded — no columns).

The rendered schema is humanized, ChatGPT-style and fully deterministic:
- lean per-table columns (Column · Data type); friendly type words
(teks/bilangan bulat/desimal); row count moved to the header line.
- adaptive columns: "Boleh kosong" only when a table mixes nullable values,
"PII" only when a column is flagged.
- per-source Ringkasan bucketing columns (Categorical / Numeric integer /
Numeric decimal / Date) — only buckets that exist; int vs float split.
- heuristic notes: a date-named string column → "convert to datetime"; a
numeric id-named column → "likely an identifier".
- big-database guard: a DB with >3 tables is summarised at the table level
(name + column/row counts) instead of dumping every column.

Supersedes the old name-match drill-down (a source referred to as "dataset
itu" now resolves via the single-/all-source fallback).

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

Files changed (1) hide show
  1. src/agents/handlers/check.py +270 -27
src/agents/handlers/check.py CHANGED
@@ -14,7 +14,7 @@ from __future__ import annotations
14
 
15
  import asyncio
16
  import re
17
- from typing import TYPE_CHECKING
18
 
19
  from src.tools.contracts import ToolOutput
20
 
@@ -166,7 +166,7 @@ def render_tool_output(out: ToolOutput, reply_language: str = "English") -> str:
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])
@@ -267,6 +267,250 @@ def _render_helicopter(
267
  return opener + "\n\n" + "\n\n".join(parts)
268
 
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  async def run_check(
271
  message: str, invoker: ToolInvoker, reply_language: str = "English"
272
  ) -> str:
@@ -287,36 +531,35 @@ async def run_check(
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", {})
292
  if inventory.kind == "error":
293
  return render_tool_output(inventory, reply_language)
294
- # Drill down to the schema of each source the user named; if they named
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:
315
- table = render_tool_output(out, reply_language)
316
- if table:
317
- label = (out.meta or {}).get("source_name") or "source"
318
- sections.append(f"**{label}**\n{table}")
319
- return "\n\n".join(sections) or _no_match
320
 
321
  # broad / ambiguous → helicopter view: call both concurrently
322
  data_out, knowledge_out = await asyncio.gather(
 
14
 
15
  import asyncio
16
  import re
17
+ from typing import TYPE_CHECKING, Any
18
 
19
  from src.tools.contracts import ToolOutput
20
 
 
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[Any]) -> str:
170
  if i == type_idx:
171
  return type_labels.get(str(row[i]), str(row[i]))
172
  return str(row[i])
 
267
  return opener + "\n\n" + "\n\n".join(parts)
268
 
269
 
270
+ # ---------------------------------------------------------------------------
271
+ # Schema-detail view (columns + types)
272
+ # ---------------------------------------------------------------------------
273
+
274
+ # Cues meaning "show me the columns/types", not just "what sources exist". When
275
+ # present, the handler drills into the schema of the named source(s) — or ALL
276
+ # structured sources when none is named — instead of listing. This is what lets
277
+ # "kolom apa aja yang aku miliki dan tipenya?" resolve straight to the data
278
+ # without the user having to type a full filename.
279
+ _SCHEMA_CUES = (
280
+ "kolom", "column", "tipe data", "data type", "tipe", "dtype",
281
+ "struktur", "structure", "schema", "skema", "field",
282
+ )
283
+
284
+ # A database with more tables than this is summarised (table names + counts)
285
+ # rather than dumping every column — otherwise a big DB is a wall of text.
286
+ _DB_TABLE_THRESHOLD = 3
287
+
288
+ # Friendly, localized data-type words (normalized from the catalog's raw type).
289
+ _TYPE_WORDS = {
290
+ "English": {"string": "text", "integer": "integer", "float": "decimal",
291
+ "date": "date", "boolean": "boolean", "other": "other"},
292
+ "Indonesian": {"string": "teks", "integer": "bilangan bulat", "float": "desimal",
293
+ "date": "tanggal", "boolean": "boolean", "other": "lainnya"},
294
+ }
295
+
296
+ # Localized strings for the schema view + the per-source Ringkasan.
297
+ _SCHEMA_STR = {
298
+ "English": {
299
+ "lead": "Here are the columns and data types across your {n} source{s}:",
300
+ "summary": "Summary:",
301
+ "cat": "Categorical", "int": "Numeric (integer)",
302
+ "float": "Numeric (decimal)", "date": "Date",
303
+ "note_date": "still text, consider converting to datetime",
304
+ "note_id": "likely an identifier",
305
+ "rows_word": "rows", "table_word": "Table",
306
+ "yes": "Yes", "no": "—",
307
+ "db_tables": "{name} has {n} tables:",
308
+ "db_item": "- {table} ({cols} columns{rows})",
309
+ "db_hint": "Name a table to see its columns.",
310
+ },
311
+ "Indonesian": {
312
+ "lead": "Berikut kolom dan tipe data di {n} sumber yang kamu punya:",
313
+ "summary": "Ringkasan:",
314
+ "cat": "Kategorikal", "int": "Numerik (bilangan bulat)",
315
+ "float": "Numerik (desimal)", "date": "Tanggal",
316
+ "note_date": "masih teks, sebaiknya dikonversi ke datetime",
317
+ "note_id": "kemungkinan identifier",
318
+ "rows_word": "baris", "table_word": "Tabel",
319
+ "yes": "Ya", "no": "—",
320
+ "db_tables": "{name} punya {n} tabel:",
321
+ "db_item": "- {table} ({cols} kolom{rows})",
322
+ "db_hint": "Sebut nama tabelnya untuk lihat kolomnya.",
323
+ },
324
+ }
325
+
326
+
327
+ def _sc(reply_language: str) -> dict[str, str]:
328
+ """Localized schema/summary string bundle; defaults to English."""
329
+ return _SCHEMA_STR.get(reply_language, _SCHEMA_STR["English"])
330
+
331
+
332
+ def _wants_schema(message: str) -> bool:
333
+ """True when the message asks about columns/types (schema detail)."""
334
+ lowered = message.lower()
335
+ return any(cue in lowered for cue in _SCHEMA_CUES)
336
+
337
+
338
+ def _type_category(raw: str) -> str:
339
+ """Normalize a catalog `data_type` string into a coarse category."""
340
+ r = (raw or "").lower()
341
+ if "bool" in r:
342
+ return "boolean"
343
+ if any(k in r for k in ("float", "double", "decimal", "numeric", "real")):
344
+ return "float"
345
+ if "int" in r:
346
+ return "integer"
347
+ if any(k in r for k in ("date", "time", "timestamp")):
348
+ return "date"
349
+ if any(k in r for k in ("char", "text", "string", "object", "varchar", "str")):
350
+ return "string"
351
+ return "other"
352
+
353
+
354
+ def _looks_date(name: str) -> bool:
355
+ return any(k in name for k in ("date", "tanggal", "tgl"))
356
+
357
+
358
+ def _looks_id(name: str) -> bool:
359
+ tokens = re.split(r"[^a-z0-9]+", name)
360
+ return "id" in tokens or "email" in tokens or name.endswith("id")
361
+
362
+
363
+ def _bucket_and_note(name: str, data_type: str, reply_language: str) -> tuple[str, str]:
364
+ """Assign a column to a Ringkasan bucket + an optional heuristic note.
365
+
366
+ Grouping is by data type, with two name-based overrides that mirror how a
367
+ human reads a schema: a date-named column goes to Date even when stored as
368
+ text (with a 'convert to datetime' note), and a numeric column whose name
369
+ looks like an id gets an 'identifier' note (shouldn't be treated as a
370
+ quantity). Deterministic — heuristics only, no LLM.
371
+ """
372
+ sc = _sc(reply_language)
373
+ cat = _type_category(data_type)
374
+ lname = name.lower()
375
+ if _looks_date(lname) or cat == "date":
376
+ return "date", (sc["note_date"] if cat != "date" else "")
377
+ if cat == "integer":
378
+ return "int", (sc["note_id"] if _looks_id(lname) else "")
379
+ if cat == "float":
380
+ return "float", (sc["note_id"] if _looks_id(lname) else "")
381
+ return "cat", ""
382
+
383
+
384
+ def _schema_rows(out: ToolOutput) -> list[dict[str, Any]]:
385
+ """Flatten a check_data(source_id) ToolOutput into per-column dicts."""
386
+ cols = out.columns or []
387
+ idx = {c: i for i, c in enumerate(cols)}
388
+
389
+ def _get(row: list[Any], key: str, default: object = None) -> object:
390
+ return row[idx[key]] if key in idx else default
391
+
392
+ rows: list[dict[str, Any]] = []
393
+ for r in out.rows or []:
394
+ rows.append({
395
+ "table": str(_get(r, "table_name", "")),
396
+ "table_rows": _get(r, "table_row_count"),
397
+ "column": str(_get(r, "column_name", "")),
398
+ "data_type": str(_get(r, "data_type", "")),
399
+ "nullable": bool(_get(r, "nullable", False)),
400
+ "pii": bool(_get(r, "pii_flag", False)),
401
+ })
402
+ return rows
403
+
404
+
405
+ def _columns_table(cols: list[dict[str, Any]], reply_language: str) -> str:
406
+ """Lean per-table markdown: Column · Data type (+ Nullable if mixed, + PII if any)."""
407
+ labels = _HEADER_LABELS.get(reply_language, _HEADER_LABELS["English"])
408
+ types = _TYPE_WORDS.get(reply_language, _TYPE_WORDS["English"])
409
+ sc = _sc(reply_language)
410
+ # Nullable only carries signal when a table mixes nullable + non-nullable;
411
+ # PII only when at least one column is flagged. Otherwise the column is noise.
412
+ show_nullable = len({c["nullable"] for c in cols}) > 1
413
+ show_pii = any(c["pii"] for c in cols)
414
+
415
+ headers = [labels["column_name"], labels["data_type"]]
416
+ if show_nullable:
417
+ headers.append(labels["nullable"])
418
+ if show_pii:
419
+ headers.append(labels["pii_flag"])
420
+ lines = [
421
+ "| " + " | ".join(headers) + " |",
422
+ "| " + " | ".join("---" for _ in headers) + " |",
423
+ ]
424
+ for c in cols:
425
+ cells = [c["column"], types.get(_type_category(c["data_type"]), c["data_type"])]
426
+ if show_nullable:
427
+ cells.append(sc["yes"] if c["nullable"] else sc["no"])
428
+ if show_pii:
429
+ cells.append(sc["yes"] if c["pii"] else sc["no"])
430
+ lines.append("| " + " | ".join(cells) + " |")
431
+ return "\n".join(lines)
432
+
433
+
434
+ def _render_summary(cols: list[dict[str, Any]], reply_language: str) -> str:
435
+ """Adaptive per-source Ringkasan: only buckets that actually have columns."""
436
+ sc = _sc(reply_language)
437
+ buckets: dict[str, list[str]] = {"cat": [], "date": [], "int": [], "float": []}
438
+ for c in cols:
439
+ bucket, note = _bucket_and_note(c["column"], c["data_type"], reply_language)
440
+ buckets[bucket].append(f"{c['column']} ({note})" if note else c["column"])
441
+ lines = [sc["summary"]]
442
+ emitted = False
443
+ for key in ("cat", "date", "int", "float"):
444
+ if buckets[key]:
445
+ emitted = True
446
+ lines.append(f"- {sc[key]}: " + ", ".join(buckets[key]))
447
+ return "\n".join(lines) if emitted else ""
448
+
449
+
450
+ def _render_schema_source(out: ToolOutput, reply_language: str) -> str:
451
+ """Render one source's schema: columns per table + a Ringkasan.
452
+
453
+ A database with more than `_DB_TABLE_THRESHOLD` tables is summarised at the
454
+ table level instead (name + column/row counts) so it doesn't become a wall
455
+ of text; the user names a table to drill into its columns.
456
+ """
457
+ if out.kind == "error":
458
+ return _s(reply_language)["lookup_error"].format(error=out.error)
459
+ rows = _schema_rows(out)
460
+ if not rows:
461
+ return ""
462
+ meta = out.meta or {}
463
+ name = str(meta.get("source_name") or "source")
464
+ source_type = str(meta.get("source_type") or "")
465
+ sc = _sc(reply_language)
466
+
467
+ tables: dict[str, list[dict[str, Any]]] = {}
468
+ for r in rows:
469
+ tables.setdefault(r["table"], []).append(r)
470
+
471
+ def _rows_suffix(rc: object) -> str:
472
+ return f", {rc} {sc['rows_word']}" if rc else ""
473
+
474
+ if source_type == "schema" and len(tables) > _DB_TABLE_THRESHOLD:
475
+ lines = [sc["db_tables"].format(name=name, n=len(tables))]
476
+ for tname, tcols in tables.items():
477
+ rc = tcols[0]["table_rows"]
478
+ lines.append(
479
+ sc["db_item"].format(table=tname, cols=len(tcols), rows=_rows_suffix(rc))
480
+ )
481
+ lines.append(sc["db_hint"])
482
+ return "\n".join(lines)
483
+
484
+ parts: list[str] = []
485
+ if len(tables) == 1:
486
+ tname, tcols = next(iter(tables.items()))
487
+ rc = tcols[0]["table_rows"]
488
+ head = f"**{name}**" + (f" ({rc} {sc['rows_word']})" if rc else "")
489
+ parts.append(f"{head}\n{_columns_table(tcols, reply_language)}")
490
+ else:
491
+ parts.append(f"**{name}**")
492
+ for tname, tcols in tables.items():
493
+ rc = tcols[0]["table_rows"]
494
+ sub = f"{sc['table_word']} {tname}" + (f" ({rc} {sc['rows_word']})" if rc else "") + ":"
495
+ parts.append(f"{sub}\n{_columns_table(tcols, reply_language)}")
496
+
497
+ summary = _render_summary(rows, reply_language)
498
+ if summary:
499
+ parts.append(summary)
500
+ return "\n\n".join(parts)
501
+
502
+
503
+ def _render_schemas(schemas: list[ToolOutput], reply_language: str) -> str:
504
+ """Stitch one or more source schemas under a single lead-in sentence."""
505
+ blocks = [b for b in (_render_schema_source(o, reply_language) for o in schemas) if b]
506
+ if not blocks:
507
+ return ""
508
+ n = len(blocks)
509
+ suffix = "s" if (reply_language == "English" and n != 1) else ""
510
+ lead = _sc(reply_language)["lead"].format(n=n, s=suffix)
511
+ return lead + "\n\n" + "\n\n".join(blocks)
512
+
513
+
514
  async def run_check(
515
  message: str, invoker: ToolInvoker, reply_language: str = "English"
516
  ) -> str:
 
531
  return _no_match
532
  return _lead("documents", reply_language, len(out.rows or [])) + "\n\n" + listing
533
 
534
+ # Schema-detail question ("kolom apa aja + tipe datanya"): drill into the
535
+ # schema of the named source(s), or ALL structured sources when none is
536
+ # named — so the user never has to type a full filename to see their columns
537
+ # and types. This supersedes name-matched drill-down (a source referred to as
538
+ # "dataset itu" now resolves via the single-/all-source fallback).
539
+ if _wants_schema(message):
540
  inventory = await invoker.invoke("check_data", {})
541
  if inventory.kind == "error":
542
  return render_tool_output(inventory, reply_language)
543
+ if not (inventory.rows or []):
544
+ return _no_match
545
+ named = _matched_source_ids(message, inventory)
546
+ cols = inventory.columns or []
547
+ sid_i = cols.index("source_id") if "source_id" in cols else 0
548
+ ids = named or [str(r[sid_i]) for r in (inventory.rows or [])]
 
 
 
549
  schemas = await asyncio.gather(
550
+ *(invoker.invoke("check_data", {"source_id": sid}) for sid in ids)
551
  )
552
+ return _render_schemas(schemas, reply_language) or _no_match
553
+
554
+ if intent == "data":
555
+ inventory = await invoker.invoke("check_data", {})
556
+ if inventory.kind == "error":
557
+ return render_tool_output(inventory, reply_language)
558
+ listing = _render_source_list(inventory, reply_language)
559
+ if not listing:
560
+ return _no_match
561
+ n = len(inventory.rows or [])
562
+ return _lead("structured", reply_language, n) + "\n\n" + listing
 
 
 
563
 
564
  # broad / ambiguous → helicopter view: call both concurrently
565
  data_out, knowledge_out = await asyncio.gather(