Skip to content

Commit 2104d66

Browse files
committed
feat: add dependency caching for local OCR engine, enhance observability logging, and improve startup configuration
1 parent b7e998c commit 2104d66

10 files changed

Lines changed: 134 additions & 21 deletions

File tree

ocr_pipeline/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ ENV PYTHONPATH=/app
2020

2121
EXPOSE 39672
2222

23-
CMD ["uvicorn", "ocr_pipeline.main:app", "--host", "0.0.0.0", "--port", "39672"]
23+
CMD ["uvicorn", "ocr_pipeline.main:app", "--host", "0.0.0.0", "--port", "39672", "--no-access-log"]

ocr_pipeline/Dockerfile.cpu

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@ ENV LOCAL_DEVICE=cpu
3131

3232
EXPOSE 39672
3333

34-
CMD ["uvicorn", "ocr_pipeline.main:app", "--host", "0.0.0.0", "--port", "39672"]
34+
CMD ["uvicorn", "ocr_pipeline.main:app", "--host", "0.0.0.0", "--port", "39672", "--no-access-log"]

ocr_pipeline/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class Settings(BaseSettings):
5959
# Server
6060
host: str = "0.0.0.0"
6161
port: int = 39672
62+
log_level: str = "INFO"
6263

6364
# Pipeline
6465
pipeline_version: str = "2.0.0"

ocr_pipeline/main.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@
2525
from ocr_pipeline.services.startup import wait_for_model_server
2626

2727
logging.basicConfig(
28-
level=logging.INFO,
29-
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
28+
level=getattr(logging, settings.log_level.upper(), logging.INFO),
29+
format="%(asctime)s %(levelname)-7s %(name)s :: %(message)s",
3030
)
31+
for noisy_logger in ("httpx", "httpcore", "urllib3", "huggingface_hub"):
32+
logging.getLogger(noisy_logger).setLevel(logging.WARNING)
3133
logger = logging.getLogger("ocr_pipeline")
3234

3335

ocr_pipeline/services/local_ocr_engine.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -97,29 +97,41 @@ def __init__(self, model_name: str | None = None) -> None:
9797
self._device: str | None = None
9898
self._dtype: Any = None
9999
self._lock = asyncio.Lock()
100+
self._load_error: BaseException | None = None
100101
self._initialized = True
101102

102103
async def _ensure_loaded(self) -> None:
103104
if self._model is not None:
104105
return
106+
if self._load_error is not None:
107+
raise RuntimeError("Local OCR engine failed to load") from self._load_error
105108
async with self._lock:
106109
if self._model is not None:
107110
return
108-
await asyncio.to_thread(self._load_blocking)
111+
if self._load_error is not None:
112+
raise RuntimeError(
113+
"Local OCR engine failed to load"
114+
) from self._load_error
115+
try:
116+
await asyncio.to_thread(self._load_blocking)
117+
except Exception as exc:
118+
self._load_error = exc
119+
logger.error("Local OCR engine failed to load: %s", exc)
120+
raise
109121

110122
def _load_blocking(self) -> None:
111-
if find_spec("torch") is None:
123+
missing = [
124+
package
125+
for package in ("torch", "transformers", "easydict")
126+
if find_spec(package) is None
127+
]
128+
if missing:
112129
raise RuntimeError(
113-
"MODEL_BACKEND=local requires `transformers` and `torch`. "
114-
"Install with: pip install -r requirements-local.txt"
130+
"MODEL_BACKEND=local missing package(s): "
131+
f"{', '.join(missing)}. Install with: "
132+
"pip install -r ocr_pipeline/requirements.txt -r requirements-local.txt"
115133
)
116-
try:
117-
from transformers import AutoModel, AutoTokenizer
118-
except ImportError as exc:
119-
raise RuntimeError(
120-
"MODEL_BACKEND=local requires `transformers` and `torch`. "
121-
"Install with: pip install -r requirements-local.txt"
122-
) from exc
134+
from transformers import AutoModel, AutoTokenizer
123135

124136
device = _resolve_device(settings.local_device)
125137
dtype = _resolve_dtype(settings.local_dtype, device)

