Skip to content

Commit 565eec9

Browse files
committed
feat: support partial dataset downloads and cached OCR pair exports with file hashing
1 parent df9555e commit 565eec9

10 files changed

Lines changed: 352 additions & 33 deletions

File tree

ocr_pipeline/routers/documents.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,20 @@ async def update_documents_bulk(payload: BulkDocumentUpdate):
3434
if not payload.document_ids:
3535
raise HTTPException(status_code=400, detail="document_ids must not be empty")
3636
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-
)
37+
try:
38+
await db.update_documents_metadata(
39+
payload.document_ids,
40+
group_path=payload.group_path,
41+
)
42+
except KeyError as exc:
43+
raise HTTPException(
44+
status_code=404, detail=f"Document not found: {exc.args[0]}"
45+
)
4746
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]
47+
by_id = {doc["id"]: doc for doc in documents}
48+
return [
49+
_document_summary(by_id[document_id]) for document_id in payload.document_ids
50+
]
5051

5152

5253
@router.get("/api/documents/{document_id}", response_model=DocumentSummary)

ocr_pipeline/routers/runs.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import hashlib
23
import json
34
from io import BytesIO
45
from pathlib import Path
@@ -310,13 +311,25 @@ async def download_ocr_pairs(
310311
run_id: str = ID,
311312
dpi: int = Query(160, ge=50, le=400),
312313
text_mode: str = Query("clean", pattern="^(clean|raw)$"),
314+
document_ids: str | None = Query(None),
313315
):
314316
db = get_db()
315317
run = await _require_run(run_id)
316318
if run["status"] != "completed":
317319
raise HTTPException(status_code=409, detail="Run is not yet completed")
318320

