Rifqi Hafizuddin commited on
Commit
277f7e2
·
1 Parent(s): 8b4794c

add source to stream output

Browse files
src/agents/chat_handler.py CHANGED
@@ -22,6 +22,7 @@ inject mocks).
22
 
23
  from __future__ import annotations
24
 
 
25
  from collections.abc import AsyncIterator
26
  from typing import TYPE_CHECKING, Any
27
 
@@ -47,6 +48,8 @@ class ChatHandler:
47
  Returns an `AsyncIterator[dict]` of SSE-style events with shape
48
  `{"event": <name>, "data": <str>}`. Event types:
49
  - `intent` — emitted once after classification (JSON-encoded decision)
 
 
50
  - `chunk` — text fragment of the streaming answer (one per token)
51
  - `done` — end of stream (data is empty string)
52
  - `error` — failure; data is a user-facing message
@@ -125,6 +128,7 @@ class ChatHandler:
125
  rewritten = decision.rewritten_query or message
126
  query_result = None
127
  chunks: list[DocumentChunk] | None = None
 
128
 
129
  # ---- 2. Route ------------------------------------------------
130
  if decision.source_hint == "structured":
@@ -162,6 +166,12 @@ class ChatHandler:
162
  return
163
  # else: chat path — no context
164
 
 
 
 
 
 
 
165
  # ---- 3. Stream answer ----------------------------------------
166
  try:
167
  async for token in self._get_answer_agent().astream(
@@ -179,6 +189,54 @@ class ChatHandler:
179
  yield {"event": "done", "data": ""}
180
 
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
183
  """Convert whatever the retriever returns into list[DocumentChunk].
184
 
 
22
 
23
  from __future__ import annotations
24
 
25
+ import json
26
  from collections.abc import AsyncIterator
27
  from typing import TYPE_CHECKING, Any
28
 
 
48
  Returns an `AsyncIterator[dict]` of SSE-style events with shape
49
  `{"event": <name>, "data": <str>}`. Event types:
50
  - `intent` — emitted once after classification (JSON-encoded decision)
51
+ - `sources` — JSON array of source refs (one per structured table, or
52
+ per (document_id, page_label) for unstructured)
53
  - `chunk` — text fragment of the streaming answer (one per token)
54
  - `done` — end of stream (data is empty string)
55
  - `error` — failure; data is a user-facing message
 
128
  rewritten = decision.rewritten_query or message
129
  query_result = None
130
  chunks: list[DocumentChunk] | None = None
131
+ raw_chunks: Any = None
132
 
133
  # ---- 2. Route ------------------------------------------------
134
  if decision.source_hint == "structured":
 
166
  return
167
  # else: chat path — no context
168
 
169
+ # ---- 2b. Emit sources ---------------------------------------
170
+ sources = _build_sources(
171
+ decision.source_hint, user_id, query_result, raw_chunks
172
+ )
173
+ yield {"event": "sources", "data": json.dumps(sources)}
174
+
175
  # ---- 3. Stream answer ----------------------------------------
176
  try:
177
  async for token in self._get_answer_agent().astream(
 
189
  yield {"event": "done", "data": ""}
190
 
191
 
192
+ def _build_sources(
193
+ source_hint: str,
194
+ user_id: str,
195
+ query_result: Any,
196
+ raw_chunks: Any,
197
+ ) -> list[dict[str, Any]]:
198
+ """Build the sources payload for the SSE `sources` event.
199
+
200
+ - structured: one entry per executed table (table_name only).
201
+ - unstructured: deduped by (document_id, page_label), Phase 1 shape.
202
+ - chat or error: empty list.
203
+ """
204
+ if source_hint == "structured":
205
+ if query_result is None or getattr(query_result, "error", None):
206
+ return []
207
+ table_name = getattr(query_result, "table_name", "") or ""
208
+ if not table_name:
209
+ return []
210
+ return [{
211
+ "document_id": f"{user_id}_{table_name}",
212
+ "filename": table_name,
213
+ "page_label": None,
214
+ }]
215
+
216
+ if source_hint == "unstructured" and raw_chunks:
217
+ seen: set[tuple[Any, Any]] = set()
218
+ sources: list[dict[str, Any]] = []
219
+ for item in raw_chunks:
220
+ if isinstance(item, RetrievalResult):
221
+ data = item.metadata.get("data", {})
222
+ elif isinstance(item, dict):
223
+ data = item
224
+ else:
225
+ continue
226
+ key = (data.get("document_id"), data.get("page_label"))
227
+ if key in seen or key == (None, None):
228
+ continue
229
+ seen.add(key)
230
+ sources.append({
231
+ "document_id": data.get("document_id"),
232
+ "filename": data.get("filename", "Unknown"),
233
+ "page_label": data.get("page_label", "Unknown"),
234
+ })
235
+ return sources
236
+
237
+ return []
238
+
239
+
240
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
241
  """Convert whatever the retriever returns into list[DocumentChunk].
242
 
src/api/v1/chat.py CHANGED
@@ -97,7 +97,8 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
97
  """Chat endpoint with streaming response.
98
 
99
  SSE event sequence:
100
- 1. sources — JSON array of source references (empty until retrieval is wired into ChatHandler)
 
101
  2. chunk — text fragments of the answer
102
  3. done — signals end of stream
103
  """
@@ -136,14 +137,20 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
136
 
137
  async def stream_response():
138
  full_response = ""
139
- yield {"event": "sources", "data": json.dumps([])}
140
  async for event in handler.handle(request.message, request.user_id, history):
141
- if event["event"] == "chunk":
 
 
 
 
 
 
142
  full_response += event["data"]
143
  yield event
144
  elif event["event"] == "done":
145
  await cache_response(redis, cache_key, full_response)
146
- await save_messages(db, request.room_id, request.message, full_response, sources=[])
147
  yield event
148
  elif event["event"] == "error":
149
  yield event
 
97
  """Chat endpoint with streaming response.
98
 
99
  SSE event sequence:
100
+ 1. sources — JSON array of source refs from ChatHandler (table for
101
+ structured; deduped document_id/page_label for unstructured)
102
  2. chunk — text fragments of the answer
103
  3. done — signals end of stream
104
  """
 
137
 
138
  async def stream_response():
139
  full_response = ""
140
+ sources: List[Dict[str, Any]] = []
141
  async for event in handler.handle(request.message, request.user_id, history):
142
+ if event["event"] == "sources":
143
+ try:
144
+ sources = json.loads(event["data"]) or []
145
+ except (TypeError, ValueError):
146
+ sources = []
147
+ yield event
148
+ elif event["event"] == "chunk":
149
  full_response += event["data"]
150
  yield event
151
  elif event["event"] == "done":
152
  await cache_response(redis, cache_key, full_response)
153
+ await save_messages(db, request.room_id, request.message, full_response, sources=sources)
154
  yield event
155
  elif event["event"] == "error":
156
  yield event
src/query/executor/base.py CHANGED
@@ -17,6 +17,8 @@ class QueryResult:
17
  truncated: bool = False
18
  elapsed_ms: int = 0
19
  error: str | None = None
 
 
20
 
21
 
22
  class BaseExecutor(ABC):
 
17
  truncated: bool = False
18
  elapsed_ms: int = 0
19
  error: str | None = None
20
+ table_id: str = ""
21
+ table_name: str = ""
22
 
23
 
24
  class BaseExecutor(ABC):
src/query/executor/db.py CHANGED
@@ -59,8 +59,12 @@ class DbExecutor(BaseExecutor):
59
 
60
  async def run(self, ir: QueryIR) -> QueryResult:
61
  started = time.perf_counter()
 
62
  try:
63
  source = self._find_source(ir.source_id)
 
 
 
64
  if source.source_type != "schema":
65
  raise ValueError(
66
  f"DbExecutor cannot run on source_type={source.source_type!r}; "
@@ -102,6 +106,8 @@ class DbExecutor(BaseExecutor):
102
  row_count=len(capped),
103
  truncated=truncated,
104
  elapsed_ms=elapsed_ms,
 
 
105
  )
106
 
107
  except Exception as e:
@@ -117,6 +123,8 @@ class DbExecutor(BaseExecutor):
117
  backend="sql",
118
  elapsed_ms=elapsed_ms,
119
  error=str(e),
 
 
120
  )
