think
16px
820px

OCR engine ↔ Gateway P2 Job API — integration & wire-compatibility review

Date: 2026-07-06
Reviewer scope: READ-ONLY on ahu-ocr-tidyup (engine repo — not modified). All proposed fixes land in ahu-gpu-manager (the gateway), since the engine was told the flip would be a "wire-compatible base-URL swap of the gpu-server contract." No code changed; this is a findings report.

Sides reviewed
- Engine: ahu-ocr-tidyup @ HEAD (019ae1b, "final integration report … deployed, smoke green"). Shared client backend/src/lib/gpu-job-client.ts; headers backend/src/lib/gateway-headers.ts; 12 job callers; gpu-server contract gpu-server/app/main.py + schemas/responses.py.
- Gateway: ahu-gpu-manager master, Job API internal/jobs/api.go, router internal/server/server.go, internal/jobs/job.go, registry deploy/gateway.yaml, docs/CONVENTIONS.md v1.0.


Verdict up front

  • (a) Engine implementation vs its prompt — CORRECT & COMPLETE. The engine did exactly what prompt-ahu-ocr-tidyup.md told it: consolidated all 12 submit-poll callers onto one client, applied the §2 headers everywhere, implemented the §3.3 split clock and the JOB_UNKNOWN→resubmit path per CONVENTIONS, uses deterministic idempotency keys, and kept everything dormant (all URLs still built from config.gpuServerUrl; nothing reads MODEL_GATEWAY_URL on the job path). The prompt explicitly instructed it to preserve each caller's exact current endpoint/payload and treat the flip as a GPU_SERVER_URL base-URL swap (prompt §2 line 34, §6 line 71).
  • (b) Gateway P2 is NOT wire-compatible as-is. The OCR flip WILL break. The engine submits to 12 distinct per-operation paths with no model, but the gateway routes only POST /jobs and requires model via ?model=/X-Model. Additionally the gateway's JOB_UNKNOWN 404 envelope diverges from CONVENTIONS §3.4 and from what the engine parses, and there are no registry/adapter entries for any of the 12 operations. The gateway needs changes (listed at the end) before ahu-ocr can be flipped.

Engine integration: correct & complete vs the prompt?

