Coverage for src / quber / playground / app.py: 43%

602 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-09-23 22:14 -0400

1"""FastAPI backend for the answering playground. 

2 

3Serves the single-page UI and these endpoints: 

4 GET /api/documents -> list ingested documents: filing metadata, 

5 derived label, overlap and review flags 

6 GET /api/document/{id}/pdf -> the source PDF (for PDF.js) 

7 PATCH /api/documents/{id} -> edit metadata (title, folder, filing 

8 fields); organization only, never identity 

9 DELETE /api/documents/{id} -> remove the document outright (hard 

10 delete; 409 while its ingest or a batch 

11 run against it is still going) 

12 POST /api/chat -> retrieve -> Opus answer -> grounded refs 

13 POST /api/upload -> raw PDF body; runs the quber workflow 

14 (fuse -> figure -> ingest) as a job 

15 GET /api/jobs/{job_id} -> job stage (uploaded/waiting/extracting/ 

16 reading figures/ingesting/done/failed) 

17 plus a tail of its log 

18 POST /api/batch -> start a run of many questions -> job id 

19 GET /api/batch/{id} -> the run's state and rows finished so far 

20 GET /api/batch/{id}/events -> SSE, one event per question completing 

21 GET /api/batch/{id}/export.xlsx and .csv -> the run as a workbook / text 

22 

23Document identity is the integer id plus the content hash. The storage key 

24(`doc_key`, the hash's first 16 hex chars) names the staged files and is what 

25ingest subprocesses take; it never reaches the UI. Labels are derived from the 

26filing metadata at read time — see `quber.playground.metadata`. 

27 

28Uploads take the PDF as the raw request body (no multipart dependency). The 

29bytes are hashed before anything runs: the same bytes as an existing document 

30are rejected outright (409, kind "duplicate"), and a filing-tuple match is 

31refused (409, kind "tuple") unless the request carries the replace intent — 

32then the swap happens when the new ingest completes. An s3:// source cannot 

33be hashed before its job stages it, so those checks run inside the job and a 

34failure carries the same structured `error_info`. Every upload is admitted; 

35a semaphore bounds how many run their pipelines at once 

36(`settings.playground.upload_concurrency`), and a job holding no slot yet 

37reports the `waiting` stage rather than appearing stalled on its first step. 

38 

39Run: 

40 uv run quber playground 

41""" 

42 

43from __future__ import annotations 

44 

45import hashlib 

46import json 

47import re 

48import shutil 

49import subprocess 

50import threading 

51import uuid 

52from pathlib import Path 

53from typing import Any, Dict, List, Literal, LiteralString, Optional 

54 

55from fastapi import FastAPI, HTTPException, Request 

56from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse 

57from loguru import logger 

58from pydantic import BaseModel 

59 

60from quber.files.cache import fetch_file 

61from quber.playground import auth, db, embedding, export, gpu, jobs, metadata, storage 

62from quber.playground.answers.document import DocumentIdentity 

63from quber.playground.answers.expectation import answer as agent_answer 

64from quber.playground.answers.expectation import answer_stream as agent_answer_stream 

65from quber.playground.batch import BatchRunner, RunState, parse_questions 

66from quber.playground.retrieval import RetrievedChunk, retrieve 

67from quber.playground.tracing import document_key 

68from quber.settings import get_settings 

69 

70STATIC = Path(__file__).with_name("static") 

71DATA_DIR = get_settings().playground.data_dir 

72UPLOADS = DATA_DIR / "uploads" 

73REPO_ROOT = Path(__file__).parents[3] 

74 

75app = FastAPI(title="Quber Playground") 

76 

77# On when the login settings are present, which is the hosted playground; 

78# off on the developer host. A partial configuration is refused here. 

79LOGIN_ENABLED = auth.install(app, get_settings().playground) 

80 

81# Set once the embedding model has loaded. The health route reports the app 

82# ready only after this, so a request handed off by the load balancer never 

83# lands on a task that would stall its first question on the model load. 

84_EMBEDDING_READY = threading.Event() 

85 

86 

87@app.on_event("startup") 

88def warm_embedding_model() -> None: 

89 """Load the embedding model in the background at startup. It otherwise 

90 loads lazily on the first question, costing that question ~5 seconds.""" 

91 

92 def warm() -> None: 

93 embedding.embed_query("warm up") 

94 _EMBEDDING_READY.set() 

95 

96 threading.Thread(target=warm, daemon=True).start() 

97 

98 

99@app.on_event("startup") 

100def open_job_records() -> None: 

101 """The job tables exist before anything writes to them, and this process 

102 starts beating for the jobs it runs.""" 

103 jobs.ensure_tables() 

104 jobs.start_heartbeat( 

105 upload_ids=lambda: [j["job_id"] for j in JOBS.values() if j["stage"] not in jobs.TERMINAL_STAGES], 

106 run_ids=BATCH.running_ids, 

107 store=BATCH_STORE, 

108 ) 

109 

110 

111@app.get("/healthz", include_in_schema=False) 

112def healthz() -> Response: 

113 """Readiness, not liveness: 200 only once the database answers and the 

114 embedding model is loaded, 503 before. The load balancer's target group 

115 and the scale-to-zero startup page both poll this, so a 200 means the 

116 app can take a question, not merely that the process is up.""" 

117 if not _EMBEDDING_READY.is_set(): 

118 return Response( 

119 '{"status":"loading embedding model"}', status_code=503, media_type="application/json" 

120 ) 

121 try: 

122 with db.connect() as conn: 

123 conn.execute("SELECT 1").fetchone() 

124 except Exception as exc: 

125 return Response( 

126 json.dumps({"status": f"database unreachable: {type(exc).__name__}"}), 

127 status_code=503, 

128 media_type="application/json", 

129 ) 

130 return Response('{"status":"ok"}', media_type="application/json") 

131 

132 

133class ChatRequest(BaseModel): 

134 doc_id: int 

135 question: str 

136 k: int = 10 

137 # What the asker expects back: "auto" lets the model choose the shape, 

138 # "value" forces a figure, "text" forces an explanation. 

139 want: Literal["auto", "value", "text"] = "auto" 

140 

141 

142class Reference(BaseModel): 

143 ref_id: str 

144 ref_type: Optional[str] 

145 page: int # 1-based, to match the viewer 

146 bbox: Optional[dict] 

147 label: str 

148 # Cell-level provenance, the GroundedCell attributes established at 

149 # reconciliation (fusion documents only; None on ADE references): 

150 status: Optional[str] = None # provenance code, e.g. 'reconciled' 

151 note: Optional[str] = None # status inspector's evidence line, if any 

152 text: Optional[str] = None # the served cell value 

153 row: Optional[int] = None # 0-based position in the corrected table 

154 col: Optional[int] = None 

155 flagged: bool = False # True when the status is registered for review 

156 # Figure-value provenance, from the grounding's position (None elsewhere): 

157 chart: Optional[str] = None # the chart the value is printed in 

158 segment: Optional[str] = None # what the value is labeled as on the chart 

159 # The status in the reader's words, from the registry's label for the 

160 # code. The transcript prints this, never the code. A misread carries 

161 # the figure the page prints so the line can name both numbers. 

162 reason: Optional[str] = None 

163 printed: Optional[str] = None 

164 

165 