ocr_pipeline/services/run_orchestrator.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ async def create_run(
133133
for s in staged:
134134
await self.db.link_run_document(run_id, s.document_id, status="pending")
135135

136+
logger.info(
137+
"run=%s queued docs=%d pages=%d name=%s",
138+
run_id,
139+
len(staged),
140+
pages_total,
141+
name or "-",
142+
)
136143
observability.job_created()
137144
return CreateRunResult(
138145
run_id=run_id, documents=staged, pages_total_estimate=pages_total
@@ -167,6 +174,7 @@ async def retry_incomplete_run(self, run_id: str) -> CreateRunResult:
167174
raise ValueError("No incomplete documents to retry")
168175

169176
name = run.get("name") or run_id
177+
logger.info("run=%s retry queued incomplete_docs=%d", run_id, len(retry_paths))
170178
result = await self.create_run(
171179
retry_paths,
172180
name=f"{name} retry",
@@ -189,15 +197,20 @@ async def _run(
189197
) -> None:
190198
run_id = result.run_id
191199
started_at = _now()
200+
pages_total = result.pages_total_estimate
201+
pages_completed = 0
202+
documents_meta: list = []
192203
await self.db.update_run(
193204
run_id, status="processing", stage="ocr", started_at=started_at
194205
)
206+
logger.info(
207+
"run=%s started docs=%d pages=%d",
208+
run_id,
209+
len(result.documents),
210+
pages_total,
211+
)
195212
await self._emit(run_id, "run_started", {"started_at": started_at})
196213

197-
pages_total = result.pages_total_estimate
198-
pages_completed = 0
199-
documents_meta: list = []
200-
201214
async def page_event(event: dict) -> None:
202215
nonlocal pages_completed
203216
etype = event.get("type")
@@ -214,15 +227,39 @@ async def page_event(event: dict) -> None:
214227
token_count=event.get("token_count", 0),
215228
validation_status=event.get("validation_status", "pass"),
216229
)
230+
logger.info(
231+
"run=%s page=%d/%d doc=%s status=%s time=%.1fms",
232+
run_id,
233+
pages_completed,
234+
pages_total,
235+
event.get("document"),
236+
event.get("validation_status"),
237+
event.get("processing_time_ms", 0.0),
238+
)
217239
elif etype == "page_retry":
218240
observability.page_retry()
241+
logger.info(
242+
"run=%s page=%s retry attempt=%s strategy=%s reason=%s",
243+
run_id,
244+
event.get("page"),
245+
event.get("attempt"),
246+
event.get("new_strategy"),
247+
event.get("reason"),
248+
)
219249
await self._emit(run_id, etype, event)
220250

221251
try:
222-
for staged in result.documents:
252+
for index, staged in enumerate(result.documents, start=1):
223253
paths = self.storage.artifact_paths(
224254
run_id, staged.document_id, staged.filename
225255
)
256+
logger.info(
257+
"run=%s doc=%d/%d started %s",
258+
run_id,
259+
index,
260+
len(result.documents),
261+
staged.filename,
262+
)
226263
processor = BatchProcessor(
227264
self.db, event_callback=page_event, strip_refs=strip_refs
228265
)
@@ -238,6 +275,16 @@ async def page_event(event: dict) -> None:
238275
await self.db.update_run(
239276
run_id, documents_completed=len(documents_meta)
240277
)
278+
logger.info(
279+
"run=%s doc=%d/%d completed %s pass=%d warn=%d fail=%d",
280+
run_id,
281+
index,
282+
len(result.documents),
283+
staged.filename,
284+
doc_meta.pages_pass,
285+
doc_meta.pages_warn,
286+
doc_meta.pages_fail,
287+
)
241288

242289
dataset_bundle = await self._maybe_export(
243290
run_id, documents_meta, export_parquet
@@ -253,6 +300,7 @@ async def page_event(event: dict) -> None:
253300
dataset_bundle=dataset_bundle,
254301
completed_at=completed_at,
255302
)
303+
logger.info("run=%s completed bundle=%s", run_id, dataset_bundle or "-")
256304
observability.job_completed()
257305
await self._emit(
258306
run_id,
@@ -285,6 +333,7 @@ async def _maybe_export(
285333
if not (export_parquet and documents_meta):
286334
return None
287335
await self.db.update_run(run_id, stage="exporting")
336+
logger.info("run=%s exporting dataset docs=%d", run_id, len(documents_meta))
288337
await self._emit(run_id, "dataset_export_started", {})
289338
exports = []
290339
for did, paths, meta in documents_meta:

requirements-local.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ transformers>=4.46.0
1010
accelerate>=0.34.0
1111
einops>=0.8.0
1212
sentencepiece>=0.2.0
13+
easydict>=1.13

scripts/start.sh

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,20 @@ mkdir -p "$INPUT_DIR" "$OUTPUT_DIR"
3131
export PYTHONPATH="${PYTHONPATH:-}:$(pwd)"
3232
PORT="${PORT:-39672}"
3333
HOST="${HOST:-0.0.0.0}"
34+
if [[ -z "${PYTHON_BIN:-}" && -x ".venv/bin/python" ]]; then
35+
PYTHON_BIN=".venv/bin/python"
36+
else
37+
PYTHON_BIN="${PYTHON_BIN:-python3}"
38+
fi
3439

3540
echo "→ OpenCR backend=$MODEL_BACKEND http://$HOST:$PORT"
3641
echo " input=$INPUT_DIR"
3742
echo " output=$OUTPUT_DIR"
43+
echo " python=$PYTHON_BIN"
44+
45+
UVICORN_ARGS=()
46+
if [[ "${ACCESS_LOG:-0}" != "1" ]]; then
47+
UVICORN_ARGS+=(--no-access-log)
48+
fi
3849

39-
exec python3 -m uvicorn ocr_pipeline.main:app --host "$HOST" --port "$PORT" "$@"
50+
exec "$PYTHON_BIN" -m uvicorn ocr_pipeline.main:app --host "$HOST" --port "$PORT" "${UVICORN_ARGS[@]}" "$@"

tests/test_local_ocr_engine.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import asyncio
2+
3+
from ocr_pipeline.services.local_ocr_engine import LocalOCREngine
4+
5+
6+
def test_local_engine_caches_load_failure(monkeypatch):
7+
async def _scenario():
8+
LocalOCREngine._instance = None
9+
engine = LocalOCREngine()
10+
calls = 0
11+
12+
def fail_load():
13+
nonlocal calls
14+
calls += 1
15+
raise RuntimeError("missing dependency")
16+
17+
monkeypatch.setattr(engine, "_load_blocking", fail_load)
18+
19+
for _ in range(2):
20+
try:
21+
await engine._ensure_loaded()
22+
except RuntimeError:
23+
pass
24+
else:
25+
raise AssertionError("expected load failure")
26+
27+
assert calls == 1
28+
29+
asyncio.run(_scenario())

tests/test_requirements.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from pathlib import Path
2+
3+
4+
def test_local_requirements_include_deepseek_remote_code_dependencies():
5+
requirements = (Path(__file__).parents[1] / "requirements-local.txt").read_text(
6+
encoding="utf-8"
7+
)
8+
assert "easydict" in requirements

0 commit comments

Comments
 (0)