[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>
- src/agents/handlers/check.py +270 -27
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
inventory = await invoker.invoke("check_data", {})
|
| 292 |
if inventory.kind == "error":
|
| 293 |
return render_tool_output(inventory, reply_language)
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 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
|
| 305 |
)
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
return
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 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(
|