166class RetrievedInfo(BaseModel): 

167 chunk_id: str 

168 chunk_type: str 

169 page: int 

170 score: float 

171 

172 

173class ChatResponse(BaseModel): 

174 answer: str 

175 references: List[Reference] 

176 retrieved: List[RetrievedInfo] 

177 # The answer as a value rather than as text. `shape` names which payload 

178 # came back — scalar, series, grid, prose, unanswerable — and `value` is 

179 # that payload. `answer` stays populated with something readable so an 

180 # existing consumer of this endpoint keeps working. 

181 shape: str = "prose" 

182 value: Optional[dict] = None 

183 

184 

185# Statuses the provenance registry marks for review; cited cells carrying one 

186# are served like any other but highlighted so the user knows they are flagged. 

187def _inspect_codes() -> frozenset[str]: 

188 from quber.core.extractors.base import CELL_STATUS_REFERENCE 

189 

190 return frozenset(s.code for s in CELL_STATUS_REFERENCE if s.inspect) 

191 

192 

193def _status_reasons() -> Dict[str, str]: 

194 from quber.core.extractors.base import CELL_STATUS_REFERENCE 

195 

196 return {s.code: s.label for s in CELL_STATUS_REFERENCE} 

197 

198 

199INSPECT_CODES = _inspect_codes() 

200STATUS_REASONS = _status_reasons() 

201 

202# The misread note is written by the figure-value reader in one of two 

203# shapes, "the page prints '6%' where this value should appear" or "fragment 

204# at the value's position prints '~ 87%'"; the figure inside the quotes is 

205# what the transcript's line needs. 

206_PRINTED_IN_NOTE = re.compile(r"prints '([^']*)'") 

207 

208 

209def _reason( 

210 status: Optional[str], note: Optional[str], served: Optional[str] 

211) -> tuple[Optional[str], Optional[str]]: 

212 """The plain-language line for a status, and the printed figure a misread names.""" 

213 if not status: 

214 return None, None 

215 printed = None 

216 if status == "value_misread" and note: 

217 m = _PRINTED_IN_NOTE.search(note) 

218 if m: 

219 printed = m.group(1) 

220 if printed and served: 

221 return f"The page prints {printed} here, not {served}. Check which is right.", printed 

222 return STATUS_REASONS.get(status, status), printed 

223 

224 

225def _label(ref_type: Optional[str], page1: int, status: Optional[str] = None) -> str: 

226 """What the source is, in the reader's words. The status never rides 

227 the label: it has its own field, and its plain-language line is `reason`.""" 

228 if ref_type == "tableCell": 

229 return f"Page {page1}, table cell" 

230 if ref_type in ("table", "chunkTable"): 

231 return f"Page {page1}, table" 

232 if ref_type == "figureValue": 

233 return f"Page {page1}, chart value" 

234 if ref_type == "line_item": 

235 return f"Page {page1}, table row" 

236 if ref_type == "picture": 

237 return f"Page {page1}, figure" 

238 return f"Page {page1}, text" 

239 

240 

241def _stamped_index() -> str: 

242 """The index shell with content-hashed asset links. 

243 

244 A browser that cached a previous deploy of app.js kept serving it on a 

245 plain reload, because the asset URLs never change. Stamping each mutable 

246 asset with a hash of its current bytes makes any change a new URL; the 

247 pinned vendor files stay as they are. Hashing runs per request — the 

248 files are small and the reload server would otherwise need cache 

249 invalidation of its own. 

250 """ 

251 html = (STATIC / "index.html").read_text() 

252 for name in ("styles.css", "app.css", "app.js"): 

253 digest = hashlib.sha256((STATIC / name).read_bytes()).hexdigest()[:8] 

254 html = html.replace(f"/static/{name}", f"/static/{name}?v={digest}") 

255 return html 

256 

257 

258@app.get("/", response_class=HTMLResponse) 

259def index() -> HTMLResponse: 

260 # no-cache makes the shell revalidate on every load, so the stamped links 

261 # inside it are always current; the assets themselves may cache freely. 

262 return HTMLResponse(_stamped_index(), headers={"Cache-Control": "no-cache"}) 

263 

264 

265@app.get("/static/{name:path}") 

266def static_asset(name: str) -> FileResponse: 

267 # Nested paths are served (the design-system token stylesheets live in 

268 # static/tokens/), but the resolved file must still sit inside static/ so 

269 # that "../" segments cannot walk out of it. 

270 path = STATIC / name 

271 if not path.is_file() or not path.resolve().is_relative_to(STATIC.resolve()): 

272 raise HTTPException(404, f"no asset {name}") 

273 return FileResponse(path) 

274 

275 

276_DOC_COLUMNS: LiteralString = ( 

277 "d.id, d.doc_key, d.filename, d.page_count, d.ade_version, d.title, d.folder, " 

278 "d.filing_type, d.year, d.period, d.version" 

279) 

280 

281 

282# The processing track (documents.track) is deliberately NOT served here or 

283# anywhere the browser can reach: naming vendor model internals in a client 

284# payload makes them scrapeable. It is written at upload finalize and read in 

285# the database only. 

286def _doc_dict(r: "tuple[Any, ...]") -> Dict[str, Any]: 

287 doc = { 

288 "id": r[0], 

289 "doc_key": r[1], 

290 "filename": r[2], 

291 "page_count": r[3], 

292 "format": "fusion" if r[4] == "quber-fusion" else "ade", 

293 "title": r[5], 

294 "folder": r[6], 

295 "filing_type": r[7], 

296 "year": r[8], 

297 "period": r[9], 

298 "version": r[10], 

299 } 

300 doc["label"] = metadata.filing_label(r[7], r[8], r[9], r[10]) 

301 return doc 

302 

303 

304def _doc_row(doc_id: int) -> Dict[str, Any]: 

305 with db.connect() as conn: 

306 r = conn.execute( 

307 "SELECT " + _DOC_COLUMNS + " FROM ade_playground.documents d WHERE d.id = %s", 

308 (doc_id,), 

309 ).fetchone() 

310 if r is None: 

311 raise HTTPException(404, f"no document {doc_id}") 

312 return _doc_dict(r) 

313 

314 

315@app.get("/api/documents") 

316def documents() -> list[dict]: 

317 # The flag count rides the list because the alternative is one 

318 # /api/document/{id}/flags call per document, each returning every flag 

319 # object with its bounding box in order to take a length. 

320 with db.connect() as conn: 

321 rows = conn.execute( 

322 "SELECT " 

323 + _DOC_COLUMNS 

324 + """, COUNT(g.id) FILTER (WHERE g.status = ANY(%s)) AS flags 

325 FROM ade_playground.documents d 

326 LEFT JOIN ade_playground.groundings g ON g.document_id = d.id 

327 GROUP BY d.id ORDER BY lower(coalesce(d.title, d.filename))""", 

328 (list(INSPECT_CODES),), 

329 ).fetchall() 

330 docs = [] 

331 for r in rows: 

332 doc = _doc_dict(r) 

333 # The storage key stays server-side; nothing in the UI needs it. 

334 del doc["doc_key"] 

335 doc["flags"] = r[11] 

336 docs.append(doc) 

337 metadata.annotate_overlaps(docs) 

338 return docs 

339 

340 

341class DocumentPatch(BaseModel): 

