Rifqi Hafizuddin commited on
Commit
b43ecc7
Β·
1 Parent(s): 9e248d9

[KM-691] Make sources trace-authoritative; drop stream sources

Browse files

Sources move fully to GET /api/v1/traceability. The SSE sources event
now always emits [] (kept for backward-compat); removed the dead
_build_sources helper and the structured-path source TODO. Updates the
contract + v2 chat docstring accordingly.

API_CONTRACT_BE_PYTHON.md CHANGED
@@ -51,7 +51,7 @@ Common event types:
51
 
52
  | Event | Data | Meaning |
53
  | --- | --- | --- |
54
- | `sources` | JSON array | Sources available early in the stream. May be empty. |
55
  | `status` | text | Optional progress update for slower paths. |
56
  | `chunk` | text | Answer text fragment. Concatenate chunks in order. |
57
  | `done` | JSON object | Terminal success event. Includes `message_id`. |
@@ -91,7 +91,7 @@ Example structured answer:
91
 
92
  ```text
93
  event: sources
94
- data: [{"document_id":"u_1a2b3c_orders","filename":"orders","page_label":null}]
95
 
96
  event: status
97
  data: Planning analysis...
@@ -127,7 +127,7 @@ Behavior notes:
127
  - Greeting and farewell messages may use a fast canned path.
128
  - Stateless `chat` intent may use a 1-hour Redis response cache.
129
  - The router may classify messages into intents such as `chat`, `help`, `check`, `unstructured_flow`, or `structured_flow`.
130
- - `sources` can be empty for chat/help/error paths.
131
  - `status` events are optional and should be safe for the frontend to ignore.
132
 
133
  ## Tools
 
51
 
52
  | Event | Data | Meaning |
53
  | --- | --- | --- |
54
+ | `sources` | JSON array | Always `[]` β€” sources moved to `GET /api/v1/traceability` (KM-691). Event kept for backward-compat; read `sources[]` from the traceability call. |
55
  | `status` | text | Optional progress update for slower paths. |
56
  | `chunk` | text | Answer text fragment. Concatenate chunks in order. |
57
  | `done` | JSON object | Terminal success event. Includes `message_id`. |
 
91
 
92
  ```text
93
  event: sources
94
+ data: []
95
 
96
  event: status
97
  data: Planning analysis...
 
127
  - Greeting and farewell messages may use a fast canned path.
128
  - Stateless `chat` intent may use a 1-hour Redis response cache.
129
  - The router may classify messages into intents such as `chat`, `help`, `check`, `unstructured_flow`, or `structured_flow`.
130
+ - `sources` in the stream is **always `[]`** (KM-691) β€” read the real `sources[]` from `GET /api/v1/traceability` after `done`.
131
  - `status` events are optional and should be safe for the frontend to ignore.
132
 
133
  ## Tools
src/agents/chat_handler.py CHANGED
@@ -547,14 +547,11 @@ class ChatHandler:
547
  # else: chat path β€” no context
548
 
549
  # ---- 2b. Emit sources ---------------------------------------
550
- sources = _build_sources(intent, user_id, query_result, raw_chunks)
551
- logger.info(
552
- "built sources",
553
- intent=intent,
554
- sources_count=len(sources),
555
- raw_chunks_count=len(raw_chunks) if raw_chunks else 0,
556
- )
557
- yield {"event": "sources", "data": json.dumps(sources)}
558
 
559
  # ---- 3. Stream answer ----------------------------------------
560
  # masked: the answer call sees real query rows / doc chunks (possible PII).
@@ -750,7 +747,9 @@ class ChatHandler:
750
  yield {"event": "error", "data": f"Analysis failed: {e}"}
751
  return
752
 
753
- yield {"event": "sources", "data": json.dumps([])} # TODO: derive from record
 
 
754
  yield {"event": "chunk", "data": result.chat_answer}
755
  try:
756
  # Stamp identity from the request scope: owner + the shared session id
@@ -798,54 +797,6 @@ class _ScopedCatalogReader:
798
  return catalog.model_copy(update={"sources": scoped or catalog.sources})
799
 
800
 
