File size: 10,528 Bytes
5a60e93 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | """Quality checkpoint (S1a) β deterministic run inspection (SPINE_V2_PLAN Β§3, W1).
Sits between `TaskRunner.run` and `Assembler.assemble` in the coordinator. 0 LLM,
never-throw: `assess` inspects the completed `RunState` against the plan and turns
today's *silent* degrade-and-continue into an *explained* one β the Assembler gets
told what specifically failed/degraded, and every flag is structlog'd as a
`repair_candidate` so the S1b (targeted repair, gated) decision has an evidence
base. INV-6 untouched: nothing here re-plans or calls a model.
v1 checks:
CK1 all tasks failed -> overall "failed" (coordinator answers honestly, no LLM)
CK2 empty retrieve consumed -> flag the retrieve + its transitive dependents
CK3 table truncated at the cap -> flag (answer must scope itself to the first 10k rows)
CK4 trend collapsed to 1 bucket -> flag (the pr/13 1970-bucket class)
CK5 all-null column consumed -> flag the consuming analyze_* task
CK6 chart-spec sanity (Β§4.6) -> flag empty/mismatched/overcrowded/non-numeric charts
"""
from __future__ import annotations
from collections import defaultdict, deque
from typing import Any
from src.middlewares.logging import get_logger
from ..planner.schemas import PLACEHOLDER_RE, TaskList
from .schemas import RepairCandidate, RunAssessment, RunState, TaskAssessment
logger = get_logger("slow_path_checkpoint")
_TABLE_ROW_CAP = 10_000 # the query pipeline's LIMIT defense cap (CK3)
_BAR_CATEGORY_CAP = 30 # Β§4.6 β a bar chart beyond this is unreadable
_PIE_CATEGORY_CAP = 8 # Β§4.6 β a pie chart beyond this is unreadable
def assess(run_state: RunState, task_list: TaskList) -> RunAssessment:
"""Inspect a completed run. NEVER raises β on an internal slip the checkpoint
degrades to today's behavior (an all-ok pass-through), never blocks the answer."""
try:
return _assess(run_state, task_list)
except Exception as exc: # noqa: BLE001 β never-throw seam (house pattern)
logger.error("checkpoint failed", error=repr(exc))
return RunAssessment(overall="ok")
def _assess(run_state: RunState, task_list: TaskList) -> RunAssessment:
results = run_state.results
notes: dict[str, list[str]] = {tid: [] for tid in results}
candidates: list[RepairCandidate] = []
def flag(task_id: str, check: str, reason: str, *, repairable: bool) -> None:
if task_id not in notes:
return
notes[task_id].append(reason)
# Telemetry for the S1b decision (SPINE_V2_PLAN Β§3): every flag is logged,
# repairable or not β the hit-rate decides whether a repair pass pays.
logger.info("repair_candidate", task_id=task_id, check=check, reason=reason)
if repairable:
candidates.append(RepairCandidate(task_id=task_id, reason=reason))
# CK1 β everything failed: no partial result worth prose. The coordinator
# returns a deterministic honest-failure answer (0 LLM), mirroring the
# infeasible path; the record stays non-substantive.
if results and all(r.status == "failure" for r in results.values()):
logger.info(
"repair_candidate",
task_id="*",
check="CK1",
reason="all tasks failed",
)
return RunAssessment(
overall="failed",
tasks=[
TaskAssessment(
task_id=tid, verdict="failed", notes=[r.error] if r.error else []
)
for tid, r in results.items()
],
)
dependents = _transitive_dependents(task_list)
for tid, result in results.items():
for out in result.outputs:
# CK2 β an empty retrieval, with downstream consumers flagged too.
if out.tool == "retrieve_data" and out.kind == "table" and not (out.rows or []):
flag(tid, "CK2", "retrieve_data returned 0 rows (the filters "
"matched nothing)", repairable=True)
for dep in sorted(dependents.get(tid, ())):
flag(dep, "CK2", f"consumed the empty result of {tid}",
repairable=False)
# CK3 β the retrieval cap was hit; findings only cover the cap window.
elif out.kind == "table" and out.rows is not None and len(out.rows) >= _TABLE_ROW_CAP:
flag(tid, "CK3", f"table hit the {_TABLE_ROW_CAP:,}-row retrieval "
"cap β findings describe only the first "
f"{_TABLE_ROW_CAP:,} rows", repairable=False)
# CK4 β a single trend bucket (e.g. every date parsed into one period).
if out.tool == "analyze_trend" and out.kind == "series" and isinstance(out.value, dict):
points = out.value.get("points")
if isinstance(points, list) and len(points) == 1:
freq = out.value.get("freq", "period")
flag(tid, "CK4", f"trend collapsed into a single {freq} bucket "
"(1 point) β no movement can be described",
repairable=True)
# CK6 β chart-spec sanity (Β§4.6; lands with W2's render_chart).
if out.kind == "chart" and isinstance(out.value, dict):
for issue in _chart_issues(out.value):
flag(tid, "CK6", issue, repairable=True)
# CK5 β an analyze_* consumed a table whose column(s) are entirely null.
# Consumption is read from the PLAN (the `data`/`data_right` placeholders);
# the runner resolves the same references at execution time.
for task in task_list.tasks:
for call in task.tool_calls:
if not call.tool.startswith("analyze_"):
continue
for arg_name in ("data", "data_right"):
ref = _placeholder_ref(call.args.get(arg_name))
table = _last_table_output(results.get(ref)) if ref else None
if table is None:
continue
null_cols = _all_null_columns(table)
if null_cols:
flag(task.id, "CK5",
f"input column(s) {null_cols} from {ref} are entirely "
"null β results based on them are meaningless",
repairable=True)
assessments: list[TaskAssessment] = []
for tid, result in results.items():
if result.status == "failure":
verdict = "failed"
elif notes[tid] or result.status == "partial":
verdict = "degraded"
else:
verdict = "ok"
assessments.append(TaskAssessment(task_id=tid, verdict=verdict, notes=notes[tid]))
overall = "degraded" if any(a.verdict != "ok" for a in assessments) else "ok"
return RunAssessment(overall=overall, tasks=assessments, repair_candidates=candidates)
def _transitive_dependents(task_list: TaskList) -> dict[str, set[str]]:
"""dependents[id] = every task downstream of id via depends_on edges."""
direct: dict[str, set[str]] = defaultdict(set)
for task in task_list.tasks:
for dep in task.depends_on:
direct[dep].add(task.id)
out: dict[str, set[str]] = {}
for tid in list(direct):
seen: set[str] = set()
queue = deque(direct[tid])
while queue:
node = queue.popleft()
if node in seen:
continue
seen.add(node)
queue.extend(direct.get(node, ()))
out[tid] = seen
return out
def _placeholder_ref(value: Any) -> str | None:
if not isinstance(value, str):
return None
match = PLACEHOLDER_RE.fullmatch(value.strip())
return match.group(1) if match else None
def _last_table_output(result: Any) -> Any | None:
"""The referenced task's representative output (matches TaskRunner's
`outputs[-1]` resolution), if it is a non-empty table."""
if result is None or not result.outputs:
return None
out = result.outputs[-1]
if out.kind != "table" or not out.columns or not out.rows:
return None
return out
def _all_null_columns(table: Any) -> list[str]:
cols: list[str] = []
for i, name in enumerate(table.columns):
if all(row[i] is None for row in table.rows if i < len(row)):
cols.append(name)
return cols
def _chart_issues(envelope: dict[str, Any]) -> list[str]:
"""Β§4.6 spec checks on a dataeyond.chart.v1 envelope. Purely structural β
the spec builder is deterministic, so an issue here means the *plan args*
picked a bad shape (wrong column, oversized category set), not a tool bug."""
issues: list[str] = []
chart_type = envelope.get("chart_type", "chart")
plotly = envelope.get("plotly")
traces = plotly.get("data") if isinstance(plotly, dict) else None
if not isinstance(traces, list) or not traces:
return [f"{chart_type} chart has no data traces"]
for trace in traces:
if not isinstance(trace, dict):
continue
name = trace.get("name")
label = f"series {name!r}" if name else "a series"
xs = trace.get("x") if trace.get("x") is not None else trace.get("labels")
ys = trace.get("y") if trace.get("y") is not None else trace.get("values")
xs = xs if isinstance(xs, list) else []
ys = ys if isinstance(ys, list) else []
if not xs or not ys or all(v is None for v in ys):
issues.append(f"{chart_type} chart: {label} is empty")
continue
if len(xs) != len(ys):
issues.append(
f"{chart_type} chart: {label} has mismatched lengths "
f"(x={len(xs)}, y={len(ys)})"
)
if chart_type == "bar" and len(xs) > _BAR_CATEGORY_CAP:
issues.append(
f"bar chart: {label} has {len(xs)} categories "
f"(> {_BAR_CATEGORY_CAP}) β unreadable; aggregate or top-N first"
)
if chart_type == "pie" and len(xs) > _PIE_CATEGORY_CAP:
issues.append(
f"pie chart has {len(xs)} slices (> {_PIE_CATEGORY_CAP}) β "
"unreadable; group the tail into 'other' or use a bar chart"
)
if chart_type in ("bar", "line", "scatter") and any(
v is not None and not isinstance(v, int | float) for v in ys
):
issues.append(f"{chart_type} chart: {label} has non-numeric y values")
return issues
|