342 """The editable metadata: presentation (title, folder) and the filing 

343 fields. Edits change organization and labels only — never identity, and 

344 never blocked by collisions; two documents landing on one tuple simply 

345 both carry the possible-duplicate flag. Fields arrive as strings so an 

346 emptied input clears its column; year and version parse to ints.""" 

347 

348 title: Optional[str] = None 

349 folder: Optional[str] = None 

350 filing_type: Optional[str] = None 

351 year: Optional[str] = None 

352 period: Optional[str] = None 

353 version: Optional[str] = None 

354 

355 

356def _parse_int(name: str, raw: str, lo: int, hi: int) -> int: 

357 try: 

358 value = int(raw) 

359 except ValueError: 

360 raise HTTPException(400, f"{name} must be a number") from None 

361 if not lo <= value <= hi: 

362 raise HTTPException(400, f"{name} must be between {lo} and {hi}") 

363 return value 

364 

365 

366@app.patch("/api/documents/{doc_id}") 

367def patch_document(doc_id: int, patch: DocumentPatch) -> dict: 

368 """Update a document's metadata. The ingested artifacts are untouched; 

369 an emptied field clears its column (folder falls back under the 

370 ungrouped heading, filing fields drop out of the label).""" 

371 if patch.title is not None and not patch.title.strip(): 

372 raise HTTPException(400, "title cannot be empty") 

373 sets: List[LiteralString] = [] 

374 params: List[Any] = [] 

375 text_fields: List[tuple[LiteralString, Optional[str]]] = [ 

376 ("title", patch.title), 

377 ("folder", patch.folder), 

378 ("filing_type", patch.filing_type), 

379 ("period", patch.period), 

380 ] 

381 for column, raw in text_fields: 

382 if raw is not None: 

383 sets.append(column + " = %s") 

384 params.append(raw.strip() or None) 

385 if patch.year is not None: 

386 sets.append("year = %s") 

387 params.append(_parse_int("year", patch.year, 1900, 2100) if patch.year.strip() else None) 

388 if patch.version is not None: 

389 sets.append("version = %s") 

390 params.append(_parse_int("version", patch.version, 1, 999) if patch.version.strip() else None) 

391 if not sets: 

392 raise HTTPException(400, "nothing to update") 

393 query: LiteralString = ( 

394 "UPDATE ade_playground.documents SET " # noqa: S608 - fragments are literals 

395 + ", ".join(sets) 

396 + " WHERE id = %s RETURNING id" 

397 ) 

398 with db.connect() as conn: 

399 row = conn.execute(query, (*params, doc_id)).fetchone() 

400 if row is None: 

401 raise HTTPException(404, f"no document {doc_id}") 

402 return _doc_row(doc_id) 

403 

404 

405def _ingest_busy(doc_id: int, doc_key: str) -> Optional[str]: 

406 """Why this document cannot be pulled out from under running work, or 

407 None. An ingest can touch it two ways: a replace targeting it, or an 

408 s3-sourced job that will resolve to the same bytes and key.""" 

409 for job in jobs.live_uploads(): 

410 if job.get("replace_id") == doc_id: 

411 return "a replacement for this document is still ingesting; wait for it to finish" 

412 if job.get("doc_key") == doc_key: 

413 return "an ingest of this document is still running; wait for it to finish" 

414 if BATCH.running_for(doc_id): 

415 return "a batch run against this document is still going; wait for it to finish" 

416 return None 

417 

418 

419def _doc_files(doc_key: str) -> List[Path]: 

420 """Every staged file a document owns: the served PDF, the cached parse 

421 and figure JSONs beside it, the uploaded original, and the pipeline 

422 workdir.""" 

423 suffixes = ( 

424 ".pdf", 

425 ".ade.json", 

426 ".unified.json", 

427 ".tables.json", 

428 ".fusion.json", 

429 ".scanned-tables.json", 

430 ".figures.json", 

431 ".figure-values.json", 

432 ".charts.json", 

433 ) 

434 paths = [DATA_DIR / f"{doc_key}{s}" for s in suffixes] 

435 paths.append(UPLOADS / f"{doc_key}.pdf") 

436 return paths 

437 

438 

439def _remove_doc_files(doc_key: str) -> int: 

440 removed = 0 

441 for path in _doc_files(doc_key): 

442 if path.exists(): 

443 path.unlink() 

444 removed += 1 

445 artifacts = UPLOADS / f"{doc_key}-artifacts" 

446 if artifacts.exists(): 

447 shutil.rmtree(artifacts) 

448 removed += 1 

449 return removed 

450 

451 

452@app.delete("/api/documents/{doc_id}") 

453def delete_document(doc_id: int) -> dict: 

454 """Remove a document outright: the row (chunks, groundings, vectors and 

455 flags cascade with it), the staged files, and the upload artifacts. 

456 

457 A hard delete, documented as such: there is no tombstone, and the 

458 cascade plus the file removal leave nothing for a re-ingest to 

459 resurrect. Refused with 409 while an ingest touching the document or a 

460 batch run against it is still going — the server owns no cancellation, 

461 so the work finishes or fails before the ground moves. 

462 """ 

463 doc = _doc_row(doc_id) 

464 busy = _ingest_busy(doc_id, doc["doc_key"]) 

465 if busy: 

466 raise HTTPException(409, busy) 

467 with db.connect() as conn: 

468 counts = conn.execute( 

469 """SELECT COUNT(DISTINCT c.id), COUNT(DISTINCT g.id) 

470 FROM ade_playground.documents d 

471 LEFT JOIN ade_playground.chunks c ON c.document_id = d.id 

472 LEFT JOIN ade_playground.groundings g ON g.document_id = d.id 

473 WHERE d.id = %s""", 

474 (doc_id,), 

475 ).fetchone() 

476 conn.execute("DELETE FROM ade_playground.documents WHERE id = %s", (doc_id,)) 

477 assert counts is not None 

478 removed_files = _remove_doc_files(doc["doc_key"]) 

479 logger.info( 

480 "document removed: {id} '{title}' ({chunks} chunks, {groundings} groundings, {files} files)", 

481 id=doc_id, 

482 title=doc["title"] or doc["filename"], 

483 chunks=counts[0], 

484 groundings=counts[1], 

485 files=removed_files, 

486 ) 

487 return {"removed": doc_id, "chunks": counts[0], "groundings": counts[1], "files": removed_files} 

488 

489 

490@app.get("/api/document/{doc_id}/pdf") 

491def pdf(doc_id: int) -> FileResponse: 

492 doc = _doc_row(doc_id) 

493 path = storage.local_pdf(doc["doc_key"]) 

494 if path is None: 

495 raise HTTPException(404, f"No PDF staged for document {doc_id}") 

496 return FileResponse(path, media_type="application/pdf") 

497 

498 

499def _bbox_dict(raw: Any) -> dict: 

500 """A groundings bbox as a dict, whether the driver returned it parsed.""" 

501 return raw if isinstance(raw, dict) else json.loads(raw) 

502 

503 

504def _box_contains(outer: dict, inner: dict, eps: float = 0.005) -> bool: 

505 """Whether `outer` wholly contains `inner`, with a hairline tolerance.""" 