319321
documents = await db.list_run_documents(run_id)
322+
selected_ids = {
323+
part.strip() for part in (document_ids or "").split(",") if part.strip()
324+
} or None
325+
if selected_ids:
326+
available_ids = {doc["document_id"] for doc in documents}
327+
missing = selected_ids - available_ids
328+
if missing:
329+
raise HTTPException(
330+
status_code=404,
331+
detail=f"Document not found in run: {sorted(missing)[0]}",
332+
)
320333
pages_by_document = {
321334
doc["document_id"]: await db.list_pages(run_id, doc["document_id"])
322335
for doc in documents
@@ -325,13 +338,29 @@ async def download_ocr_pairs(
325338
doc["document_id"]: await db.get_document(doc["document_id"]) or {}
326339
for doc in documents
327340
}
328-
exporter = OCRPairExporter(settings.runs_dir / run_id / "dataset" / "ocr_pairs")
341+
scope = "all"
342+
if selected_ids:
343+
scope = hashlib.sha256(
344+
",".join(sorted(selected_ids)).encode("utf-8")
345+
).hexdigest()[:12]
346+
export_dir = (
347+
settings.runs_dir / run_id / "dataset" / f"ocr_pairs_{text_mode}_{dpi}_{scope}"
348+
)
349+
cached_bundle = export_dir.with_suffix(".zip")
350+
if cached_bundle.exists():
351+
return FileResponse(
352+
cached_bundle,
353+
media_type="application/zip",
354+
filename=f"{run_id}-ocr-pairs.zip",
355+
)
356+
exporter = OCRPairExporter(export_dir)
329357
result = await asyncio.to_thread(
330358
exporter.export_run,
331359
run=run,
332360
documents=documents,
333361
pages_by_document=pages_by_document,
334362
catalog_by_document=catalog_by_document,
363+
document_ids=selected_ids,
335364
dpi=dpi,
336365
text_mode=text_mode,
337366
)

ocr_pipeline/routers/ui.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Static-friendly endpoints for input file management. Output/dataset listing
22
moved to /api/runs."""
33

4+
import hashlib
45
from pathlib import Path
56

67
from fastapi import APIRouter, HTTPException, UploadFile
@@ -25,12 +26,18 @@ async def upload_pdf(file: UploadFile):
2526
raise HTTPException(status_code=400, detail="Invalid filename")
2627

2728
settings.input_dir.mkdir(parents=True, exist_ok=True)
28-
dest = settings.input_dir / safe_name
2929
content = await file.read()
30+
digest = hashlib.sha256(content).hexdigest()
31+
dest = settings.input_dir / f"{digest[:16]}__{safe_name}"
3032
dest.write_bytes(content)
3133
await catalog_pdf(get_db(), dest, filename=safe_name)
3234

33-
return {"filename": safe_name, "size": len(content), "path": str(dest)}
35+
return {
36+
"filename": safe_name,
37+
"stored_filename": dest.name,
38+
"size": len(content),
39+
"path": str(dest),
40+
}
3441

3542

3643
@router.get("/api/files/input", response_model=list[FileInfo])
@@ -40,13 +47,17 @@ async def list_input_files():
4047
if not input_dir.exists():
4148
return []
4249

50+
documents_by_path = {
51+
doc["source_path"]: doc for doc in await get_db().list_documents(limit=1000)
52+
}
4353
files = []
4454
for p in sorted(input_dir.iterdir()):
4555
if p.is_file() and p.suffix.lower() == ".pdf":
4656
stat = p.stat()
57+
document = documents_by_path.get(str(p))
4758
files.append(
4859
FileInfo(
49-
name=p.name,
60+
name=document["filename"] if document else p.name,
5061
size=stat.st_size,
5162
modified=stat.st_mtime,
5263
path=str(p),

ocr_pipeline/services/db.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,40 @@ async def update_document_metadata(
431431
raise KeyError(document_id)
432432
return doc
433433

434+
async def update_documents_metadata(
435+
self, document_ids: list[str], **fields: Any
436+
) -> list[dict[str, Any]]:
437+
if not document_ids:
438+
return []
439+
clean = {k: v for k, v in fields.items() if k in DOCUMENT_METADATA_FIELDS}
440+
placeholders = ", ".join("?" for _ in document_ids)
441+
async with self.conn.execute(
442+
f"SELECT id FROM documents WHERE id IN ({placeholders})",
443+
document_ids,
444+
) as cur:
445+
existing = {row["id"] for row in await cur.fetchall()}
446+
missing = [
447+
document_id for document_id in document_ids if document_id not in existing
448+
]
449+
if missing:
450+
raise KeyError(missing[0])
451+
if clean:
452+
clean["catalog_updated_at"] = _now()
453+
cols = ", ".join(f"{k} = ?" for k in clean)
454+
values = [*clean.values()]
455+
for document_id in document_ids:
456+
await self.conn.execute(
457+
f"UPDATE documents SET {cols} WHERE id = ?",
458+
[*values, document_id],
459+
)
460+
await self.conn.commit()
461+
docs = []
462+
for document_id in document_ids:
463+
doc = await self.get_document(document_id)
464+
if doc:
465+
docs.append(doc)
466+
return docs
467+
434468
async def list_document_runs(self, document_id: str) -> list[dict[str, Any]]:
435469
async with self.conn.execute(
436470
"""

ocr_pipeline/services/ocr_pair_exporter.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import hashlib
22
import json
33
import shutil
4+
import tempfile
45
import zipfile
56
from dataclasses import dataclass
67
from pathlib import Path
@@ -45,6 +46,8 @@ def _split_name(stable_key: str) -> str:
4546

4647
@staticmethod
4748
def _json_list(raw: str | None) -> list[str]:
49+
if isinstance(raw, list):
50+
return [str(item) for item in raw]
4851
if not raw:
4952
return []
5053
try:
@@ -68,15 +71,19 @@ def export_run(
6871
documents: list[dict],
6972
pages_by_document: dict[str, list[dict]],
7073
catalog_by_document: dict[str, dict],
74+
document_ids: set[str] | None = None,
7175
dpi: int = 160,
7276
text_mode: str = "clean",
7377
) -> OCRPairExportResult:
7478
if text_mode not in {"clean", "raw"}:
7579
raise ValueError("text_mode must be clean or raw")
7680

77-
if self.export_dir.exists():
78-
shutil.rmtree(self.export_dir)
79-
images_dir = self.export_dir / "images"
81+
tmp_parent = self.export_dir.parent
82+
tmp_parent.mkdir(parents=True, exist_ok=True)
83+
tmp_path = Path(
84+
tempfile.mkdtemp(prefix=f"{self.export_dir.name}.", dir=tmp_parent)
85+
)
86+
images_dir = tmp_path / "images"
8087
images_dir.mkdir(parents=True, exist_ok=True)
8188

8289
split_rows: dict[str, list[dict]] = {"train": [], "validation": [], "test": []}
@@ -86,6 +93,8 @@ def export_run(
8693
if doc.get("status") != "completed":
8794
continue
8895
document_id = doc["document_id"]
96+
if document_ids is not None and document_id not in document_ids:
97+
continue
8998
catalog = catalog_by_document.get(document_id, {})
9099
pdf_path_str = doc.get("artifact_source_pdf") or doc.get(
91100
"document_source_path"
@@ -121,16 +130,24 @@ def export_run(
121130
raw_text = raw_pages[page_num - 1]
122131
clean_text = clean_pages[page_num - 1]
123132
text = clean_text if text_mode == "clean" else raw_text
124-
split = self._split_name(page_id)
133+
split_key = doc.get("file_sha256") or document_id
134+
split = self._split_name(split_key)
135+
image_path = images_dir / f"{page_id}.png"
136+
image_hash = hashlib.sha256(image_path.read_bytes()).hexdigest()
125137

126138
split_rows[split].append(
127139
{
128140
"id": page_id,
141+
"run_id": run["id"],
129142
"image": image_rel,
130143
"text": text,
131144
"raw_text": raw_text,
132145
"clean_text": clean_text,
133146
"text_mode": text_mode,
147+
"label_source": "cleaned_machine_ocr"
148+
if text_mode == "clean"
149+
else "machine_ocr",
150+
"review_status": "unreviewed",
134151
"document_id": document_id,
135152
"document_name": doc.get("document_filename"),
136153
"page": page_num,
@@ -155,6 +172,10 @@ def export_run(
155172
"extraction_mode": page_meta.get("extraction_mode"),
156173
"extraction_attempt": page_meta.get("extraction_attempt"),
157174
"dpi_used": page_meta.get("dpi_used"),
175+
"render_dpi": dpi,
176+
"image_width": image.width,
177+
"image_height": image.height,
178+
"image_sha256": image_hash,
158179
"source_file": doc.get("document_filename"),
159180
"source_pdf_sha256": doc.get("file_sha256"),
160181
"ocr_model": run.get("model_used"),
@@ -163,8 +184,11 @@ def export_run(
163184
)
164185
pages_count += 1
165186

166-
self._write_jsonl(split_rows)
167-
self._write_manifest(run, pages_count, dpi, text_mode)
187+
self._write_jsonl(tmp_path, split_rows)
188+
self._write_manifest(tmp_path, run, pages_count, dpi, text_mode)
189+
if self.export_dir.exists():
190+
shutil.rmtree(self.export_dir)
191+
tmp_path.replace(self.export_dir)
168192
bundle = self.export_dir.with_suffix(".zip")
169193
if bundle.exists():
170194
bundle.unlink()
@@ -181,16 +205,16 @@ def _read_text(path_str: str | None) -> str:
181205
path = Path(path_str)
182206
return path.read_text(encoding="utf-8") if path.exists() else ""
183207

184-
def _write_jsonl(self, split_rows: dict[str, list[dict]]) -> None:
208+
def _write_jsonl(self, export_dir: Path, split_rows: dict[str, list[dict]]) -> None:
185209
for split, rows in split_rows.items():
186-
path = self.export_dir / f"{split}.jsonl"
210+
path = export_dir / f"{split}.jsonl"
187211
path.write_text(
188212
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
189213
encoding="utf-8",
190214
)
191215

192216
def _write_manifest(
193-
self, run: dict, pages_count: int, dpi: int, text_mode: str
217+
self, export_dir: Path, run: dict, pages_count: int, dpi: int, text_mode: str
194218
) -> None:
195219
payload = {
196220
"export_type": "ocr_pairs",
@@ -200,13 +224,23 @@ def _write_manifest(
200224
"image_format": "png",
201225
"dpi": dpi,
202226
"text_mode": text_mode,
227+
"dataset_purpose": "ocr_audit",
228+
"label_source": "cleaned_machine_ocr"
229+
if text_mode == "clean"
230+
else "machine_ocr",
231+
"review_status": "unreviewed",
203232
"schema_version": 1,
233+
"split_strategy": {
234+
"method": "sha256_bucket",
235+
"key": "source_pdf_sha256",
236+
"ratios": {"train": 0.90, "validation": 0.05, "test": 0.05},
237+
},
204238
"ocr_model": run.get("model_used") or settings.model_name,
205239
"pipeline_version": run.get("pipeline_version")
206240
or settings.pipeline_version,
207241
"splits": ["train", "validation", "test"],
208242
}
209-
(self.export_dir / "manifest.json").write_text(
243+
(export_dir / "manifest.json").write_text(
210244
json.dumps(payload, indent=2, ensure_ascii=False),
211245
encoding="utf-8",
212246
)

ocr_pipeline/static/index.html

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ <h2>Documents</h2>
170170
<span class="pill pill-sm"
171171
:class="doc.metadata_complete ? 'pill-success' : 'pill-warn'"
172172
x-text="doc.metadata_complete ? 'ready' : 'missing'"></span>
173-
<span x-text="doc.latest_run_status || 'never'"></span>
173+
<span class="pill pill-sm"
174+
:class="documentProcessClass(doc)"
175+
x-text="documentProcessLabel(doc)"></span>
174176
</div>
175177
</template>
176178
</div>
@@ -258,7 +260,7 @@ <h2>Run <code x-text="selectedRun?.id"></code></h2>
258260
↓ Dataset bundle (zip)
259261
</button>
260262
<button class="btn btn-ghost" @click="downloadOCRPairs">
261-
↓ OCR pairs (zip)
263+
<span x-text="selectedRunDocumentIds.length ? `↓ OCR pairs (${selectedRunDocumentIds.length})` : '↓ OCR pairs (zip)'"></span>
262264
</button>
263265
<button class="btn btn-primary"
264266
@click="openHFModal"
@@ -280,7 +282,12 @@ <h3 class="section-title">Documents</h3>
280282
:class="{ active: inspector.documentId === doc.document_id }"
281283
@click="openDocument(doc.document_id)">
282284
<div class="doc-head">
283-
<span class="doc-name" x-text="doc.filename"></span>
285+
<label class="checkbox-row" @click.stop>
286+
<input type="checkbox"
287+
:checked="selectedRunDocumentIds.includes(doc.document_id)"
288+
@change="toggleRunDocument(doc.document_id)">
289+
<span class="doc-name" x-text="doc.filename"></span>
290+
</label>
284291
<span class="pill pill-sm"
285292
:class="runStatusClass(doc.status)"
286293
x-text="doc.status"></span>

ocr_pipeline/static/js/api.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ const API = {
143143
return `/api/runs/${encodeURIComponent(runId)}/dataset/download`;
144144
},
145145

146-
ocrPairsDownloadUrl(runId, { dpi = 160, textMode = 'clean' } = {}) {
147-
return `/api/runs/${encodeURIComponent(runId)}/ocr-pairs/download?dpi=${dpi}&text_mode=${encodeURIComponent(textMode)}`;
146+
ocrPairsDownloadUrl(runId, { dpi = 160, textMode = 'clean', documentIds = [] } = {}) {
147+
const params = new URLSearchParams({ dpi: String(dpi), text_mode: textMode });
148+
if (documentIds.length > 0) params.set('document_ids', documentIds.join(','));
149+
return `/api/runs/${encodeURIComponent(runId)}/ocr-pairs/download?${params.toString()}`;
148150
},
149151

150152
async publishToHF(runId, payload) {

0 commit comments

Comments
 (0)