Prompt area Status Notes
§2 shared job client (consolidate 12 callers) gpu-job-client.ts submitAndPollJob used by all: ocr/gpu-server.ts, ocr/azure-on-prem-bukti-setor.ts, ocr/azure-on-prem-domisili.ts, llm/{ktp-cleanup,override-scrutiny,perbaikan-advisory,pendirian-pp-extract,surat-pernyataan-extract}.ts, services/{perbaikan-ai-summary-worker,document-processor}.ts (bukti-setor, domisili, contact-info, berita-acara-rups, jual-beli).
§1 §2 headers on ALL egress buildGatewayHeaders emits X-Tenant-Id: ahu-ocr, X-Surface: internal, X-Request-Id (ALS trace), X-Priority, Idempotency-Key (submit only), X-User-Id/X-Doc-Hash/X-Doc-Pages when present. Sent on submit AND poll (poll omits Idempotency-Key by design). Git log confirms the same header set was extended to Azure DI, PaddleOCR, classifier, and doc-forensic seams.
§3.3 queued-vs-processing clock Processing-timeout clock advances only while status==="processing"; queued time accrues against gpuQueueWaitMs (GPU_QUEUE_WAIT_MS, default 1 800 000). Tick reset after a JOB_UNKNOWN resubmit.
JOB_UNKNOWN → resubmit same key ⚠️ (engine-correct, blocked by gateway) Engine resubmits with the same deterministic key on 404 when it reads top-level code === "JOB_UNKNOWN". This matches CONVENTIONS §3.4 exactly — but the gateway nests the code (see Mismatch #4), so the branch never fires against P2. Engine is right; gateway is wrong.
Deterministic Idempotency-Key buildIdempotencyKey = \${documentId}:${stage}:${contentHash}`documentIddefaults to"no-doc",stagealways set,contentHashissha256Hex(body)`. Never empty.
Dormancy (env unset ⇒ byte-identical) Job callers build submitUrl/pollUrlBase from config.gpuServerUrl only; MODEL_GATEWAY_URL is read solely by the chat client (gateway-chat-client.ts). Unset gateway env ⇒ unchanged job behavior.

Engine is clean. The one caveat (JOB_UNKNOWN) is a gateway defect, not an engine defect.


Compatibility matrix (engine SENDS/EXPECTS vs gateway REQUIRES/RETURNS)

Dimension Engine (ahu-ocr) Gateway P2 Result
Submit method + path POST to 12 per-op paths: /jobs (multipart, document_type), /ktp/cleanup, /override-scrutiny, /perbaikan/advisory, /perbaikan/ai-summary/extract, /perbaikan/surat-pernyataan/extract, /pendirian-pp/surat-pernyataan/extract, /bukti-setor/extract, /domisili/extract, /jual-beli/extract, /contact-info/extract, /berita-acara-rups/extract Routes only POST /jobs (server.go:190). All other paths hit the ServeMux default → 404, no body ❌ MISMATCH (critical) — 11 of 12 submit endpoints are unrouted.
How model is conveyed None. Operation is implicit in the path; /jobs selects op via document_type form field Requires ?model= or X-Model; else 400 MODEL_REQUIRED (api.go:92-99). Does not read document_type ❌ MISMATCH (critical) — even the one /jobs caller 400s.
Idempotency-Key required Always sent, deterministic, never empty Required non-empty; else 400 IDEMPOTENCY_KEY_REQUIRED (api.go:63-67) ✅ MATCH
Submit 202 body Reads { job_id } only Returns {job_id,status,queue_position,eta_ms} ✅ MATCH (engine ignores extras)
Poll path GET ${pollUrlBase}/jobs/${id} = ${gateway}/jobs/{id} GET /jobs/{id} (server.go:191) ✅ MATCH
Poll status enum queued | processing | completed | failed (unknown ⇒ keep polling) Same four (job.go:14-17) ✅ MATCH
RESULT field name/location Reads status.result (bare object) on completed Sets top-level result = json.RawMessage (raw JSON, not base64) when non-empty (api.go:358-361) ✅ MATCH on field/shape. ⚠️ content depends on an adapter that doesn't exist yet (see below).
queue_position / eta_ms Reads queue_position/eta_ms on queued (surfaced via onQueueUpdate) Emits queue_position/eta_ms on queued (api.go:349-354) ✅ MATCH
Unknown-job 404 shape/code Reads top-level body.code === "JOB_UNKNOWN" (gpu-job-client.ts:295); CONVENTIONS §3.4 says 404 {code:"JOB_UNKNOWN"} writeErr emits nested {"error":{"code":"JOB_UNKNOWN",...}} (api.go:41, 327) ❌ MISMATCH (important) — engine sees no top-level code, treats it as a plain 404, throws GpuJobNotFoundError instead of resubmitting. Gateway also violates its own §3.4.
Failed-poll error detail Reads status.error (message string) Emits error_code only, no error (api.go:362-363) ⚠️ MISMATCH (minor) — engine fail-soft to "Unknown error"; loses the failure message. gpu-server returns error.
Auth / headers X-Tenant-Id: ahu-ocr, no Authorization Header-trust when no tenant token is set (default gateway.yaml: ahu-ocr token ""TokenTenants empty ⇒ tenant from X-Tenant-Id). Token-only 401 if tokens configured (api.go:49-56, server.go:199) ✅ MATCH by default. ⚠️ Conditional 401 if tokens are later enabled (engine has no bearer-token wiring).

MISMATCHES that break the flip — with the gateway-side fix

Critical

M1 — Submit paths unrouted (11/12 endpoints → 404).
The engine POSTs to gpu-server per-operation paths; the gateway serves only POST /jobs. On flip (GPU_SERVER_URL=<gateway>) every non-/jobs submit 404s at the mux and the client throws GpuJobSubmitFailedError.
Gateway fix: Add a gpu-server-compat submit façade that registers each of the 12 gpu-server submit routes and maps path → (model, operation, upstream SubmitPath) internally, then enqueues through the existing job pipeline. The engine must not need to change (its promise was a base-URL swap).

M2 — No model on submit → 400 MODEL_REQUIRED / no operation resolution.
The engine never sends ?model=/X-Model; the /jobs caller selects the operation with a document_type form field the gateway ignores.
Gateway fix: The façade in M1 supplies the model/operation from the matched route (and, for /jobs, from the document_type form field), so Reg.Resolve succeeds without the engine sending a model.

M3 — No registry models/adapters exist for the 12 operations.
deploy/gateway.yaml registers only qwen-35b, cleanup-3b, tei-embeddings, ext-dashscope-397b (chat/embed), paddleocr, azure-di, doc-classifier. There is nothing for KTP-cleanup, bukti-setor/domisili/jual-beli/contact-info/berita-acara extraction, override-scrutiny, the perbaikan/pendirian-pp jobs, or the akta/ktp /jobs document pipeline. Even with routing fixed, Reg.Resolve would 404 UNKNOWN_MODEL, and result would be empty because no adapter produces the {data, confidence[, bounding_boxes, ocr_metadata]} shapes the engine parses.
Gateway fix: Register the real gpu-server as an ocr-http upstream (or a small family of them) with adapter: sync-http and a submit_path per operation, plus a registry model id per operation, so the sync-http adapter forwards the payload to the gpu-server's own per-op endpoint and returns its JSON body verbatim as result (which already matches the engine's expected shapes). The gpu-server's Celery/vLLM extraction logic stays authoritative — the gateway only queues + audits in front of it.