506 return ( 

507 outer["left"] <= inner["left"] + eps 

508 and outer["top"] <= inner["top"] + eps 

509 and outer["right"] >= inner["right"] - eps 

510 and outer["bottom"] >= inner["bottom"] - eps 

511 ) 

512 

513 

514def _resolve_refs(doc_id: int, cited_ids: List[str]) -> List[Reference]: 

515 """Map cited chunk/cell ids to page + bbox overlays via the groundings table.""" 

516 if not cited_ids: 

517 return [] 

518 with db.connect() as conn: 

519 rows = conn.execute( 

520 """SELECT g.ref_id, g.ref_type, g.page, g.bbox, g.status, g.note, 

521 g.cell_text, g.position 

522 FROM ade_playground.groundings g 

523 WHERE g.document_id = %s AND g.ref_id = ANY(%s)""", 

524 (doc_id, cited_ids), 

525 ).fetchall() 

526 # Preserve the model's citation order. 

527 by_id = {r[0]: r for r in rows} 

528 # A cited figure value with its own box makes citing its containing 

529 # picture redundant: the specific highlight is the answer's evidence, 

530 # the whole-picture box only buries it. 

531 covered_pictures = set() 

532 for r in by_id.values(): 

533 if r[1] != "figureValue" or r[3] is None: 

534 continue 

535 position = r[7] if isinstance(r[7], dict) else (json.loads(r[7]) if r[7] else {}) 

536 picture_ref = (position or {}).get("picture_ref") 

537 if picture_ref: 

538 covered_pictures.add(picture_ref) 

539 # A cited table cell with its own box makes citing its containing line 

540 # record redundant on the same terms: the cell is the answer's evidence, 

541 # the full-row band only buries it. The enclosing record's id is derived 

542 # from the ingest's naming — cell `t<i>-<row>-<col>` sits in line record 

543 # `t<i>-line-<row>`. 

544 covered_lines = set() 

545 for r in by_id.values(): 

546 if r[1] != "tableCell" or r[3] is None: 

547 continue 

548 position = r[7] if isinstance(r[7], dict) else (json.loads(r[7]) if r[7] else {}) 

549 row = (position or {}).get("row") 

550 if row is not None: 

551 covered_lines.add(f"{r[0].split('-', 1)[0]}-line-{row}") 

552 # The finest citation wins the overlay outright. A cited figure value or 

553 # table cell is the answer's evidence; any other cited region whose box 

554 # wholly contains it on the same page — the stat panel's group, a 

555 # wrapping paragraph — would only bury the tight highlight under a 

556 # bigger rectangle, so it is not drawn as a reference. 

557 fine_types = {"figureValue", "tableCell"} 

558 fine_boxes = [ 

559 ((r[2] or 0), _bbox_dict(r[3])) for r in by_id.values() if r[1] in fine_types and r[3] is not None 

560 ] 

561 

562 def buries_fine(r: tuple) -> bool: 

563 if r[1] in fine_types or r[3] is None: 

564 return False 

565 outer = _bbox_dict(r[3]) 

566 return any(page == (r[2] or 0) and _box_contains(outer, fine) for page, fine in fine_boxes) 

567 

568 refs: List[Reference] = [] 

569 for cid in cited_ids: 

570 r = by_id.get(cid) 

571 if not r: 

572 continue 

573 if r[1] == "picture" and r[0] in covered_pictures: 

574 continue 

575 if r[1] == "line_item" and r[0] in covered_lines: 

576 continue 

577 if buries_fine(r): 

578 continue 

579 page1 = (r[2] or 0) + 1 

580 bbox = r[3] if isinstance(r[3], dict) else (json.loads(r[3]) if r[3] else None) 

581 status, note, cell_text = r[4], r[5], r[6] 

582 position = r[7] if isinstance(r[7], dict) else (json.loads(r[7]) if r[7] else None) 

583 reason, printed = _reason(status, note, cell_text) 

584 refs.append( 

585 Reference( 

586 ref_id=r[0], 

587 ref_type=r[1], 

588 page=page1, 

589 bbox=bbox, 

590 label=_label(r[1], page1, status), 

591 status=status, 

592 note=note, 

593 text=cell_text, 

594 row=(position or {}).get("row"), 

595 col=(position or {}).get("col"), 

596 flagged=bool(status and status in INSPECT_CODES), 

597 chart=(position or {}).get("chart"), 

598 segment=(position or {}).get("label"), 

599 reason=reason, 

600 printed=printed, 

601 ) 

602 ) 

603 return refs 

604 

605 

606class Flag(BaseModel): 

607 """One item of the document's review queue: a cell whose provenance 

608 status is registered for inspection, or a table-level dropped-text flag.""" 

609 

610 ref_id: str 

611 ref_type: Optional[str] # tableCell | tableFlag 

612 page: int # 1-based 

613 bbox: Optional[dict] 

614 status: str 

615 note: Optional[str] = None 

616 text: str = "" # the flagged cell value, or the dropped fragment 

617 reason: Optional[str] = None # the status in the reader's words 

618 

619 

620@app.get("/api/document/{doc_id}/flags", response_model=List[Flag]) 

621def document_flags(doc_id: int) -> List[Flag]: 

622 """Every flagged item for one document, in page order.""" 

623 with db.connect() as conn: 

624 rows = conn.execute( 

625 """SELECT g.ref_id, g.ref_type, g.page, g.bbox, g.status, g.note, g.cell_text 

626 FROM ade_playground.groundings g 

627 WHERE g.document_id = %s AND g.status = ANY(%s) 

628 ORDER BY g.page, g.ref_id""", 

629 (doc_id, list(INSPECT_CODES)), 

630 ).fetchall() 

631 return [ 

632 Flag( 

633 ref_id=r[0], 

634 ref_type=r[1], 

635 page=(r[2] or 0) + 1, 

636 bbox=r[3] if isinstance(r[3], dict) else (json.loads(r[3]) if r[3] else None), 

637 status=r[4], 

638 note=r[5], 

639 text=r[6] or "", 

640 reason=_reason(r[4], r[5], r[6])[0], 

641 ) 

642 for r in rows 

643 ] 

644 

645 

646# ---------------------------------------------------------------- uploads 

647 

648# The jobs this process is running, in memory as the working copy. Every 

649# stage transition is also written to the database (quber.playground.jobs), 

650# which is what answers a status poll on any task and what outlives a task. 

651JOBS: dict[str, dict] = {} 

652_JOBS_LOCK = threading.Lock() 

653_LOG_TAIL = 200 # lines kept per job 

654 

655# Every upload is admitted; this bounds how many run their pipelines at once. 

656# Every stage is a subprocess, so two concurrent extractions are two 

657# `quber fuse` processes on one CUDA device, each also making model calls. 

658_UPLOAD_SLOTS = threading.Semaphore(get_settings().playground.upload_concurrency) 

659 

660 

661# The ordered stages each pipeline moves through, ending in done or failed. 

662# `waiting` is the admitted-but-queued state, ahead of the stages the pipeline 

663# emits — without it a queued document looks stalled on its first step. 

664PIPELINE_STAGES = { 

665 "fusion": ["uploaded", "waiting", "extracting", "reading figures", "ingesting"], 

666} 

667 

668#: The fusion pipeline's ingestion tracks, keyed by model line. A track selects 

669#: the whole line at once — the figure scan's model and the ingest's chunking 