121
 
122
  # ------------------------------------------------------------------
 
59
 
60
  async def run(self, ir: QueryIR) -> QueryResult:
61
  started = time.perf_counter()
62
+ table_name = ""
63
  try:
64
  source = self._find_source(ir.source_id)
65
+ table_name = next(
66
+ (t.name for t in source.tables if t.table_id == ir.table_id), ""
67
+ )
68
  if source.source_type != "schema":
69
  raise ValueError(
70
  f"DbExecutor cannot run on source_type={source.source_type!r}; "
 
106
  row_count=len(capped),
107
  truncated=truncated,
108
  elapsed_ms=elapsed_ms,
109
+ table_id=ir.table_id,
110
+ table_name=table_name,
111
  )
112
 
113
  except Exception as e:
 
123
  backend="sql",
124
  elapsed_ms=elapsed_ms,
125
  error=str(e),
126
+ table_id=ir.table_id,
127
+ table_name=table_name,
128
  )
129
 
130
  # ------------------------------------------------------------------
src/query/executor/tabular.py CHANGED
@@ -55,8 +55,10 @@ class TabularExecutor(BaseExecutor):
55
 
56
  async def run(self, ir: QueryIR) -> QueryResult:
57
  started = time.perf_counter()
 
58
  try:
59
  source, table = self._lookup(ir)
 
60
  if source.source_type != "tabular":
61
  raise ValueError(
62
  f"TabularExecutor cannot run on source_type={source.source_type!r}; "
@@ -91,6 +93,8 @@ class TabularExecutor(BaseExecutor):
91
  row_count=len(rows),
92
  truncated=truncated,
93
  elapsed_ms=elapsed_ms,
 
 
94
  )
95
 
96
  except Exception as e:
@@ -106,6 +110,8 @@ class TabularExecutor(BaseExecutor):
106
  backend="tabular",
107
  elapsed_ms=elapsed_ms,
108
  error=str(e),
 
 
109
  )
110
 
111
  # ------------------------------------------------------------------
 
55
 
56
  async def run(self, ir: QueryIR) -> QueryResult:
57
  started = time.perf_counter()
58
+ table_name = ""
59
  try:
60
  source, table = self._lookup(ir)
61
+ table_name = table.name
62
  if source.source_type != "tabular":
63
  raise ValueError(
64
  f"TabularExecutor cannot run on source_type={source.source_type!r}; "
 
93
  row_count=len(rows),
94
  truncated=truncated,
95
  elapsed_ms=elapsed_ms,
96
+ table_id=ir.table_id,
97
+ table_name=table_name,
98
  )
99
 
100
  except Exception as e:
 
110
  backend="tabular",
111
  elapsed_ms=elapsed_ms,
112
  error=str(e),
113
+ table_id=ir.table_id,
114
+ table_name=table_name,
115
  )
116
 
117
  # ------------------------------------------------------------------