File size: 14,991 Bytes
f873f92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a60e93
 
 
 
 
 
 
 
f873f92
 
 
 
 
 
5a60e93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f873f92
 
 
 
 
 
 
 
 
32abc41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a60e93
 
 
 
 
32abc41
 
 
f873f92
 
 
 
 
 
 
 
 
 
 
32abc41
 
f873f92
 
 
 
32abc41
 
 
 
 
 
f873f92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32abc41
f873f92
 
 
 
 
 
49b0848
f873f92
32abc41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f873f92
 
 
 
 
49b0848
 
 
 
f873f92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32abc41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f873f92
 
 
 
32abc41
f873f92
 
 
 
 
 
 
 
 
32abc41
f873f92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
"""Per-request traceability accumulator + tool-invoker wrapper (KM-691).

`TraceabilityScratchpad` is a mutable, per-request blackboard that `ChatHandler`
fills while answering a turn, then `build()`s into a `TraceabilityPayload` right
before the `done` SSE event. `TraceabilityToolInvoker` wraps the real tool invoker
so every tool call on the slow path / check branch records its full I/O into the
scratchpad (mirrors `TracingToolInvoker` in `src/observability/langfuse/tracing.py`,
whose name is taken — this one records real I/O, not masked metadata).

Everything here is best-effort: recording must never break the user's answer.
"""

from __future__ import annotations

from typing import Any

from pydantic import BaseModel

from src.middlewares.logging import get_logger

from .schemas import PlanningInfo, PlanStep, ToolCallInfo, TraceabilityPayload

logger = get_logger("traceability")

# Truncation caps (bound the JSONB payload) — see plan §3.
CAP_PREVIEW_ROWS = 5
CAP_STR = 300
# Executed queries get a higher cap than free strings: the SQL/query is the point of
# the feature, and 300 chars mangles all but the smallest statements.
CAP_QUERY = 2000