670#: stream — so the two are never mixed within one document. The model is the 

671#: CLI spelling: `quber figure` resolves "dpt-3" to its pinned dated version, 

672#: so the pin stays single-sourced in the scan module. A future line (dpt-4, 

673#: dpt-5) is one more entry here. 

674TRACKS = { 

675 "dpt-3": {"model": "dpt-3", "chunking": "grouped"}, 

676 "dpt-2": {"model": "dpt-2", "chunking": "flat"}, 

677} 

678DEFAULT_TRACK = "dpt-3" 

679 

680#: Filing types whose figure scan stays on nominated pages only. These are the 

681#: long prose-and-table filings where full coverage buys mostly empty scans at 

682#: real cost; every other type — and that includes free-form types this set has 

683#: never heard of — scans every page, because a deck's content routinely lives 

684#: on pages the parse cannot nominate. 

685NOMINATED_ONLY_TYPES = {"10-Q", "10-K"} 

686 

687 

688def _scan_breadth(job: Dict[str, Any], pdf: Path, workdir: Path) -> tuple[str, str]: 

689 """The figure scan's page breadth for one upload, and the reason for it. 

690 

691 Typed documents are decided by their filing type alone. An untyped document 

692 is never allowed to default to full coverage — the type field is optional 

693 and routinely left blank, and a blank must not buy an 80-page scan by 

694 accident. A cover attribution agent reads the first page instead and 

695 decides presentation (all pages) against prose filing (nominated only); 

696 any failure in that read falls back to nominated-only, with the fallback 

697 stated in the returned reason so the record shows breadth was reduced and 

698 why. The verdict decides breadth only — it is never written to the 

699 document's filing type, which stays exactly as the user entered it. 

700 """ 

701 filing_type = (job.get("filing_type") or "").strip().upper() 

702 if filing_type: 

703 scope = "nominated" if filing_type in NOMINATED_ONLY_TYPES else "all" 

704 return scope, f"filing type {filing_type}" 

705 import asyncio 

706 

707 import fitz 

708 

709 from quber.agents.cover_attribution import get_cover_attributor 

710 from quber.files.pdf import render_page 

711 

712 try: 

713 with fitz.open(pdf) as doc: 

714 page_count = doc.page_count 

715 cover = workdir / f"{pdf.stem}.cover.png" 

716 render_page(pdf, 1, 100, cover) 

717 verdict = asyncio.run(get_cover_attributor().attribute(cover.read_bytes(), page_count)) 

718 cover.unlink(missing_ok=True) 

719 if verdict.kind == "presentation": 

720 return "all", f"attributed presentation: {verdict.reason}" 

721 return "nominated", f"attributed filing: {verdict.reason}" 

722 except Exception as exc: 

723 return ( 

724 "nominated", 

725 f"attribution failed ({type(exc).__name__}: {exc}); nominated-only fallback", 

726 ) 

727 

728 

729class JobStatus(BaseModel): 

730 job_id: str 

731 filename: str 

732 title: Optional[str] = None 

733 folder: Optional[str] = None 

734 # One of `stages`, or the terminal `done` / `failed`. The view reads the 

735 # stage names off this route rather than keeping a parallel set, so these 

736 # strings are the contract — the stepper draws `stages` and marks `stage` 

737 # against it, which is how the ADE variant appears without the view 

738 # learning anything new. 

739 stage: str 

740 stages: List[str] = PIPELINE_STAGES["fusion"] 

741 error: Optional[str] = None 

742 # Structured refusal for the states the modal renders specially: kind 

743 # "duplicate" (with the existing document to link to), "tuple", or 

744 # "busy". Set alongside `error` when an s3-sourced job fails admission — 

745 # a body upload hits the same checks synchronously as a 409 whose detail 

746 # carries this same shape. 

747 error_info: Optional[dict] = None 

748 # Set once the ingest completes: the new document's id. 

749 doc_id: Optional[int] = None 

750 log: List[str] 

751 

752 

753def _run_stage(job: dict, stage: str, cmd: List[str]) -> None: 

754 job["stage"] = stage 

755 job["log"].append(f"--- {stage}: {' '.join(cmd)}") 

756 jobs.save_upload(job) 

757 proc = subprocess.Popen(cmd, cwd=REPO_ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) 

758 assert proc.stdout is not None 

759 for line in proc.stdout: 

760 job["log"].append(line.rstrip()) 

761 if len(job["log"]) > _LOG_TAIL: 

762 del job["log"][:-_LOG_TAIL] 

763 proc.wait() 

764 # What the stage wrote is copied to the durable prefix whether it 

765 # succeeded or not: a failed stage's partial outputs are the diagnosis. 

766 storage.publish_workdir(job["doc_key"], UPLOADS / f"{job['doc_key']}-artifacts") 

767 jobs.save_upload(job) 

768 if proc.returncode != 0: 

769 raise RuntimeError(f"{stage} exited with code {proc.returncode}") 

770 

771 

772def _run_hosted_extraction(job: dict, pdf: str, workdir: Path) -> None: 

773 """The extracting stage where this host has no GPU: the docling parse 

774 goes to the worker while the table engine runs here, and fusion reads 

775 the two sets of artifacts from the workdir once both are in it.""" 

776 doc_key = job["doc_key"] 

777 

778 def log(line: str) -> None: 

779 job["log"].append(line) 

780 if len(job["log"]) > _LOG_TAIL: 

781 del job["log"][:-_LOG_TAIL] 

782 

783 job["stage"] = "extracting" 

784 jobs.save_upload(job) 

785 _run_stage( 

786 job, "extracting", ["uv", "run", "quber", "table", pdf, "-o", str(workdir), "--llm-backend", "api"] 

787 ) 

788 gpu.parse(doc_key, workdir, log) 

789 jobs.save_upload(job) 

790 _run_stage( 

791 job, 

792 "extracting", 

793 ["uv", "run", "quber", "fuse", pdf, "--artifacts-dir", str(workdir), "-o", str(workdir)], 

794 ) 

795 

796 

797def _await_slot(job: dict) -> None: 

798 """Hold until a pipeline slot frees. Admitted-but-waiting is its own 

799 stage: a queued document has something honest to show rather than 

800 appearing stalled on its first step.""" 

801 job["stage"] = "waiting" 

802 jobs.save_upload(job) 

803 _UPLOAD_SLOTS.acquire() 

804 

805 

806def _admission_error(job: dict) -> Optional[dict]: 

807 """The reason this upload must not run, as the structured error_info the 

808 modal renders — or None. Checks, in order: the same bytes already in the 

809 library (or already being ingested), then the filing tuple against 

810 existing documents and in-flight jobs. A tuple match with the replace 

811 intent records the target on the job instead of refusing, unless the 

812 target is under running work.""" 

813 with db.connect() as conn: 

814 dup = conn.execute( 

815 "SELECT id, title, folder, filename FROM ade_playground.documents WHERE content_hash = %s", 

816 (job["content_hash"],), 

817 ).fetchone() 

818 if dup: 

819 title = dup[1] or dup[3] 

820 return { 

821 "kind": "duplicate", 

822 "message": f"This exact file is already in the library — '{title}' in {dup[2] or 'Ungrouped'}.", 

823 "existing": {"id": dup[0], "title": dup[1], "folder": dup[2]}, 

824 } 