Important

M4 — JOB_UNKNOWN 404 envelope mismatch (breaks resubmit + violates §3.4).
writeErr wraps every error as {"error":{"code","message"}}, but the engine reads top-level body.code, and CONVENTIONS §3.4 specifies 404 {code:"JOB_UNKNOWN"}. Result: the engine's resubmit-with-same-key recovery never fires — a genuinely expired/unknown job surfaces as a hard GpuJobNotFoundError.
Gateway fix: For the JOB_UNKNOWN 404 (at minimum), emit a top-level code field, e.g. {"code":"JOB_UNKNOWN","error":{"code":"JOB_UNKNOWN","message":...}}, or change the poll 404 to the §3.4 shape. This is a gateway defect independent of the façade decision.

M5 — Failed poll returns error_code, not error.
The engine reads status.error on failed; the gateway sends only error_code. The job still fails correctly, but the human-facing message degrades to "Unknown error".
Gateway fix: Include a human-readable error (message) field alongside error_code on failed polls, matching the gpu-server's JobStatusResponse.error.

Conditional

M6 — Bearer-token auth (only if tokens get enabled).
Default deploy is header-trust (ahu-ocr token ""), so X-Tenant-Id alone authenticates and this is a MATCH today. If tenant tokens are ever set, both Submit and Poll return 401 (the engine sends no Authorization).
Gateway fix / decision: Keep ahu-ocr in header-trust mode for the flip, or coordinate a token + engine change first (the latter is not a base-URL swap).


Summary of required gateway changes before the OCR flip

  1. gpu-server-compat submit façade — route the 12 per-operation paths, derive model/operation/SubmitPath server-side (M1 + M2).
  2. Registry + adapter entries for all 12 operations pointing at the real gpu-server via sync-http, returning its JSON as result (M3).
  3. Top-level code on the JOB_UNKNOWN 404 to match CONVENTIONS §3.4 and re-enable the engine's resubmit path (M4). (Standalone bug fix — do regardless.)
  4. Add error message field on failed polls for parity with the gpu-server contract (M5).
  5. Keep ahu-ocr header-trust at flip time, or plan a separate token rollout (M6).

Items 3 and 4 are clean, low-risk gateway corrections that improve CONVENTIONS conformance on their own. Items 1–3 are the substantive work: P2's generic POST /jobs?model= API does not match the gpu-server's per-operation contract the engine was built and shipped against, so a compat façade + registry wiring is the gateway-side path to honoring the "wire-compatible base-URL swap" promise without touching the (already-deployed, hands-off) engine.