Skip to content

Commit df9555e

Browse files
committed
feat: add bulk document update endpoint and OCR pair dataset exporter service
1 parent 9083184 commit df9555e

10 files changed

Lines changed: 634 additions & 27 deletions

File tree

ocr_pipeline/models/schemas.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ class DocumentUpdate(BaseModel):
9999
tags_json: Optional[str] = None
100100

101101

102+
class BulkDocumentUpdate(BaseModel):
103+
document_ids: list[str]
104+
group_path: Optional[str] = None
105+
106+
102107
class DocumentSummary(BaseModel):
103108
id: str
104109
filename: str

ocr_pipeline/routers/documents.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from fastapi import APIRouter, HTTPException, Path as PathParam, Query
22

3-
from ocr_pipeline.models.schemas import DocumentSummary, DocumentUpdate, RunSummary
3+
from ocr_pipeline.models.schemas import (
4+
BulkDocumentUpdate,
5+
DocumentSummary,
6+
DocumentUpdate,
7+
RunSummary,
8+
)
49
from ocr_pipeline.routers.runs import _run_summary
510
from ocr_pipeline.services.db import get_db
611

@@ -24,6 +29,26 @@ async def list_documents(limit: int = Query(500, ge=1, le=1000)):
2429
return [_document_summary(d) for d in await get_db().list_documents(limit=limit)]
2530

2631

32+
@router.patch("/api/documents/bulk", response_model=list[DocumentSummary])
33+
async def update_documents_bulk(payload: BulkDocumentUpdate):
34+
if not payload.document_ids:
35+
raise HTTPException(status_code=400, detail="document_ids must not be empty")
36+
db = get_db()
37+
for document_id in payload.document_ids:
38+
try:
39+
await db.update_document_metadata(
40+
document_id,
41+
group_path=payload.group_path,
42+
)
43+
except KeyError:
44+
raise HTTPException(
45+
status_code=404, detail=f"Document not found: {document_id}"
46+
)
47+
documents = await db.list_documents(limit=1000)
48+
selected = {document_id for document_id in payload.document_ids}
49+
return [_document_summary(doc) for doc in documents if doc["id"] in selected]
50+
51+
2752
@router.get("/api/documents/{document_id}", response_model=DocumentSummary)
2853
async def get_document(document_id: str = ID):
2954
doc = await get_db().get_document(document_id)

ocr_pipeline/routers/runs.py

Lines changed: 104 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,34 @@
44
from pathlib import Path
55

66
from fastapi import APIRouter, HTTPException, Path as PathParam, Query, Request
7-
from fastapi.responses import FileResponse, PlainTextResponse, Response, StreamingResponse
7+
from fastapi.responses import (
8+
FileResponse,
9+
PlainTextResponse,
10+
Response,
11+
StreamingResponse,
12+
)
813

914
from ocr_pipeline.config import settings
1015
from ocr_pipeline.models.schemas import (
11-
HFPublishRequest, HFPublishResponse, PageSummary, RunCreateRequest,
12-
RunCreateResponse, RunDetail, RunDocumentDetail, RunDocumentSummary,
13-
RunSummary, StagedDocumentInfo,
16+
HFPublishRequest,
17+
HFPublishResponse,
18+
PageSummary,
19+
RunCreateRequest,
20+
RunCreateResponse,
21+
RunDetail,
22+
RunDocumentDetail,
23+
RunDocumentSummary,
24+
RunSummary,
25+
StagedDocumentInfo,
26+
)
27+
from ocr_pipeline.services.auth_session import (
28+
is_oauth_enabled,
29+
session_token,
30+
session_user,
1431
)
15-
from ocr_pipeline.services.auth_session import is_oauth_enabled, session_token, session_user
1632
from ocr_pipeline.services.db import get_db
1733
from ocr_pipeline.services.hf_publisher import publish_run_to_hf
34+
from ocr_pipeline.services.ocr_pair_exporter import OCRPairExporter
1835
from ocr_pipeline.services.pdf_renderer import PDFRenderer
1936
from ocr_pipeline.services.run_orchestrator import get_orchestrator
2037
from ocr_pipeline.services.startup import model_readiness
@@ -33,9 +50,12 @@
3350
"source": ("artifact_source_pdf", "application/pdf"),
3451
}
3552
TEXT_MODES = {
36-
"raw": "artifact_raw_txt", "raw_txt": "artifact_raw_txt",
37-
"txt": "artifact_clean_txt", "clean": "artifact_clean_txt",
38-
"md": "artifact_markdown", "markdown": "artifact_markdown",
53+
"raw": "artifact_raw_txt",
54+
"raw_txt": "artifact_raw_txt",
55+
"txt": "artifact_clean_txt",
56+
"clean": "artifact_clean_txt",
57+
"md": "artifact_markdown",
58+
"markdown": "artifact_markdown",
3959
}
4060