825 others = [j for j in jobs.live_uploads() if j["job_id"] != job["job_id"]] 

826 if any(j.get("doc_key") == job["doc_key"] for j in others): 

827 return {"kind": "duplicate", "message": "This exact file is already being ingested."} 

828 

829 key = metadata.tuple_key(job["folder"], job["filing_type"], job["year"], job["period"], job["version"]) 

830 if key is None: 

831 return None 

832 for j in others: 

833 other_key = metadata.tuple_key( 

834 j.get("folder"), j.get("filing_type"), j.get("year"), j.get("period"), j.get("version") 

835 ) 

836 if other_key == key: 

837 return { 

838 "kind": "tuple", 

839 "message": "A document with this filing metadata is already being ingested.", 

840 } 

841 with db.connect() as conn: 

842 rows = conn.execute("SELECT " + _DOC_COLUMNS + " FROM ade_playground.documents d").fetchall() 

843 match = next( 

844 ( 

845 d 

846 for d in (_doc_dict(r) for r in rows) 

847 if metadata.tuple_key(d["folder"], d["filing_type"], d["year"], d["period"], d["version"]) == key 

848 ), 

849 None, 

850 ) 

851 if match is None: 

852 return None 

853 if not job["replace"]: 

854 return { 

855 "kind": "tuple", 

856 "message": f"A {match['label']} already exists in {match['folder'] or 'Ungrouped'}.", 

857 "existing": {"id": match["id"], "title": match["title"], "folder": match["folder"]}, 

858 } 

859 busy = _ingest_busy(match["id"], match["doc_key"]) 

860 if busy: 

861 return {"kind": "busy", "message": f"Cannot replace: {busy}."} 

862 job["replace_id"] = match["id"] 

863 job["replace_key"] = match["doc_key"] 

864 return None 

865 

866 

867def _finalize_job(job: dict) -> None: 

868 """Stamp the upload's metadata onto the freshly ingested row and, for a 

869 replace, swap the target out in the same transaction. Ingest recreates 

870 the document row, so this runs after it.""" 

871 replaced_key: Optional[str] = None 

872 with db.connect() as conn: 

873 with conn.transaction(): 

874 conn.execute( 

875 """UPDATE ade_playground.documents 

876 SET title = %s, folder = %s, filing_type = %s, year = %s, period = %s, 

877 version = %s, track = %s 

878 WHERE doc_key = %s""", 

879 ( 

880 job.get("title"), 

881 job.get("folder"), 

882 job.get("filing_type"), 

883 job.get("year"), 

884 job.get("period"), 

885 job.get("version"), 

886 job.get("track"), 

887 job["doc_key"], 

888 ), 

889 ) 

890 if job.get("replace_id") is not None: 

891 # A batch run started against the target while the new ingest 

892 # ran: leave both documents standing (the overlap flag names 

893 # them) rather than deleting under the run. 

894 if BATCH.running_for(job["replace_id"]): 

895 job["log"].append( 

896 "replace target is under a running batch; both documents kept " 

897 "(remove the old one from the library when the run finishes)" 

898 ) 

899 else: 

900 conn.execute("DELETE FROM ade_playground.documents WHERE id = %s", (job["replace_id"],)) 

901 replaced_key = job.get("replace_key") 

902 row = conn.execute( 

903 "SELECT id FROM ade_playground.documents WHERE doc_key = %s", (job["doc_key"],) 

904 ).fetchone() 

905 if replaced_key: 

906 _remove_doc_files(replaced_key) 

907 logger.info("replaced document {id} ({key})", id=job["replace_id"], key=replaced_key) 

908 job["doc_id"] = row[0] if row else None 

909 jobs.save_upload(job) 

910 

911 

912def _admit_or_fail(job: dict, pdf_bytes: Optional[bytes], s3_uri: Optional[str]) -> Optional[str]: 

913 """Resolve the upload to local bytes, hash them, and run admission. 

914 Returns the staged PDF path to run the pipeline on, or None after 

915 marking the job failed. For body uploads admission already ran in the 

916 route; this stages the s3 source and repeats it (the authoritative 

917 pass — the route could not hash bytes it never had).""" 

918 if pdf_bytes is None: 

919 assert s3_uri is not None 

920 staged = UPLOADS / f"tmp-{job['job_id']}.pdf" 

921 try: 

922 fetch_file(s3_uri, staged) 

923 pdf_bytes = staged.read_bytes() 

924 except Exception as exc: 

925 job["stage"] = "failed" 

926 job["error"] = f"could not fetch {s3_uri}: {exc}" 

927 return None 

928 job["content_hash"] = hashlib.sha256(pdf_bytes).hexdigest() 

929 job["doc_key"] = job["content_hash"][:16] 

930 info = _admission_error(job) 

931 if info is not None: 

932 staged.unlink(missing_ok=True) 

933 job["stage"] = "failed" 

934 job["error"] = info["message"] 

935 job["error_info"] = info 

936 return None 

937 staged.rename(UPLOADS / f"{job['doc_key']}.pdf") 

938 return str(UPLOADS / f"{job['doc_key']}.pdf") 

939 

940 

941def _run_job(job_id: str, pdf_bytes: Optional[bytes], s3_uri: Optional[str]) -> None: 

942 """The quber workflow: fuse, then read the figures, then ingest. 

943 

944 The figure stage is part of the workflow rather than an option on it. Without 

945 it a document arrives holding only what the text layer gave up: a chart's 

946 plotted values are absent, a table printed as an image stands as the parse's 

947 own reading of it, and the notes that qualify a figure are attached to 

948 nothing. The standalone ADE path is a different pipeline and is unaffected. 

949 

950 The stage runs on every upload, and it spends: each nominated page is a scan. 

951 A document the parse nominates no page in costs nothing and writes no figure 

952 artifacts, which the ingest reads as "the run produced none of that kind" 

953 rather than as an error. 

954 """ 

955 job = JOBS[job_id] 

956 try: 

957 _await_slot(job) 

958 pdf = _admit_or_fail(job, pdf_bytes, s3_uri) 

959 if pdf is None: 

960 return 

961 doc_key = job["doc_key"] 

962 workdir = UPLOADS / f"{doc_key}-artifacts" 

963 workdir.mkdir(parents=True, exist_ok=True) 

964 base = doc_key 

965 # The PDF and its source manifest reach the durable prefix before any 

966 # stage runs, and every stage's outputs follow it as the stage ends. 

967 manifest = storage.write_source_manifest( 

968 doc_key, filename=job["filename"], content_hash=job["content_hash"], source_uri=s3_uri 

969 ) 

970 storage.publish(doc_key, [Path(pdf), manifest]) 

971 track = TRACKS[job.get("track") or DEFAULT_TRACK] 

972 # Breadth is decided before anything runs and recorded on the job, so 

973 # the run's record always says how many pages were in play and why — 

974 # server-side only, never part of a client payload. 

975 pages, breadth_reason = _scan_breadth(job, Path(pdf), workdir) 

976 job["scan_pages"] = pages 

977 job["scan_pages_reason"] = breadth_reason 

978 job["source_uri"] = s3_uri 

979 jobs.save_upload(job) 

980 logger.info("Upload {}: figure scan breadth {} ({})", job_id, pages, breadth_reason) 

981 if gpu.configured(): 

