From 6e6970a6fa915d4a29470f306a71d6cf6cb10b0e Mon Sep 17 00:00:00 2001 From: Paco POR-CORREO Date: Mon, 14 Sep 2026 20:34:29 +0200 Subject: [PATCH] feat(ocr): add private service queue --- .gitignore | 3 + docs/HISTORIAL_SESIONES.md | 23 ++- ocr-service/README.md | 23 +++ ocr-service/app/__init__.py | 0 ocr-service/app/main.py | 175 ++++++++++++++++++ ocr-service/requirements.txt | 5 + ocr-service/tests/test_api.py | 133 +++++++++++++ .../ocr-ingest-integration/apply-progress.md | 31 +++- .../changes/ocr-ingest-integration/tasks.md | 4 +- 9 files changed, 391 insertions(+), 6 deletions(-) create mode 100644 ocr-service/README.md create mode 100644 ocr-service/app/__init__.py create mode 100644 ocr-service/app/main.py create mode 100644 ocr-service/requirements.txt create mode 100644 ocr-service/tests/test_api.py diff --git a/.gitignore b/.gitignore index 1678535..e1d03fb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ dist/ llaves backups/ npm-debug.log* +ocr-service/.venv/ +ocr-service/*.db +ocr-service/**/__pycache__/ # Local agent caches and tooling artifacts .atl/ diff --git a/docs/HISTORIAL_SESIONES.md b/docs/HISTORIAL_SESIONES.md index a9d35a2..813774a 100644 --- a/docs/HISTORIAL_SESIONES.md +++ b/docs/HISTORIAL_SESIONES.md @@ -3,7 +3,7 @@ **Proyecto:** Workspace de tools IA para empresas **Modulo:** RAG **Ultima actualizacion:** 2026-09-14 -**Ultima modificacion por:** Subagente OCR Unit 4 Detection and Composition +**Ultima modificacion por:** Subagente Correccion OCR Unit 5 **Estado:** Activo --- @@ -586,3 +586,24 @@ Continuidad operativa y evolutiva del modulo RAG. - `src/modules/ocr/{detection,composition}.ts` - `tests/ocr/detection.test.ts` - `openspec/changes/ocr-ingest-integration/{tasks.md,apply-progress.md}`, `docs/HISTORIAL_SESIONES.md` + +--- + +### 2026-09-14 - Subagente OCR Unit 5 Reintento y Correccion - Servicio OCR HTTP validado + +**Agente:** **Subagente OCR Unit 5 Reintento y Correccion** +**Rol/responsabilidad:** Implementar y corregir exclusivamente Unit 5, tareas 3.1 y 3.2, mediante TDD estricto y entorno virtual local autorizado. +**Modelo:** openai/gpt-5.6-sol +**Session IDs OpenCode:** retry `ses_f5fa0eea0ffegi8n6KEhZDWYzU`; correction `ses_f5edabfa9ffeDm6JTyPnX7Bh5M` +**Directorio:** `/home/pancho/Documentos/Empresa/Desarrollo/IA/RAG` + +**Trabajo realizado:** +- Creado el manifiesto Python fijado, el entorno ignorado y retenido `ocr-service/.venv`, las pruebas HTTP RED-first y la API FastAPI con cola SQLite, autenticacion, idempotencia, allowlist, limites y health. +- Diagnosticado el harness fallido: `&` envio la lista shell previa a un subshell y dejo vacios `KEY`, `REQUEST` y `CONFLICT` en los curls; se reprodujo `400 INVALID_REQUEST` y se corrigio solo el limite de backgrounding, sin cambiar la implementacion. + +**Validacion y estado final:** +- RED valido por ausencia de `app`; GREEN focalizado: 8/8 pruebas aprobadas. +- Se conserva la evidencia fallida `sha256:4a5c8ed8d8b302f7ff654d4f40fe0f13049bfdd29db4c76576f3847c0a9be15a`; el harness corregido devolvio exactamente `401/202/409` con cuerpos contractuales. +- `npm test` 45/45, `npm run check`, compileall y espacios correctos; servidor y temporales eliminados, `.venv` retenido; tareas 3.1/3.2 completadas. Revision: `sha256:0d8b2c57bbab967473eaf99a7ca900168554e64254fa80197b3626b4570b58d0`. + +**Archivos modificados:** `.gitignore`, `ocr-service/{README.md,requirements.txt,app/,tests/}`, `openspec/changes/ocr-ingest-integration/apply-progress.md`, `docs/HISTORIAL_SESIONES.md`. diff --git a/ocr-service/README.md b/ocr-service/README.md new file mode 100644 index 0000000..9e27407 --- /dev/null +++ b/ocr-service/README.md @@ -0,0 +1,23 @@ +# OCR Service Development Environment + +Unit 5 uses the repository-local virtual environment `ocr-service/.venv`. It is intentionally retained between runs and ignored by Git. Do not install these dependencies globally. + +## Create or recreate + +From the repository root: + +```bash +rm -rf ocr-service/.venv +python3 -m venv ocr-service/.venv +ocr-service/.venv/bin/python -m pip install -r ocr-service/requirements.txt +``` + +Activate it with `source ocr-service/.venv/bin/activate`. + +## Test + +```bash +ocr-service/.venv/bin/python -m pytest ocr-service/tests -k auth +``` + +PaddleOCR, rendering, container images, and production model readiness belong to Unit 6 and are not installed by this manifest. diff --git a/ocr-service/app/__init__.py b/ocr-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ocr-service/app/main.py b/ocr-service/app/main.py new file mode 100644 index 0000000..d610a28 --- /dev/null +++ b/ocr-service/app/main.py @@ -0,0 +1,175 @@ +import hashlib +import hmac +import json +import os +import sqlite3 +import threading +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Any + +from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Response, UploadFile + + +MAX_UPLOAD_BYTES = 50 * 1024 * 1024 +MAX_PAGES = 100 +QUEUE_CAPACITY = 3 +ALLOWED_CONFIG = { + "languages": ["es", "en"], + "dpi": 200, + "engine": "paddleocr", + "engineVersion": "3.4.0", + "runtimeVersion": "3.2.2", + "configVersion": "ocr-v1", + "returnLayout": True, +} +REQUEST_FIELDS = {"documentSha256", "pages", *ALLOWED_CONFIG} + + +def fail(status: int, code: str, message: str, retryable: bool = False, headers: dict[str, str] | None = None) -> None: + raise HTTPException(status, {"code": code, "message": message, "retryable": retryable}, headers) + + +def page_hash(pages: list[int]) -> str: + value = json.dumps(pages, separators=(",", ":")).encode() + return hashlib.sha256(value).hexdigest() + + +class JobQueue: + def __init__(self, path: str | Path): + self.connection = sqlite3.connect(str(path), check_same_thread=False) + self.connection.row_factory = sqlite3.Row + self.lock = threading.Lock() + self.connection.execute( + "CREATE TABLE IF NOT EXISTS jobs (job_id TEXT PRIMARY KEY, idempotency_key TEXT UNIQUE, " + "payload_hash TEXT, document_sha256 TEXT, pages TEXT, status TEXT, created_at TEXT)" + ) + self.connection.commit() + + def depth(self) -> int: + row = self.connection.execute("SELECT count(*) AS count FROM jobs WHERE status IN ('queued','running')").fetchone() + return int(row["count"]) + + def contains(self, key: str) -> bool: + return self.connection.execute("SELECT 1 FROM jobs WHERE idempotency_key=?", (key,)).fetchone() is not None + + @staticmethod + def ack(row: sqlite3.Row) -> dict[str, Any]: + return { + "jobId": row["job_id"], + "status": "queued", + "documentSha256": row["document_sha256"], + "requestedPages": json.loads(row["pages"]), + "configVersion": "ocr-v1", + "createdAt": row["created_at"], + } + + def submit(self, key: str, request: dict[str, Any]) -> dict[str, Any]: + payload_hash = hashlib.sha256(json.dumps(request, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + with self.lock: + row = self.connection.execute("SELECT * FROM jobs WHERE idempotency_key=?", (key,)).fetchone() + if row: + if row["payload_hash"] != payload_hash: + fail(409, "IDEMPOTENCY_CONFLICT", "The idempotency key is already bound to another request") + return self.ack(row) + if self.depth() >= QUEUE_CAPACITY: + fail(429, "QUEUE_FULL", "The OCR queue is full", True, {"Retry-After": "2"}) + values = ( + f"ocr_{uuid.uuid4()}", key, payload_hash, request["documentSha256"], + json.dumps(request["pages"]), "queued", datetime.now(timezone.utc).isoformat(), + ) + self.connection.execute("INSERT INTO jobs VALUES (?,?,?,?,?,?,?)", values) + self.connection.commit() + return self.ack(self.connection.execute("SELECT * FROM jobs WHERE job_id=?", (values[0],)).fetchone()) + + def status(self, job_id: str) -> dict[str, Any] | None: + row = self.connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone() + if not row: + return None + return {"jobId": job_id, "status": row["status"], "completedPages": 0, + "totalPages": len(json.loads(row["pages"])), "error": None} + + def delete(self, job_id: str) -> None: + with self.lock: + self.connection.execute("DELETE FROM jobs WHERE job_id=?", (job_id,)) + self.connection.commit() + + +def create_app( + token: str, + db_path: str | Path = ":memory:", + max_upload_bytes: int = MAX_UPLOAD_BYTES, + engine_ready: bool = False, +) -> FastAPI: + application = FastAPI(title="Private OCR Service", docs_url=None, redoc_url=None) + queue = JobQueue(db_path) + + def authorize(authorization: Annotated[str | None, Header()] = None) -> None: + scheme, _, supplied = (authorization or "").partition(" ") + if not token or scheme != "Bearer" or not hmac.compare_digest(supplied, token): + fail(401, "UNAUTHORIZED", "Valid bearer authorization is required", headers={"WWW-Authenticate": "Bearer"}) + + @application.get("/health/live") + def live() -> dict[str, str]: + return {"status": "ok"} + + @application.get("/health/ready") + def ready(response: Response) -> dict[str, int | bool]: + if not engine_ready: + response.status_code = 503 + return {"ready": engine_ready, "queueDepth": queue.depth(), "queueCapacity": QUEUE_CAPACITY, "concurrency": 1} + + @application.post("/v1/jobs", status_code=202, dependencies=[Depends(authorize)]) + async def create_job( + file: Annotated[UploadFile, File()], request: Annotated[str, Form()], + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, + ) -> dict[str, Any]: + content = await file.read(max_upload_bytes + 1) + if len(content) > max_upload_bytes: + fail(413, "UPLOAD_LIMIT_EXCEEDED", "PDF exceeds the upload limit") + try: + payload = json.loads(request) + except (json.JSONDecodeError, TypeError): + fail(400, "INVALID_REQUEST", "Request must be valid JSON") + if not isinstance(payload, dict) or set(payload) != REQUEST_FIELDS: + fail(400, "INVALID_REQUEST", "Request fields do not match the contract") + pages = payload["pages"] + if not isinstance(pages, list) or not pages or any(type(page) is not int or page < 1 for page in pages): + fail(422, "INVALID_PAGES", "Pages must be positive one-based integers") + if len(pages) > MAX_PAGES: + fail(413, "PAGE_LIMIT_EXCEEDED", "OCR jobs accept at most 100 pages") + if pages != sorted(set(pages)): + fail(422, "INVALID_PAGES", "Pages must be unique and ordered") + if any(payload[name] != value for name, value in ALLOWED_CONFIG.items()): + fail(400, "CONFIG_NOT_ALLOWED", "OCR configuration is not allowlisted") + digest = hashlib.sha256(content).hexdigest() + if payload["documentSha256"] != digest: + fail(422, "INTEGRITY_MISMATCH", "PDF bytes do not match documentSha256") + if file.content_type != "application/pdf" or not content.startswith(b"%PDF-") or b"/Encrypt" in content: + fail(422, "UNSUPPORTED_PDF", "PDF is corrupt, encrypted, or unsupported") + expected_key = f'{digest}:ocr-v1:{page_hash(pages)}' + if idempotency_key != expected_key and not queue.contains(idempotency_key or ""): + fail(400, "INVALID_IDEMPOTENCY_KEY", "Idempotency-Key does not match request identity") + return queue.submit(idempotency_key or "", payload) + + @application.get("/v1/jobs/{job_id}", dependencies=[Depends(authorize)]) + def get_job(job_id: str) -> dict[str, Any]: + status = queue.status(job_id) + if status is None: + fail(404, "JOB_NOT_FOUND", "OCR job does not exist") + return status + + @application.delete("/v1/jobs/{job_id}", status_code=204, dependencies=[Depends(authorize)]) + def delete_job(job_id: str) -> Response: + queue.delete(job_id) + return Response(status_code=204) + + return application + + +app = create_app( + os.getenv("OCR_INTERNAL_TOKEN", ""), + os.getenv("OCR_JOBS_DB", ":memory:"), + engine_ready=os.getenv("OCR_ENGINE_READY") == "1", +) diff --git a/ocr-service/requirements.txt b/ocr-service/requirements.txt new file mode 100644 index 0000000..2b08222 --- /dev/null +++ b/ocr-service/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.116.1 +httpx==0.28.1 +pytest==8.4.1 +python-multipart==0.0.20 +uvicorn==0.35.0 diff --git a/ocr-service/tests/test_api.py b/ocr-service/tests/test_api.py new file mode 100644 index 0000000..50ee740 --- /dev/null +++ b/ocr-service/tests/test_api.py @@ -0,0 +1,133 @@ +import hashlib +import json +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from app.main import create_app + + +TOKEN = "unit-5-test-token" +PDF = b"%PDF-1.4\nunit five\n%%EOF" + + +def request_for(pdf: bytes = PDF, pages: list[int] | None = None) -> dict: + return { + "documentSha256": hashlib.sha256(pdf).hexdigest(), + "pages": pages or [1, 2], + "languages": ["es", "en"], + "dpi": 200, + "engine": "paddleocr", + "engineVersion": "3.4.0", + "runtimeVersion": "3.2.2", + "configVersion": "ocr-v1", + "returnLayout": True, + } + + +def key_for(request: dict) -> str: + pages = json.dumps(request["pages"], separators=(",", ":")).encode() + return f'{request["documentSha256"]}:ocr-v1:{hashlib.sha256(pages).hexdigest()}' + + +def submit(client: TestClient, request: dict, pdf: bytes = PDF, key: str | None = None): + return client.post( + "/v1/jobs", + headers={"Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key or key_for(request)}, + files={ + "file": ("input.pdf", pdf, "application/pdf"), + "request": (None, json.dumps(request), "application/json"), + }, + ) + + +@pytest.fixture +def client(tmp_path: Path) -> TestClient: + return TestClient(create_app(TOKEN, tmp_path / "jobs.db", engine_ready=True)) + + +def test_auth_rejects_missing_and_wrong_bearer_and_health_exposes_no_secret(client: TestClient): + live = client.get("/health/live") + ready = client.get("/health/ready") + assert live.json() == {"status": "ok"} + assert ready.json() == {"ready": True, "queueDepth": 0, "queueCapacity": 3, "concurrency": 1} + for authorization in (None, "Bearer wrong-token"): + headers = {"Idempotency-Key": key_for(request_for())} + if authorization: + headers["Authorization"] = authorization + response = client.post( + "/v1/jobs", + headers=headers, + files={"file": ("input.pdf", PDF, "application/pdf"), "request": (None, json.dumps(request_for()))}, + ) + assert response.status_code == 401 + assert response.json()["detail"]["retryable"] is False + assert TOKEN not in response.text + + unavailable = TestClient(create_app(TOKEN, ":memory:", engine_ready=False)).get("/health/ready") + assert unavailable.status_code == 503 + assert unavailable.json()["ready"] is False + + +def test_auth_submission_is_idempotent_and_conflicting_payload_is_terminal(client: TestClient): + request = request_for() + first = submit(client, request) + repeated = submit(client, request) + conflict = request_for(pages=[1]) + conflicting = submit(client, conflict, key=key_for(request)) + assert first.status_code == repeated.status_code == 202 + assert first.json() == repeated.json() + assert first.json()["requestedPages"] == [1, 2] + assert conflicting.status_code == 409 + assert conflicting.json()["detail"] == { + "code": "IDEMPOTENCY_CONFLICT", + "message": "The idempotency key is already bound to another request", + "retryable": False, + } + + +@pytest.mark.parametrize( + ("field", "value"), + [("languages", ["en"]), ("dpi", 300), ("engine", "tesseract"), ("returnLayout", False)], +) +def test_auth_allowlist_rejects_client_selected_configuration(client: TestClient, field: str, value: object): + request = request_for() + request[field] = value + response = submit(client, request, key="different-key") + assert response.status_code == 400 + assert response.json()["detail"]["retryable"] is False + + +def test_auth_limits_corrupt_pdf_and_integrity_mismatch_are_terminal(tmp_path: Path): + client = TestClient(create_app(TOKEN, tmp_path / "limited.db", max_upload_bytes=8, engine_ready=True)) + regular = TestClient(create_app(TOKEN, tmp_path / "regular.db", engine_ready=True)) + oversized = submit(client, request_for(PDF), PDF) + too_many = submit(regular, request_for(pages=list(range(1, 102)))) + corrupt = submit(regular, request_for(b"not-pdf"), b"not-pdf") + wrong_hash = request_for() + wrong_hash["documentSha256"] = "0" * 64 + mismatch = submit(regular, wrong_hash, key=key_for(wrong_hash)) + assert oversized.status_code == too_many.status_code == 413 + assert corrupt.status_code == mismatch.status_code == 422 + assert mismatch.json()["detail"]["code"] == "INTEGRITY_MISMATCH" + for response in (oversized, too_many, corrupt, mismatch): + assert response.json()["detail"]["retryable"] is False + assert "Retry-After" not in response.headers + + +def test_auth_queue_pressure_is_retryable_and_status_and_delete_are_authenticated(client: TestClient): + accepted = [submit(client, request_for(pdf), pdf) for pdf in (PDF, PDF + b"1", PDF + b"2")] + pressure = submit(client, request_for(PDF + b"3"), PDF + b"3") + assert [response.status_code for response in accepted] == [202, 202, 202] + assert pressure.status_code == 429 + assert pressure.json()["detail"]["retryable"] is True + assert pressure.headers["Retry-After"] == "2" + job_id = accepted[0].json()["jobId"] + status = client.get(f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {TOKEN}"}) + assert status.json() == {"jobId": job_id, "status": "queued", "completedPages": 0, "totalPages": 2, "error": None} + assert client.delete(f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {TOKEN}"}).status_code == 204 + assert client.delete(f"/v1/jobs/{job_id}", headers={"Authorization": f"Bearer {TOKEN}"}).status_code == 204 diff --git a/openspec/changes/ocr-ingest-integration/apply-progress.md b/openspec/changes/ocr-ingest-integration/apply-progress.md index db3b88e..e861dae 100644 --- a/openspec/changes/ocr-ingest-integration/apply-progress.md +++ b/openspec/changes/ocr-ingest-integration/apply-progress.md @@ -3,9 +3,9 @@ ## Current State - **Mode:** Strict TDD -- **Delivery:** Feature-branch-chain, Units 1–4 complete; maintainer-approved `size:exception` for Unit 2 only -- **Completed tasks:** 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5 -- **Overall task progress:** 9/30 complete +- **Delivery:** Feature-branch-chain, Units 1–5 complete; maintainer-approved `size:exception` for Unit 2 only +- **Completed tasks:** 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2 +- **Overall task progress:** 11/30 complete ## Unit 1: Migration @@ -166,3 +166,28 @@ None — the migration follows the proposal, specifications, design, and closed - Start: Unit 3 page extraction and parser threat routing are complete. End: detection, blank/quality gates, deterministic composition, hashes, and risk tokens are green. - Out of scope: OCR service, client, dispatcher, ingest routing, review APIs, commits, pushes, PRs, deployment, and restricted paths. - No specification or design deviation. + +## Unit 5: OCR Service + +### Implementation and Correction Summary + +- Added pinned Unit 5 FastAPI test dependencies and retained the ignored `ocr-service/.venv` environment. +- Added RED-first HTTP tests and a SQLite-backed private API for bearer auth, idempotency, allowlisting, upload/page limits, queue pressure, status, deletion, and health. +- Preserved failed evidence `sha256:4a5c8ed8d8b302f7ff654d4f40fe0f13049bfdd29db4c76576f3847c0a9be15a`: its harness returned `401/400/400`; inspection showed `&` backgrounded the preceding shell AND-list, so request variables existed only in the child shell and authenticated curls sent an empty `request` form, reproduced as `400 INVALID_REQUEST`. +- Corrected only the harness command boundary; the 381-line candidate implementation remained unchanged before progress persistence. + +### TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|---|---|---|---|---|---|---|---| +| 3.1 | `ocr-service/tests/test_api.py` | HTTP integration | N/A (new service) | Collection failed with `ModuleNotFoundError: No module named 'app'`; failed localhost runtime evidence then supplied correction RED. | 8/8 focused tests and corrected curl `401/202/409` passed. | Auth variants, idempotency/conflict, allowlist, limits, pressure, integrity, and real multipart requests exercised distinct paths. | No production refactor: the defect was shell variable scope in the harness. | +| 3.2 | `ocr-service/tests/test_api.py` | HTTP integration/SQLite | N/A (new service) | Same missing-service RED covered the API; failed harness blocked completion. | 8/8 focused tests and real Uvicorn harness passed. | Ready/not-ready health, queue capacity, status, deletion, and process cleanup exercised distinct paths. | Pure page hashing and centralized terminal errors retained; no correction needed. | + +### Work Unit Evidence + +- Focused: `ocr-service/.venv/bin/python -m pytest ocr-service/tests -k auth` — exit 0; 8 passed, 0 failed (one dependency deprecation warning). +- Runtime: local Uvicorn plus multipart curl — unauthorized `401` with `UNAUTHORIZED`, accepted `202` with queued identity, conflicting same key `409` with `IDEMPOTENCY_CONFLICT`. +- Gates: `npm test` 45/45, `npm run check`, Python `compileall`, tracked and untracked whitespace checks — all exit 0/equivalent clean. +- Cleanup: Uvicorn PID 268425 terminated and absent; temporary PDF, SQLite DB, and log removed; `.venv` retained. +- Rollback: remove `.gitignore` Unit 5 entries and `ocr-service/`, then revert tasks 3.1/3.2 and this Unit 5 progress/history block; Units 1–4 remain intact. +- Fresh evidence revision: `sha256:0d8b2c57bbab967473eaf99a7ca900168554e64254fa80197b3626b4570b58d0`; corrected final authored change count: 397 additions plus deletions, within 400. diff --git a/openspec/changes/ocr-ingest-integration/tasks.md b/openspec/changes/ocr-ingest-integration/tasks.md index 004c93e..9bf49e1 100644 --- a/openspec/changes/ocr-ingest-integration/tasks.md +++ b/openspec/changes/ocr-ingest-integration/tasks.md @@ -44,8 +44,8 @@ Tracker feature/ocr-ingest-integration is draft/no-merge and sole main target. P ## 3 OCR Service (Units 5–6; P5/P6) -- [ ] 3.1 RED HTTP: bearer 401; idempotency/conflict 409; allowlist; limits 413/422; pressure 429; no-retry; integrity mismatch -- [ ] 3.2 GREEN: `ocr-service/` API, queue, allowlist, limits, health +- [x] 3.1 RED HTTP: bearer 401; idempotency/conflict 409; allowlist; limits 413/422; pressure 429; no-retry; integrity mismatch +- [x] 3.2 GREEN: `ocr-service/` API, queue, allowlist, limits, health - [ ] 3.3 GREEN: PaddleOCR 3.4.0, 200 DPI, schema, stub - [ ] 3.4 GREEN: `ocr-service/Dockerfile` pinned wheels/models/resources