801
- def _build_sources(
802
- intent: str,
803
- user_id: str,
804
- query_result: Any,
805
- raw_chunks: Any,
806
- ) -> list[dict[str, Any]]:
807
- """Build the sources payload for the SSE `sources` event.
808
-
809
- - structured_flow: one entry per executed table (table_name only).
810
- - unstructured_flow: deduped by (document_id, page_label), Phase 1 shape.
811
- - chat or error: empty list.
812
- """
813
- if intent == "structured_flow":
814
- if query_result is None or getattr(query_result, "error", None):
815
- return []
816
- table_name = getattr(query_result, "table_name", "") or ""
817
- if not table_name:
818
- return []
819
- return [{
820
- "document_id": f"{user_id}_{table_name}",
821
- "filename": table_name,
822
- "page_label": None,
823
- }]
824
-
825
- if intent == "unstructured_flow" and raw_chunks:
826
- seen: set[tuple[Any, Any]] = set()
827
- sources: list[dict[str, Any]] = []
828
- for item in raw_chunks:
829
- if isinstance(item, RetrievalResult):
830
- data = item.metadata.get("data", {})
831
- elif isinstance(item, dict):
832
- data = item
833
- else:
834
- continue
835
- key = (data.get("document_id"), data.get("page_label"))
836
- if key in seen or key == (None, None):
837
- continue
838
- seen.add(key)
839
- sources.append({
840
- "document_id": data.get("document_id"),
841
- "filename": data.get("filename", "Unknown"),
842
- "page_label": data.get("page_label", "Unknown"),
843
- })
844
- return sources
845
-
846
- return []
847
-
848
-
849
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
850
  """Convert whatever the retriever returns into list[DocumentChunk].
851
 
 
547
  # else: chat path β€” no context
548
 
549
  # ---- 2b. Emit sources ---------------------------------------
550
+ # Sources moved to traceability (KM-691): the stream stays text-only. The FE
551
+ # reads sources from GET /api/v1/traceability (richer + intent-consistent; the
552
+ # document sources for this turn are captured on the pad above). The empty
553
+ # `sources` event is kept for SSE backward-compat.
554
+ yield {"event": "sources", "data": json.dumps([])}
 
 
 
555
 
556
  # ---- 3. Stream answer ----------------------------------------
557
  # masked: the answer call sees real query rows / doc chunks (possible PII).
 
747
  yield {"event": "error", "data": f"Analysis failed: {e}"}
748
  return
749
 
750
+ # Sources live in traceability now (KM-691), derived from the run's
751
+ # retrieve_data calls; the stream stays text-only.
752
+ yield {"event": "sources", "data": json.dumps([])}
753
  yield {"event": "chunk", "data": result.chat_answer}
754
  try:
755
  # Stamp identity from the request scope: owner + the shared session id
 
797
  return catalog.model_copy(update={"sources": scoped or catalog.sources})
798
 
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
801
  """Convert whatever the retriever returns into list[DocumentChunk].
802
 
src/api/v2/chat.py CHANGED
@@ -93,11 +93,11 @@ async def chat_stream(
93
  """Chat endpoint with streaming response (v2 β€” keyed on `analysis_id`).
94
 
95
  SSE event sequence:
96
- 1. sources β€” JSON array of source refs (table for structured; deduped
97
- document_id/page_label for unstructured; [] for chat/help/error)
98
  2. status β€” slow-path progress pings (optional)
99
  3. chunk β€” text fragments of the answer
100
- 4. done β€” {"message_id": "..."} for the observability lookup
101
  """
102
  analysis_id = body.analysis_id
103
  message_id = _mint_message_id()
 
93
  """Chat endpoint with streaming response (v2 β€” keyed on `analysis_id`).
94
 
95
  SSE event sequence:
96
+ 1. sources β€” always `[]` (KM-691): sources moved to GET /api/v1/traceability;
97
+ the stream stays text-only. Event kept for backward-compat.
98
  2. status β€” slow-path progress pings (optional)
99
  3. chunk β€” text fragments of the answer
100
+ 4. done β€” {"message_id": "..."} for the traceability lookup
101
  """
102
  analysis_id = body.analysis_id
103
  message_id = _mint_message_id()
src/traceability/scratchpad.py CHANGED
@@ -148,7 +148,8 @@ class TraceabilityScratchpad:
148
 
149
  def add_document_sources(self, raw_chunks: Any, query: str) -> None:
150
  """Dedupe retrieved chunks by (document_id, page_label) into document
151
- sources (mirrors `chat_handler._build_sources`), stamped with the query."""
 
152
  for item in raw_chunks or []:
153
  if hasattr(item, "metadata"):
154
  data = item.metadata.get("data", {})
 
148
 
149
  def add_document_sources(self, raw_chunks: Any, query: str) -> None:
150
  """Dedupe retrieved chunks by (document_id, page_label) into document
151
+ sources, stamped with the query. Sole source of document provenance now
152
+ that the stream no longer emits sources (KM-691)."""
153
  for item in raw_chunks or []:
154
  if hasattr(item, "metadata"):
155
  data = item.metadata.get("data", {})