982 _run_hosted_extraction(job, pdf, workdir) 

983 else: 

984 _run_stage(job, "extracting", ["uv", "run", "quber", "fuse", pdf, "-o", str(workdir)]) 

985 _run_stage( 

986 job, 

987 "reading figures", 

988 [ 

989 "uv", 

990 "run", 

991 "quber", 

992 "figure", 

993 pdf, 

994 "--parse", 

995 str(workdir / f"{base}.unified.json"), 

996 "-o", 

997 str(workdir), 

998 "--model", 

999 track["model"], 

1000 "--pages", 

1001 pages, 

1002 ], 

1003 ) 

1004 _run_stage( 

1005 job, 

1006 "ingesting", 

1007 [ 

1008 "uv", 

1009 "run", 

1010 "python", 

1011 "-m", 

1012 "quber.playground.ingest_fusion", 

1013 "--doc-key", 

1014 doc_key, 

1015 "--filename", 

1016 job["filename"], 

1017 "--unified-json", 

1018 str(workdir / f"{base}.unified.json"), 

1019 "--tables-json", 

1020 str(workdir / f"{base}.tables.json"), 

1021 "--fusion-json", 

1022 str(workdir / f"{base}.fusion.json"), 

1023 "--scanned-tables-json", 

1024 str(workdir / f"{base}.scanned-tables.json"), 

1025 "--figures-json", 

1026 str(workdir / f"{base}.figures.json"), 

1027 "--figure-values-json", 

1028 str(workdir / f"{base}.figure-values.json"), 

1029 "--pdf", 

1030 pdf, 

1031 "--chunking", 

1032 track["chunking"], 

1033 ], 

1034 ) 

1035 _finalize_job(job) 

1036 job["stage"] = "done" 

1037 except Exception as exc: 

1038 job["stage"] = "failed" 

1039 job["error"] = str(exc) 

1040 finally: 

1041 jobs.save_upload(job) 

1042 _UPLOAD_SLOTS.release() 

1043 

1044 

1045@app.post("/api/upload", response_model=JobStatus) 

1046async def upload( 

1047 request: Request, 

1048 filename: str, 

1049 s3_uri: Optional[str] = None, 

1050 title: Optional[str] = None, 

1051 folder: Optional[str] = None, 

1052 filing_type: Optional[str] = None, 

1053 year: Optional[int] = None, 

1054 period: Optional[str] = None, 

1055 version: Optional[int] = None, 

1056 replace: bool = False, 

1057 track: str = DEFAULT_TRACK, 

1058) -> JobStatus: 

1059 """Run the fusion pipeline on a PDF sent as the raw request body, or on an 

1060 s3:// object named by `s3_uri` (body then stays empty). 

1061 

1062 `track` selects the model line the document is processed on — see TRACKS. 

1063 

1064 `filename` is the original name, kept for presentation and never for 

1065 identity. The metadata fields are how the library presents and keys the 

1066 document; this route and the PATCH are the only places they are ever 

1067 set. `replace` carries the modal's confirmed intent to swap out the 

1068 document whose filing tuple this upload matches. Every admitted upload 

1069 waits in the `waiting` stage until a pipeline slot frees. 

1070 """ 

1071 if track not in TRACKS: 

1072 raise HTTPException(400, f"track must be one of {sorted(TRACKS)}") 

1073 body: Optional[bytes] = None 

1074 if s3_uri is not None: 

1075 if not s3_uri.startswith("s3://") or not s3_uri.lower().endswith(".pdf"): 

1076 raise HTTPException(400, "s3_uri must be an s3:// URI ending in .pdf") 

1077 else: 

1078 body = await request.body() 

1079 if not body.startswith(b"%PDF"): 

1080 raise HTTPException(400, "request body is not a PDF") 

1081 job_id = uuid.uuid4().hex[:12] 

1082 job: dict = { 

1083 "job_id": job_id, 

1084 "filename": filename, 

1085 "title": title, 

1086 "folder": folder, 

1087 "filing_type": filing_type, 

1088 "year": year, 

1089 "period": period, 

1090 "version": version, 

1091 "replace": replace, 

1092 "track": track, 

1093 "replace_id": None, 

1094 "replace_key": None, 

1095 "doc_key": None, 

1096 "content_hash": None, 

1097 "doc_id": None, 

1098 "stage": "uploaded", 

1099 "stages": PIPELINE_STAGES["fusion"], 

1100 "error": None, 

1101 "error_info": None, 

1102 "log": [], 

1103 } 

1104 if body is not None: 

1105 # The bytes are in hand: hash and run admission now, so a duplicate 

1106 # or an unconfirmed tuple match is a synchronous refusal the modal 

1107 # shows in place — not a job that fails a poll later. 

1108 job["content_hash"] = hashlib.sha256(body).hexdigest() 

1109 job["doc_key"] = job["content_hash"][:16] 

1110 info = _admission_error(job) 

1111 if info is not None: 

1112 raise HTTPException(409, info) 

1113 UPLOADS.mkdir(parents=True, exist_ok=True) 

1114 (UPLOADS / f"{job['doc_key']}.pdf").write_bytes(body) 

1115 else: 

1116 UPLOADS.mkdir(parents=True, exist_ok=True) 

1117 with _JOBS_LOCK: 

1118 JOBS[job_id] = job 

1119 jobs.save_upload(job) 

1120 threading.Thread(target=_run_job, args=(job_id, body, s3_uri), daemon=True).start() 

1121 return JobStatus(**{**job, "log": []}) 

1122 

1123 

1124@app.get("/api/jobs/{job_id}", response_model=JobStatus) 

1125def job_status(job_id: str, tail: int = 12) -> JobStatus: 

1126 """The job as this process holds it when it is running it, else as the 

1127 database records it, which is how a poll lands on any task.""" 

1128 job = JOBS.get(job_id) or jobs.load_upload(job_id) 

1129 if not job: 

1130 raise HTTPException(404, f"no job {job_id}") 

1131 return JobStatus(**{**job, "log": job["log"][-tail:]}) 

1132 

1133 

1134def _readable(payload) -> str: 

1135 """What to put in `answer` for a consumer that only reads text. 

1136 

1137 A figure renders as the figure, so the text field of a value-seeking 

1138 answer is the value and not a sentence about it. 

1139 """ 

1140 if payload.kind == "prose": 

1141 return payload.text 

1142 if payload.kind == "unanswerable": 

1143 return payload.reason 

1144 if payload.kind == "scalar": 

1145 return payload.value 

1146 return json.dumps(payload.model_dump(mode="json"), indent=2) 

1147 

1148 

1149def cited_ids_source_first(payload, cited_ids: List[str]) -> List[str]: 

1150 """The cited ids with the answer's own source leading. 

1151 

1152 A value payload names the single cell or chunk it was read from in 

1153 `source_id`. The UI activates the first reference that carries a box, so 

1154 whatever id leads this list becomes the default highlight — and the 

1155 value's own cell is the highlight the answer earned, not the wider line 

1156 record the model happened to cite first. Payloads without a source 

1157 (prose, unanswerable, series) keep the model's citation order. 

1158 """ 

1159 source_id = getattr(payload, "source_id", None) 

1160 if not source_id: 

1161 return list(cited_ids) 