def _truncate(obj: Any) -> Any:
    """Recursively cap strings to CAP_STR and summarize embedded tool results.

    A Pattern-A `analyze_*` input carries its upstream `retrieve_data` result as a
    `ToolOutput` (a BaseModel). Left untouched that would re-embed the FULL upstream
    table (all rows + the untruncated query) into the payload, defeating the caps —
    so a tool-result-shaped model is summarized via `_output_to_dict` (preview ≤ 5
    rows, `row_count` kept), and any other BaseModel is dumped then recursed.
    """
    if isinstance(obj, str):
        return obj[:CAP_STR]
    if isinstance(obj, BaseModel):
        if hasattr(obj, "kind") and hasattr(obj, "rows"):  # a ToolOutput-shaped result
            return _output_to_dict(obj)
        return _truncate(obj.model_dump(mode="json"))
    if isinstance(obj, dict):
        return {k: _truncate(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_truncate(v) for v in obj]
    return obj


def _output_to_dict(output: Any) -> dict[str, Any]:
    """Normalize a tool result (`ToolOutput` or a synth dict) to the wire shape:
    kind/columns/row_count/preview/value/error, all truncation-capped."""
    if isinstance(output, dict):
        return _truncate(output)

    kind = getattr(output, "kind", None)
    result: dict[str, Any] = {"kind": kind}
    rows = getattr(output, "rows", None)
    if rows is not None:
        result["row_count"] = len(rows)
        columns = getattr(output, "columns", None)
        if columns is not None:
            result["columns"] = list(columns)
        result["preview"] = [
            [_truncate(cell) for cell in row] for row in rows[:CAP_PREVIEW_ROWS]
        ]
    value = getattr(output, "value", None)
    if value is not None:
        if kind == "chart" and isinstance(value, dict):
            # The full dataeyond.chart.v1 envelope is persisted in message_charts
            # (SPINE_V2_PLAN §4.4); traceability keeps a compact summary — the
            # spec's x/y arrays are numeric, so _truncate's string caps would let
            # them through whole.
            result.update(_chart_summary(value))
        else:
            result["value"] = _truncate(value)
    error = getattr(output, "error", None)
    if error is not None:
        result["error"] = _truncate(error)
    return result


def _chart_summary(envelope: dict[str, Any]) -> dict[str, Any]:
    """Compact wire shape for a chart-kind output: chart_type/title/trace_count/
    point_count (charts have no rows, so `row_count` is absent — §4.7)."""
    plotly = envelope.get("plotly")
    traces = plotly.get("data") if isinstance(plotly, dict) else None
    traces = traces if isinstance(traces, list) else []
    points = 0
    for trace in traces:
        if isinstance(trace, dict):
            axis = trace.get("x") if trace.get("x") is not None else trace.get("labels")
            if isinstance(axis, list):
                points += len(axis)
    return {
        "chart_type": envelope.get("chart_type"),
        "title": envelope.get("title"),
        "trace_count": len(traces),
        "point_count": points,
    }


def _meta_of(output: Any) -> dict[str, Any]:
    """Best-effort read of a tool result's `meta` dict (ToolOutput or plain dict)."""
    if isinstance(output, dict):
        meta = output.get("meta")
    else:
        meta = getattr(output, "meta", None)
    return meta if isinstance(meta, dict) else {}


def _summarize(name: str, out_dict: dict[str, Any], meta: dict[str, Any]) -> str:
    """One plain-English line per tool step (fixed templates — never an LLM call)."""
    rc = out_dict.get("row_count")
    cols = out_dict.get("columns")
    ncol = len(cols) if isinstance(cols, list) else None
    if name == "check_data":
        return "Inspected your data source structure"
    if name == "retrieve_data":
        parts = "Retrieved"
        if rc is not None:
            parts += f" {rc} rows"
        if ncol:
            parts += f" across {ncol} columns"
        table = meta.get("table_name")
        if table:
            parts += f" from {table}"
        return parts
    if name == "retrieve_knowledge":
        return f"Searched your documents ({rc if rc is not None else 0} passages found)"
    if name.startswith("analyze_"):
        pretty = name.removeprefix("analyze_").replace("_", " ")
        return f"Ran {pretty} analysis on {ncol} columns" if ncol else f"Ran {pretty} analysis"
    if name == "render_chart":
        ctype = out_dict.get("chart_type") or "chart"
        points = out_dict.get("point_count")
        summary = f"Built a {ctype} chart"
        return f"{summary} ({points} data points)" if points else summary
    return name.replace("_", " ")


class TraceabilityScratchpad:
    """Mutable per-request accumulator; `build()` freezes it into a payload."""

    def __init__(self) -> None:
        self.message_id: str | None = None  # set at handler entry; None => no flush
        self.intent: str = "chat"  # default until the router classifies
        self._planning: PlanningInfo | None = None
        self._tool_calls: list[ToolCallInfo] = []
        self._db_sources: list[dict[str, Any]] = []
        self._doc_sources: list[dict[str, Any]] = []
        self._doc_seen: set[tuple[Any, Any]] = set()
        self._catalog: Any = None  # set by set_catalog: enables id->name resolution
        self._retrieve_calls: list[dict[str, Any]] = []  # raw retrieve_data IRs + meta

    def set_intent(self, intent: str) -> None:
        self.intent = intent

    def set_catalog(self, catalog: Any) -> None:
        """Provide the catalog used this turn so `build()` can resolve the IR ids in
        each retrieve_data call to real names (the `data_used` layer). No-op-safe:
        without it, `data_used` stays empty and the raw tool_calls still carry the IR."""
        self._catalog = catalog

    def record_tool_call(
        self,
        name: str,
        args: dict[str, Any],
        output: Any,
        task_id: str | None = None,
    ) -> None:
        """Append one tool call (input + normalized output). For `retrieve_data`,
        also derive a database source from the args + executed query in `meta`."""
        out_dict = _output_to_dict(output)
        status = "error" if out_dict.get("kind") == "error" else "success"
        self._tool_calls.append(
            ToolCallInfo(
                order=len(self._tool_calls) + 1,
                task_id=task_id,
                name=name,
                summary=_summarize(name, out_dict, _meta_of(output)),
                input=_truncate(dict(args)),
                output=out_dict,
                status=status,
                error=out_dict.get("error"),
            )
        )
        if name == "retrieve_data" and status == "success":
            self._record_db_source(output)
            self._capture_retrieve(args, output)

    def _capture_retrieve(self, args: Any, output: Any) -> None:
        """Stash the raw retrieve_data IR + provenance meta so `build()` can resolve a
        `DataUsed`. Gated on the SAME `meta.source_id` check as `_record_db_source`, so
        the two stay index-aligned (the db source was just appended)."""
        meta = _meta_of(output)
        if not meta.get("source_id"):
            return
        ir = args.get("ir") if isinstance(args, dict) else None
        if not isinstance(ir, dict):
            return
        query = meta.get("query")
        self._retrieve_calls.append({
            "ir": ir,
            "query": query[:CAP_QUERY] if isinstance(query, str) else None,
            "source_name": meta.get("source_name"),
            "row_count": meta.get("row_count"),
            "db_source_index": len(self._db_sources) - 1,
        })

    def _record_db_source(self, output: Any) -> None:
        # retrieve_data's args are {"ir": ...}; the reliable source_id/table/query
        # live on the tool OUTPUT meta (see tools/data_access.py::_retrieve_data).
        meta = _meta_of(output)
        if not meta.get("source_id"):
            # A failed/aborted retrieval carries no provenance meta — emitting it
            # anyway produced all-null source rows in the payload.
            return
        query = meta.get("query")
        table = meta.get("table_name") or meta.get("table_id")
        self._db_sources.append({
            "type": "database",
            "source_id": meta.get("source_id"),
            "name": table,
            "query": query[:CAP_QUERY] if isinstance(query, str) else None,
            "detail": {"table": table, "row_count": meta.get("row_count")},
        })

    def set_planning_from_record(self, record: Any) -> None:
        """Map an `AnalysisRecord` (goal_restated + tasks_run) to `PlanningInfo`."""
        try:
            steps = [
                PlanStep(
                    step=i + 1,
                    stage=str(getattr(task, "stage", "")),
                    objective=getattr(task, "objective", ""),
                    status=str(getattr(task, "status", "")),
                    tools_used=list(getattr(task, "tools_used", []) or []),
                )
                for i, task in enumerate(getattr(record, "tasks_run", []) or [])
            ]
            self._planning = PlanningInfo(
                goal_restated=getattr(record, "goal_restated", "") or "",
                assumptions=[],  # AnalysisRecord carries no assumptions field (honest: empty)
                steps=steps,
            )
        except Exception as exc:  # never break the answer on a mapping slip
            logger.warning("traceability planning mapping failed", error=str(exc))

    def add_document_sources(self, raw_chunks: Any, query: str) -> None:
        """Dedupe retrieved chunks by (document_id, page_label) into document
        sources, stamped with the query. Sole source of document provenance now
        that the stream no longer emits sources (KM-691)."""
        for item in raw_chunks or []:
            if hasattr(item, "metadata"):
                data = item.metadata.get("data", {})
            elif isinstance(item, dict):
                data = item
            else:
                continue
            key = (data.get("document_id"), data.get("page_label"))
            if key == (None, None) or key in self._doc_seen:
                continue
            self._doc_seen.add(key)
            source: dict[str, Any] = {
                "type": "document",
                "document_id": data.get("document_id"),
                "filename": data.get("filename", "Unknown"),
                "page_label": data.get("page_label"),
                "query": _truncate(query),
            }
            snippet = data.get("snippet") or data.get("content") or data.get("text")
            if isinstance(snippet, str):
                source["snippet"] = snippet[:CAP_STR]
            score = data.get("score")
            if score is not None:
                source["score"] = score
            self._doc_sources.append(source)

    def _build_data_used(self) -> list[Any]:
        """Resolve each captured retrieve_data IR into a `DataUsed` (real names) and
        enrich the matching db source with source_name + all tables touched. Never
        raises — a resolution slip drops that entry, never breaks the answer."""
        if self._catalog is None or not self._retrieve_calls:
            return []
        from ..query.ir.models import QueryIR
        from .resolve import resolve_data_used

        out: list[Any] = []
        for rc in self._retrieve_calls:
            try:
                ir = QueryIR.model_validate(rc["ir"])
                du = resolve_data_used(ir, self._catalog, rc.get("query"), rc.get("row_count"))
                out.append(du)
                idx = rc.get("db_source_index")
                if isinstance(idx, int) and 0 <= idx < len(self._db_sources):
                    self._db_sources[idx]["source_name"] = rc.get("source_name")
                    self._db_sources[idx]["tables"] = [t.name for t in du.tables]
            except Exception as exc:  # never break the answer on a resolve slip
                logger.warning("data_used resolve failed", error=str(exc))
        return out

    def build(self, analysis_id: str, user_id: str, message_id: str) -> TraceabilityPayload:
        """Freeze the accumulated state into a `TraceabilityPayload`."""
        from datetime import UTC, datetime

        data_used = self._build_data_used()  # also enriches self._db_sources in place
        return TraceabilityPayload(
            analysis_id=analysis_id,
            message_id=message_id,
            user_id=user_id,
            intent=self.intent,
            generated_at=datetime.now(UTC),
            planning=self._planning,
            thinking=None,
            tool_calls=list(self._tool_calls),
            data_used=data_used,
            sources=self._doc_sources + self._db_sources,
        )


class TraceabilityToolInvoker:
    """Wraps a ToolInvoker to record each call's full I/O into a scratchpad.

    Implements the ToolInvoker protocol (`async invoke(tool_name, args)`). Recording
    is never-throw so a trace slip can't break the tool run. Distinct from
    `TracingToolInvoker` (Langfuse, masked-metadata-only) — that name is taken.
    """

    def __init__(self, inner: Any, pad: TraceabilityScratchpad) -> None:
        self._inner = inner
        self._pad = pad

    async def invoke(self, tool_name: str, args: dict[str, Any]) -> Any:
        out = await self._inner.invoke(tool_name, args)
        try:
            self._pad.record_tool_call(tool_name, args, out)
        except Exception as exc:  # never break the tool run
            logger.warning("traceability tool record failed", tool=tool_name, error=str(exc))
        return out