Commit ·
0e64f6b
1
Parent(s): a79d8da
[NOTICKET] feat(report): evidence tables for cluster, anomaly, descriptive, data-quality and forecast
Browse files
src/agents/report/anchored.py
CHANGED
|
@@ -321,8 +321,92 @@ _TOOL_TABLES: dict[str, Any] = {
|
|
| 321 |
["column a", "column b", "correlation"],
|
| 322 |
[[p.get("a"), p.get("b"), p.get("corr")] for p in _dicts(v.get("pairs"))],
|
| 323 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
}
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
def _tool_table(rec: AnalysisRecord) -> tuple[list[str], list[list[Any]]] | None:
|
| 328 |
"""(columns, rows) of the LAST analysis-tool output with a table form, else None."""
|
|
|
|
| 321 |
["column a", "column b", "correlation"],
|
| 322 |
[[p.get("a"), p.get("b"), p.get("corr")] for p in _dicts(v.get("pairs"))],
|
| 323 |
),
|
| 324 |
+
# 2026-11-03 (test-demo #17): the profiling / ML tools return `stats`, so a cluster,
|
| 325 |
+
# anomaly or forecast answer had no evidence at all (its raw model input is skipped)
|
| 326 |
+
# and a descriptive / data-quality answer was evidenced by ten raw rows.
|
| 327 |
+
"analyze_cluster": lambda v: _cluster_table(v),
|
| 328 |
+
"analyze_anomaly": lambda v: _anomaly_table(v),
|
| 329 |
+
"analyze_descriptive": lambda v: _descriptive_table(v),
|
| 330 |
+
"analyze_data_quality": lambda v: _data_quality_table(v),
|
| 331 |
+
"analyze_forecast": lambda v: (
|
| 332 |
+
["period", "value", "kind"],
|
| 333 |
+
[[p.get("period"), p.get("value"), "actual"] for p in _dicts(v.get("history"))[-3:]]
|
| 334 |
+
+ [[p.get("period"), p.get("value"), "forecast"] for p in _dicts(v.get("forecast"))],
|
| 335 |
+
),
|
| 336 |
}
|
| 337 |
|
| 338 |
+
# Members per cluster shown in the evidence table; the tool carries up to 15.
|
| 339 |
+
_EVIDENCE_MEMBERS = 5
|
| 340 |
+
# Scored columns shown per anomaly row, after its label columns and before the score.
|
| 341 |
+
_EVIDENCE_ANOMALY_COLS = 4
|
| 342 |
+
_DESCRIPTIVE_STATS = ("count", "mean", "median", "std", "min", "max")
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def _cluster_table(v: dict) -> tuple[list[str], list[list[Any]]]:
|
| 346 |
+
"""cluster · size · share · per-column means · a few example members."""
|
| 347 |
+
measures = [c for c in v.get("column_ids") or [] if isinstance(c, str)]
|
| 348 |
+
clusters = _dicts(v.get("clusters"))
|
| 349 |
+
named = any("example_members" in c for c in clusters)
|
| 350 |
+
cols = ["cluster", "size", "share %"] + measures + (["example members"] if named else [])
|
| 351 |
+
rows = []
|
| 352 |
+
for c in clusters:
|
| 353 |
+
row = [c.get("cluster"), c.get("size"), _pct(c.get("share"))]
|
| 354 |
+
row += [c.get(m) for m in measures]
|
| 355 |
+
if named:
|
| 356 |
+
members = [str(m) for m in c.get("example_members") or []]
|
| 357 |
+
shown = members[:_EVIDENCE_MEMBERS]
|
| 358 |
+
more = " …" if (c.get("size") or 0) > len(shown) else ""
|
| 359 |
+
row.append(", ".join(shown) + more)
|
| 360 |
+
rows.append(row)
|
| 361 |
+
return cols, rows
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _anomaly_table(v: dict) -> tuple[list[str], list[list[Any]]]:
|
| 365 |
+
"""The flagged rows, most anomalous first: labels, a few scored columns, score."""
|
| 366 |
+
flagged = _dicts(v.get("anomalies"))
|
| 367 |
+
if not flagged:
|
| 368 |
+
return [], []
|
| 369 |
+
scored = [c for c in v.get("column_ids") or [] if isinstance(c, str)]
|
| 370 |
+
labels = [k for k in flagged[0] if k not in scored and k != "anomaly_score"]
|
| 371 |
+
cols = labels + scored[:_EVIDENCE_ANOMALY_COLS] + ["anomaly_score"]
|
| 372 |
+
return cols, [[a.get(c) for c in cols] for a in flagged]
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _descriptive_table(v: dict) -> tuple[list[str], list[list[Any]]]:
|
| 376 |
+
"""One row per column with its headline statistics."""
|
| 377 |
+
profiles = {k: p for k, p in v.items() if isinstance(p, dict)}
|
| 378 |
+
present = [s for s in _DESCRIPTIVE_STATS if any(s in p for p in profiles.values())]
|
| 379 |
+
return ["column"] + present, [[k] + [p.get(s) for s in present] for k, p in profiles.items()]
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _data_quality_table(v: dict) -> tuple[list[str], list[list[Any]]]:
|
| 383 |
+
"""One row per column, the most missing / most outliers first.
|
| 384 |
+
|
| 385 |
+
A 0/1 flag (≤ 2 distinct values) sorts after the rest: its IQR "outliers" are just
|
| 386 |
+
the rarer value — the local run of 3 Nov put `is_mtbs_achieved` on top.
|
| 387 |
+
"""
|
| 388 |
+
columns = v.get("columns")
|
| 389 |
+
profiles = (
|
| 390 |
+
{k: p for k, p in columns.items() if isinstance(p, dict)}
|
| 391 |
+
if isinstance(columns, dict) else {}
|
| 392 |
+
)
|
| 393 |
+
ordered = sorted(
|
| 394 |
+
profiles.items(),
|
| 395 |
+
key=lambda kv: (
|
| 396 |
+
-(kv[1].get("null_rate") or 0),
|
| 397 |
+
(kv[1].get("distinct_count") or 0) <= 2,
|
| 398 |
+
-(kv[1].get("outlier_count") or 0),
|
| 399 |
+
),
|
| 400 |
+
)
|
| 401 |
+
return (
|
| 402 |
+
["column", "missing %", "distinct", "outliers", "min", "max"],
|
| 403 |
+
[
|
| 404 |
+
[k, _pct(p.get("null_rate")), p.get("distinct_count"), p.get("outlier_count"),
|
| 405 |
+
p.get("min"), p.get("max")]
|
| 406 |
+
for k, p in ordered
|
| 407 |
+
],
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
|
| 411 |
def _tool_table(rec: AnalysisRecord) -> tuple[list[str], list[list[Any]]] | None:
|
| 412 |
"""(columns, rows) of the LAST analysis-tool output with a table form, else None."""
|