sofhiaazzhr Claude Opus 5.5 commited on
Commit
0e64f6b
·
1 Parent(s): a79d8da

[NOTICKET] feat(report): evidence tables for cluster, anomaly, descriptive, data-quality and forecast

Browse files
Files changed (1) hide show
  1. src/agents/report/anchored.py +84 -0
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."""