4161

@@ -103,6 +123,7 @@ def _doc_summary(row: dict) -> RunDocumentSummary:
103123
def _page_summary(row: dict) -> PageSummary:
104124
def _bool(v):
105125
return bool(v) if v is not None else None
126+
106127
return PageSummary(
107128
page_num=row["page_num"],
108129
status=row["status"],
@@ -149,7 +170,9 @@ def _existing_path(rd: dict, field: str) -> Path:
149170
@router.post("/api/runs", response_model=RunCreateResponse)
150171
async def create_run(request: RunCreateRequest):
151172
if not model_readiness.ready:
152-
raise HTTPException(status_code=503, detail=f"Model server not ready: {model_readiness.status}")
173+
raise HTTPException(
174+
status_code=503, detail=f"Model server not ready: {model_readiness.status}"
175+
)
153176
if not request.file_paths:
154177
raise HTTPException(status_code=400, detail="file_paths must not be empty")
155178

@@ -164,7 +187,9 @@ async def create_run(request: RunCreateRequest):
164187
except FileNotFoundError as exc:
165188
raise HTTPException(status_code=404, detail=str(exc))
166189

167-
orchestrator.start(result, strip_refs=request.strip_refs, export_parquet=request.export_parquet)
190+
orchestrator.start(
191+
result, strip_refs=request.strip_refs, export_parquet=request.export_parquet
192+
)
168193

169194
return RunCreateResponse(
170195
run_id=result.run_id,
@@ -193,29 +218,37 @@ async def list_runs(limit: int = Query(50, ge=1, le=500)):
193218
async def get_run(run_id: str = ID):
194219
run = await _require_run(run_id)
195220
documents = await get_db().list_run_documents(run_id)
196-
return RunDetail(**_run_summary(run).model_dump(),
197-
documents=[_doc_summary(d) for d in documents])
221+
return RunDetail(
222+
**_run_summary(run).model_dump(), documents=[_doc_summary(d) for d in documents]
223+
)
198224

199225

200226
@router.delete("/api/runs/{run_id}")
201227
async def delete_run(run_id: str = ID):
202228
run = await _require_run(run_id)
203229
if run["status"] == "processing":
204-
raise HTTPException(status_code=409, detail="Cannot delete a run that is still processing")
230+
raise HTTPException(
231+
status_code=409, detail="Cannot delete a run that is still processing"
232+
)
205233
await get_db().delete_run(run_id)
206234
return {"deleted": run_id}
207235

208236

209-
@router.get("/api/runs/{run_id}/documents/{document_id}", response_model=RunDocumentDetail)
237+
@router.get(
238+
"/api/runs/{run_id}/documents/{document_id}", response_model=RunDocumentDetail
239+
)
210240
async def get_run_document(run_id: str = ID, document_id: str = ID):
211241
rd = await _require_doc(run_id, document_id)
212242
pages = await get_db().list_pages(run_id, document_id)
213-
return RunDocumentDetail(**_doc_summary(rd).model_dump(),
214-
pages=[_page_summary(p) for p in pages])
243+
return RunDocumentDetail(
244+
**_doc_summary(rd).model_dump(), pages=[_page_summary(p) for p in pages]
245+
)
215246

216247

217248
@router.get("/api/runs/{run_id}/documents/{document_id}/text")
218-
async def get_run_document_text(run_id: str = ID, document_id: str = ID, mode: str = "txt"):
249+
async def get_run_document_text(
250+
run_id: str = ID, document_id: str = ID, mode: str = "txt"
251+
):
219252
field = TEXT_MODES.get(mode)
220253
if not field:
221254
raise HTTPException(status_code=400, detail="Unsupported mode")
@@ -267,7 +300,48 @@ async def download_dataset_bundle(run_id: str = ID):
267300
bundle = run.get("dataset_bundle")
268301
if not bundle or not Path(bundle).exists():
269302
raise HTTPException(status_code=404, detail="Dataset bundle not available")
270-
return FileResponse(bundle, media_type="application/zip", filename=Path(bundle).name)
303+
return FileResponse(
304+
bundle, media_type="application/zip", filename=Path(bundle).name
305+
)
306+
307+
308+
@router.get("/api/runs/{run_id}/ocr-pairs/download")
309+
async def download_ocr_pairs(
310+
run_id: str = ID,
311+
dpi: int = Query(160, ge=50, le=400),
312+
text_mode: str = Query("clean", pattern="^(clean|raw)$"),
313+
):
314+
db = get_db()
315+
run = await _require_run(run_id)
316+
if run["status"] != "completed":
317+
raise HTTPException(status_code=409, detail="Run is not yet completed")
318+
319+
documents = await db.list_run_documents(run_id)
320+
pages_by_document = {
321+
doc["document_id"]: await db.list_pages(run_id, doc["document_id"])
322+
for doc in documents
323+
}
324+
catalog_by_document = {
325+
doc["document_id"]: await db.get_document(doc["document_id"]) or {}
326+
for doc in documents
327+
}
328+
exporter = OCRPairExporter(settings.runs_dir / run_id / "dataset" / "ocr_pairs")
329+
result = await asyncio.to_thread(
330+
exporter.export_run,
331+
run=run,
332+
documents=documents,
333+
pages_by_document=pages_by_document,
334+
catalog_by_document=catalog_by_document,
335+
dpi=dpi,
336+
text_mode=text_mode,
337+
)
338+
if result.pages_count == 0:
339+
raise HTTPException(status_code=404, detail="No completed OCR pages to export")
340+
return FileResponse(
341+
result.bundle,
342+
media_type="application/zip",
343+
filename=f"{run_id}-ocr-pairs.zip",
344+
)
271345

272346

273347
@router.get("/api/runs/{run_id}/stream")
@@ -276,14 +350,18 @@ async def stream_run(run_id: str = ID, after_event_id: int = 0):
276350
orchestrator = get_orchestrator()
277351

278352
async def gen():
279-
async for event in orchestrator.subscribe(run_id, after_event_id=after_event_id):
353+
async for event in orchestrator.subscribe(
354+
run_id, after_event_id=after_event_id
355+
):
280356
yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"
281357

282358
return StreamingResponse(gen(), media_type="text/event-stream")
283359

284360

285361
@router.post("/api/runs/{run_id}/publish/hf", response_model=HFPublishResponse)
286-
async def publish_to_hf(payload: HFPublishRequest, http_request: Request, run_id: str = ID):
362+
async def publish_to_hf(
363+
payload: HFPublishRequest, http_request: Request, run_id: str = ID
364+
):
287365
db = get_db()
288366
run = await _require_run(run_id)
289367
if run["status"] != "completed":
@@ -293,7 +371,9 @@ async def publish_to_hf(payload: HFPublishRequest, http_request: Request, run_id
293371
# 1. signed-in user's HF OAuth token (preferred — tied to a real user)
294372
# 2. token explicitly passed in the request body (paste-token mode)
295373
# 3. HF_TOKEN env var (single-user / dev fallback, resolved inside publisher)
296-
user = session_user(http_request.session) if hasattr(http_request, "session") else None
374+
user = (
375+
session_user(http_request.session) if hasattr(http_request, "session") else None
376+
)
297377
sess_tok = session_token(http_request.session) if user else None
298378

299379
# If OAuth is enabled and the user is signed in, ignore the body token —
@@ -302,7 +382,9 @@ async def publish_to_hf(payload: HFPublishRequest, http_request: Request, run_id
302382
# publishes entirely so the panel acts as a true gate.
303383
if is_oauth_enabled():
304384
if not sess_tok:
305-
raise HTTPException(status_code=401, detail="Sign in with HuggingFace to publish.")
385+
raise HTTPException(
386+
status_code=401, detail="Sign in with HuggingFace to publish."
387+
)
306388
token = sess_tok
307389
else:
308390
token = payload.token

0 commit comments

Comments
 (0)