File size: 6,260 Bytes
f873f92 32abc41 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 | """Traceability payload schemas (KM-691).
User-facing provenance for one assistant turn: what the AI planned, which tools it
called (with real inputs + outputs), and which data sources it read (with the
executed query). One `TraceabilityPayload` is built per assistant `message_id`,
stored as a JSONB row, and served by `GET /api/v1/traceability`.
Distinct from Langfuse *observability* (`src/observability/langfuse/`): that is
engineering-only, PII-masked (arg keys + row counts). Traceability shows the user
their own data's provenance β real args, output previews, executed SQL β so it is
NOT masked. Truncation caps (in `scratchpad.py`) bound the payload size instead.
`thinking` is always `null` in v1 (our agents are plain chat completions with no
native reasoning output; synthesizing it post-hoc would be unfaithful). The field
stays in the payload so adding it later is contract-compatible.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class PlanStep(BaseModel):
"""One CRISP-DM step the Planner ran (derived from an AnalysisRecord task)."""
step: int
stage: str
objective: str
status: str
tools_used: list[str] = Field(default_factory=list)
class PlanningInfo(BaseModel):
"""Planner output for a slow-path turn; `null` on every other turn type."""
goal_restated: str
assumptions: list[str] = Field(default_factory=list)
steps: list[PlanStep] = Field(default_factory=list)
class ToolCallInfo(BaseModel):
"""One tool invocation with its real input + output (both truncation-capped).
The field is spelled `input` in the wire contract (the FE reads it), which
shadows the `input` builtin β hence the alias + `populate_by_name` so callers
can pass `input=` on construction and `model_dump(by_alias=True)` emits `input`.
`summary` is a plain-English one-liner built from a fixed per-tool template
(never an LLM call) β the FE headline for this step; `input`/`output` stay raw
for the collapsible "technical details" layer.
"""
model_config = ConfigDict(populate_by_name=True)
order: int
task_id: str | None = None
name: str
summary: str | None = None
input_: dict[str, Any] = Field(default_factory=dict, alias="input")
output: dict[str, Any] = Field(default_factory=dict)
status: Literal["success", "error"] = "success"
error: str | None = None
# ---------------------------------------------------------------------------
# `data_used` β the user-facing "what data did this analysis touch" layer.
# All names are resolved from the catalog at build time (deterministic lookup,
# no LLM). Every `id` field is MACHINE-ONLY β the FE must never render it; it
# exists for click-through/linking, reconciliation, and audit. One DataUsed
# entry per `retrieve_data` call.
# ---------------------------------------------------------------------------
class SourceRef(BaseModel):
"""The data source a pull read from. `id` is machine-only (FE must not render)."""
id: str
name: str
type: str | None = None
class TableRef(BaseModel):
"""A table the query touched. `id` is machine-only. `role`: base | joined."""
id: str
name: str
role: str
class JoinRef(BaseModel):
"""A join, rendered in real names, e.g. condition='order_items.order_id = orders.id'."""
type: str
condition: str
class ColumnRef(BaseModel):
"""A real catalog column the query read. `id` is machine-only (FE must not render).
`roles` records why it was used: selected | aggregated | filtered | grouped |
joined | ordered. `table` is the real table name for qualification.
"""
id: str
name: str
table: str
data_type: str | None = None
pii: bool = False
roles: list[str] = Field(default_factory=list)
class OutputColumn(BaseModel):
"""A column in the result set.
`kind='column'` β read straight from the data (has a real `from`).
`kind='computed'` β calculated; carries a `formula`, and has NO catalog id
because it is not a stored column (e.g. total_revenue = SUM(line_total)).
`from` is spelled with an alias (Python keyword) β populate_by_name lets
callers pass `from_=`.
"""
model_config = ConfigDict(populate_by_name=True)
name: str
kind: Literal["column", "computed"]
from_: str | None = Field(default=None, alias="from")
formula: str | None = None
class FilterRef(BaseModel):
"""A filter, resolved to a real column plus a plain-language `description`."""
column: str
op: str
value: Any = None
description: str
class OrderByRef(BaseModel):
"""A sort. `target` is a real column name (kind='column') OR a computed
output alias (kind='computed' β the IR stores the alias in `column_id`)."""
target: str
kind: Literal["column", "computed"]
dir: str = "asc"
class DataUsed(BaseModel):
"""One `retrieve_data` pull, fully resolved for the user. Names for display,
ids for machine linkage only (FE must not render any `id`)."""
source: SourceRef
tables: list[TableRef] = Field(default_factory=list)
joins: list[JoinRef] = Field(default_factory=list)
columns_read: list[ColumnRef] = Field(default_factory=list)
output_columns: list[OutputColumn] = Field(default_factory=list)
filters: list[FilterRef] = Field(default_factory=list)
group_by: list[str] = Field(default_factory=list)
order_by: list[OrderByRef] = Field(default_factory=list)
limit: int | None = None
rows_returned: int | None = None
query: str | None = None
class TraceabilityPayload(BaseModel):
"""The full provenance record for one assistant `message_id`."""
model_config = ConfigDict(populate_by_name=True)
analysis_id: str
message_id: str
user_id: str # ownership column on the row; the FE may ignore it
intent: str
generated_at: datetime
planning: PlanningInfo | None = None
thinking: str | None = None
tool_calls: list[ToolCallInfo] = Field(default_factory=list)
data_used: list[DataUsed] = Field(default_factory=list)
sources: list[dict[str, Any]] = Field(default_factory=list)
|