|
13 | 13 | import json |
14 | 14 | import sys |
15 | 15 | import time |
| 16 | +from pathlib import Path |
16 | 17 |
|
17 | 18 | import httpx |
18 | 19 |
|
@@ -149,76 +150,138 @@ def render_error(event: dict) -> None: |
149 | 150 | } |
150 | 151 |
|
151 | 152 |
|
| 153 | +class JsonlTraceWriter: |
| 154 | + """Write agentic search stream events as replayable JSONL records.""" |
| 155 | + |
| 156 | + def __init__(self, path: str, request: dict) -> None: |
| 157 | + self.path = Path(path) |
| 158 | + self.path.parent.mkdir(parents=True, exist_ok=True) |
| 159 | + self.file = self.path.open("w", encoding="utf-8") |
| 160 | + self.started_at = time.time() |
| 161 | + self.sequence = 0 |
| 162 | + self.write( |
| 163 | + { |
| 164 | + "type": "metadata", |
| 165 | + "schema_version": 1, |
| 166 | + "request": request, |
| 167 | + "started_at": self.started_at, |
| 168 | + } |
| 169 | + ) |
| 170 | + |
| 171 | + def write(self, payload: dict) -> None: |
| 172 | + self.file.write(json.dumps(payload, ensure_ascii=False) + "\n") |
| 173 | + self.file.flush() |
| 174 | + |
| 175 | + def write_event(self, event: dict) -> None: |
| 176 | + self.sequence += 1 |
| 177 | + self.write( |
| 178 | + { |
| 179 | + "type": "event", |
| 180 | + "sequence": self.sequence, |
| 181 | + "elapsed_ms": round((time.time() - self.started_at) * 1000), |
| 182 | + "event": event, |
| 183 | + } |
| 184 | + ) |
| 185 | + |
| 186 | + def close(self, elapsed_s: float) -> None: |
| 187 | + self.write( |
| 188 | + { |
| 189 | + "type": "summary", |
| 190 | + "event_count": self.sequence, |
| 191 | + "elapsed_s": round(elapsed_s, 3), |
| 192 | + } |
| 193 | + ) |
| 194 | + self.file.close() |
| 195 | + |
| 196 | + |
152 | 197 | def main(): |
153 | 198 | parser = argparse.ArgumentParser(description="AgenticSearch streaming search viewer") |
154 | 199 | parser.add_argument("collection_id", help="Collection readable ID") |
155 | 200 | parser.add_argument("query", help="Search query") |
156 | 201 | parser.add_argument("--host", default="http://localhost:8001", help="API host") |
157 | 202 | parser.add_argument("--filter", default=None, help="Filter JSON string") |
158 | 203 | parser.add_argument("--mode", default="agentic", choices=["agentic", "direct"]) |
| 204 | + parser.add_argument( |
| 205 | + "--jsonl", |
| 206 | + default=None, |
| 207 | + help="Write raw stream events to a JSONL trace for replay/eval analysis", |
| 208 | + ) |
159 | 209 | args = parser.parse_args() |
160 | 210 |
|
161 | 211 | url = f"{args.host}/collections/{args.collection_id}/agentic-search/stream" |
162 | 212 | body = {"query": args.query, "mode": args.mode} |
163 | 213 | if args.filter: |
164 | 214 | body["filter"] = json.loads(args.filter) |
165 | 215 |
|
| 216 | + trace_writer = JsonlTraceWriter(args.jsonl, body) if args.jsonl else None |
| 217 | + |
166 | 218 | print(f"{'─' * 60}") |
167 | 219 | print(f" {bold('Agentic Search')}") |
168 | 220 | print(f" {dim('Collection:')} {args.collection_id}") |
169 | 221 | print(f" {dim('Query:')} {args.query}") |
170 | 222 | if args.filter: |
171 | 223 | print(f" {dim('Filter:')} {args.filter}") |
172 | 224 | print(f" {dim('Mode:')} {args.mode}") |
| 225 | + if trace_writer: |
| 226 | + print(f" {dim('JSONL trace:')} {trace_writer.path}") |
173 | 227 | print(f"{'─' * 60}") |
174 | 228 |
|
175 | 229 | start = time.monotonic() |
176 | 230 |
|
177 | | - with httpx.stream( |
178 | | - "POST", |
179 | | - url, |
180 | | - json=body, |
181 | | - headers={"Content-Type": "application/json"}, |
182 | | - timeout=120.0, |
183 | | - ) as response: |
184 | | - if response.status_code != 200: |
185 | | - print(red(f"\nHTTP {response.status_code}")) |
186 | | - print(response.read().decode()) |
187 | | - sys.exit(1) |
188 | | - |
189 | | - buffer = "" |
190 | | - for chunk in response.iter_text(): |
191 | | - buffer += chunk |
192 | | - while "\n\n" in buffer: |
193 | | - message, buffer = buffer.split("\n\n", 1) |
194 | | - message = message.strip() |
195 | | - if not message: |
196 | | - continue |
197 | | - |
198 | | - # Parse SSE data line |
199 | | - if message.startswith("data: "): |
200 | | - data_str = message[6:] |
201 | | - elif message.startswith("data:"): |
202 | | - data_str = message[5:] |
203 | | - else: |
204 | | - continue |
205 | | - |
206 | | - try: |
207 | | - event = json.loads(data_str) |
208 | | - except json.JSONDecodeError: |
209 | | - print(dim(f" [raw] {data_str}")) |
210 | | - continue |
211 | | - |
212 | | - event_type = event.get("type", "unknown") |
213 | | - renderer = RENDERERS.get(event_type) |
214 | | - if renderer: |
215 | | - renderer(event) |
216 | | - else: |
217 | | - print(dim(f" [unknown event] {json.dumps(event, indent=2)}")) |
218 | | - |
219 | | - elapsed = time.monotonic() - start |
| 231 | + try: |
| 232 | + with httpx.stream( |
| 233 | + "POST", |
| 234 | + url, |
| 235 | + json=body, |
| 236 | + headers={"Content-Type": "application/json"}, |
| 237 | + timeout=120.0, |
| 238 | + ) as response: |
| 239 | + if response.status_code != 200: |
| 240 | + print(red(f"\nHTTP {response.status_code}")) |
| 241 | + print(response.read().decode()) |
| 242 | + sys.exit(1) |
| 243 | + |
| 244 | + buffer = "" |
| 245 | + for chunk in response.iter_text(): |
| 246 | + buffer += chunk |
| 247 | + while "\n\n" in buffer: |
| 248 | + message, buffer = buffer.split("\n\n", 1) |
| 249 | + message = message.strip() |
| 250 | + if not message: |
| 251 | + continue |
| 252 | + |
| 253 | + # Parse SSE data line |
| 254 | + if message.startswith("data: "): |
| 255 | + data_str = message[6:] |
| 256 | + elif message.startswith("data:"): |
| 257 | + data_str = message[5:] |
| 258 | + else: |
| 259 | + continue |
| 260 | + |
| 261 | + try: |
| 262 | + event = json.loads(data_str) |
| 263 | + except json.JSONDecodeError: |
| 264 | + print(dim(f" [raw] {data_str}")) |
| 265 | + continue |
| 266 | + |
| 267 | + if trace_writer: |
| 268 | + trace_writer.write_event(event) |
| 269 | + |
| 270 | + event_type = event.get("type", "unknown") |
| 271 | + renderer = RENDERERS.get(event_type) |
| 272 | + if renderer: |
| 273 | + renderer(event) |
| 274 | + else: |
| 275 | + print(dim(f" [unknown event] {json.dumps(event, indent=2)}")) |
| 276 | + finally: |
| 277 | + elapsed = time.monotonic() - start |
| 278 | + if trace_writer: |
| 279 | + trace_writer.close(elapsed) |
| 280 | + |
220 | 281 | print(f"\n{'─' * 60}") |
221 | 282 | print(f" {dim(f'Total time: {elapsed:.1f}s')}") |
| 283 | + if trace_writer: |
| 284 | + print(f" {dim(f'JSONL trace written to {trace_writer.path}')}") |
222 | 285 | print(f"{'─' * 60}\n") |
223 | 286 |
|
224 | 287 |
|
|
0 commit comments