1162 return [source_id] + [cid for cid in cited_ids if cid != source_id] 

1163 

1164 

1165def _chat_response(doc_id: int, result, chunks) -> ChatResponse: 

1166 payload = result.payload 

1167 return ChatResponse( 

1168 answer=_readable(payload), 

1169 shape=payload.kind, 

1170 value=payload.model_dump(mode="json"), 

1171 references=_resolve_refs(doc_id, cited_ids_source_first(payload, result.cited_ids)), 

1172 retrieved=[ 

1173 RetrievedInfo( 

1174 chunk_id=c.chunk_id, chunk_type=c.chunk_type, page=c.page + 1, score=round(c.score, 4) 

1175 ) 

1176 for c in chunks 

1177 ], 

1178 ) 

1179 

1180 

1181@app.post("/api/chat", response_model=ChatResponse) 

1182async def chat(req: ChatRequest) -> ChatResponse: 

1183 doc = _doc_row(req.doc_id) 

1184 document_key.set(doc["doc_key"]) 

1185 chunks = await retrieve(doc["doc_key"], req.question, k=req.k) 

1186 if not chunks: 

1187 raise HTTPException(404, f"No chunks for document {req.doc_id} — is it ingested?") 

1188 result = await agent_answer(req.question, chunks, req.want, DocumentIdentity.from_row(doc)) 

1189 return _chat_response(req.doc_id, result, chunks) 

1190 

1191 

1192@app.post("/api/chat/stream") 

1193async def chat_stream(req: ChatRequest) -> StreamingResponse: 

1194 """Same contract as /api/chat, streamed: server-sent events carrying the 

1195 answer text as it generates ('delta'), then the full response ('done').""" 

1196 doc = _doc_row(req.doc_id) 

1197 document_key.set(doc["doc_key"]) 

1198 chunks = await retrieve(doc["doc_key"], req.question, k=req.k) 

1199 if not chunks: 

1200 raise HTTPException(404, f"No chunks for document {req.doc_id} — is it ingested?") 

1201 

1202 identity = DocumentIdentity.from_row(doc) 

1203 

1204 async def events(): 

1205 try: 

1206 async for kind, payload in agent_answer_stream(req.question, chunks, req.want, identity): 

1207 if kind == "delta": 

1208 yield f"data: {json.dumps({'type': 'delta', 'answer': payload})}\n\n" 

1209 else: 

1210 body = _chat_response(req.doc_id, payload, chunks).model_dump() 

1211 yield f"data: {json.dumps({'type': 'done', 'payload': body})}\n\n" 

1212 except Exception as exc: 

1213 yield f"data: {json.dumps({'type': 'error', 'detail': str(exc)})}\n\n" 

1214 

1215 return StreamingResponse(events(), media_type="text/event-stream") 

1216 

1217 

1218# ---------------------------------------------------------------- batch 

1219 

1220BATCH_STORE = jobs.BatchStore() 

1221BATCH = BatchRunner(store=BATCH_STORE) 

1222 

1223 

1224class BatchRequest(BaseModel): 

1225 doc_id: int 

1226 # The batch screen's prompt window, verbatim: one question per line. The 

1227 # server parses it so there is exactly one reading of that format. 

1228 questions: str 

1229 k: int = 10 

1230 # One expectation for the whole run; see ChatRequest.want. 

1231 want: Literal["auto", "value", "text"] = "auto" 

1232 

1233 

1234@app.post("/api/batch", response_model=RunState) 

1235async def batch_start(req: BatchRequest) -> RunState: 

1236 """Start a run. The work continues if the page is closed; the page keeps 

1237 the returned job id in sessionStorage and re-attaches through the state 

1238 and events routes.""" 

1239 questions = parse_questions(req.questions) 

1240 if not questions: 

1241 raise HTTPException(400, "no questions — the batch window takes one per line") 

1242 doc = _doc_row(req.doc_id) 

1243 doc_key = doc["doc_key"] 

1244 # Set before the probe so its selection run carries the document too; the 

1245 # run task copies this context when it is created. 

1246 document_key.set(doc_key) 

1247 probe = await retrieve(doc_key, questions[0], k=1) 

1248 if not probe: 

1249 raise HTTPException(404, f"No chunks for document {req.doc_id} — is it ingested?") 

1250 

1251 identity = DocumentIdentity.from_row(doc) 

1252 

1253 async def retrieve_one(question: str) -> List[RetrievedChunk]: 

1254 return await retrieve(doc_key, question, k=req.k) 

1255 

1256 async def answer_one(question: str, chunks: List[RetrievedChunk]) -> Dict[str, Any]: 

1257 result = await agent_answer(question, chunks, req.want, identity) 

1258 return _chat_response(req.doc_id, result, chunks).model_dump(mode="json") 

1259 

1260 return BATCH.start(req.doc_id, questions, req.want, retrieve_one, answer_one) 

1261 

1262 

1263def _run_state(job_id: str) -> RunState: 

1264 state = BATCH.get(job_id) 

1265 if state is None: 

1266 # A restart and a cap eviction arrive here identically: an id the page 

1267 # kept and a run this process cannot answer for. The page reports the 

1268 # run as gone; it is never silence. 

1269 raise HTTPException(404, f"no run {job_id} — the server no longer has it") 

1270 return state 

1271 

1272 

1273@app.get("/api/batch/{job_id}", response_model=RunState) 

1274def batch_state(job_id: str) -> RunState: 

1275 return _run_state(job_id) 

1276 

1277 

1278@app.get("/api/batch/{job_id}/events") 

1279async def batch_events(job_id: str) -> StreamingResponse: 

1280 """One server-sent event per question completing — index, total, outcome — 

1281 then a final `done` event. Watching a run and re-attaching to one are 

1282 different needs; this is the watching route.""" 

1283 _run_state(job_id) 

1284 

1285 async def stream(): 

1286 async for event in BATCH.events(job_id): 

1287 yield f"data: {json.dumps(event)}\n\n" 

1288 

1289 return StreamingResponse(stream(), media_type="text/event-stream") 

1290 

1291 

1292def _export_stem(state: RunState) -> str: 

1293 """The export filename's document part, down the label precedence. A run 

1294 whose document was removed mid-session still exports; it just gets the 

1295 generic stem.""" 

1296 try: 

1297 doc = _doc_row(state.doc_id) 

1298 except HTTPException: 

1299 return "document" 

1300 return metadata.export_stem(doc["label"], doc["title"], doc["filename"]) 

1301 

1302 

1303@app.get("/api/batch/{job_id}/export.xlsx") 

1304def batch_export_xlsx(job_id: str) -> Response: 

1305 state = _run_state(job_id) 

1306 return Response( 

1307 export.write_xlsx(state), 

1308 media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 

1309 headers={"Content-Disposition": f'attachment; filename="batch-{_export_stem(state)}-{job_id}.xlsx"'}, 

1310 ) 

1311 

1312 

1313@app.get("/api/batch/{job_id}/export.csv") 

1314def batch_export_csv(job_id: str) -> Response: 

1315 state = _run_state(job_id) 

1316 return Response( 

1317 export.write_csv(state), 

1318 media_type="text/csv; charset=utf-8", 

1319 headers={"Content-Disposition": f'attachment; filename="batch-{_export_stem(state)}-{job_id}.csv"'}, 

1320 )