think
16px
820px

W4 report — OCR/classifier gateway header seams + doc identity (final code wave)

Status: DONE

Branch: refactor/tidyup-foundation. Worktree: /home/efran/remote-development/poc-ahu-ai/ahu-ocr-tidyup.

Commits

  1. refactor(gateway): §2 headers on the 7 remaining Azure DI submit+poll seams
    azure-on-prem-{layout,ktp,npwp,id-document,contact-info,sp-pendirian-pp,bukti-setor-custom}.ts — submit + operation-location poll, each gets buildGatewayHeaders() merged over its existing Content-Type/Ocp-Apim-Subscription-Key headers.
  2. refactor(gateway): §2 headers on PaddleOCR + classifier + doc-forensic egress
    paddleocr-layout.ts, ocr-ensemble.ts (VLM-service leg), ocr/classifier.ts, services/doc-forensic-client.ts.
  3. refactor(gateway): §2 headers on the classifier/GPU-egress route bypasses + fitness ceilings
    The 5 route-level direct-classifierUrl calls the Deliverable-2 sweep turned up, the 2 direct-gpuServerUrl calls (/summarize, /cek-bukti), and the file-size.test.ts grandfathered-ceiling bumps the header instrumentation required.
  4. test(gateway): representative gateway-header tests — azure layout, classifier, paddleocr

Gate at every commit: ./node_modules/.bin/tsc --noEmit clean, full backend suite green.

Test summary

Full backend suite: 2065 pass / 1 skip / 0 fail across 2066 tests (baseline 2060/1 + 6 new gateway-header tests), tsc --noEmit clean.

Deliverable 1 — 7 Azure DI submit+poll seams

File Stage slug documentId docPages
azure-on-prem-layout.ts (submitAndPoll, shared by extractText/extractTextWithWords) azure-layout "no-doc" omitted
azure-on-prem-ktp.ts (processKtp) azure-ktp "no-doc" omitted
azure-on-prem-npwp.ts (processNpwp) azure-npwp "no-doc" omitted
azure-on-prem-id-document.ts (extractIdDocument) azure-id-document "no-doc" omitted
azure-on-prem-contact-info.ts (processContactInfoViaAzureDi) azure-contact-info "no-doc" omitted
azure-on-prem-sp-pendirian-pp.ts (processSpPendirianPp) azure-sp-pendirian-pp "no-doc" omitted
azure-on-prem-bukti-setor-custom.ts (processBuktiSetorCustom) azure-bukti-setor-custom "no-doc" omitted

None of these 7 functions accept a documentId parameter today, and none know the page count before the analyze result comes back — per the brief's "do not widen signatures" rule, documentId is "no-doc" and docPages is omitted everywhere in this deliverable (not a per-file inconsistency).

Per file: contentHash = sha256Hex(fileBuffer) computed once before submit and reused for both the submit call's Idempotency-Key/X-Doc-Hash and every poll tick's X-Doc-Hash (anti-footgun from the brief — no re-hashing per poll). Submit merges buildGatewayHeaders({traceId: currentTraceId(), priority: "batch", idempotencyKey, docHash}) over the existing headers; poll merges the same minus idempotencyKey (W3 convention — polls never carry one).

azure-on-prem-domisili.ts — verified, not touched. Its processDomisili() submit already carries buildGatewayHeaders({traceId: currentTraceId(), priority: "batch"}) from W3 (no idempotencyKey/docHash — a narrower header set than the other 7 files get in this wave). Its poll loop carries no gateway headers at all. Both are outside this wave's explicit scope ("already got headers in W3 — verify, don't duplicate" names only the submit call); flagging for the coordinator in case parity with the other 7 files' fuller header set is wanted before the gateway flip — not changed here since the brief only asked me to verify.

Deliverable 2 — PaddleOCR + classifier + doc-forensic

  • paddleocr-layout.ts (${paddleOcrUrl}/layout) — stage paddleocr-layout, FormData (no manual Content-Type).
  • ocr-ensemble.ts (runVlmService, ${url}/ocr) — stage ocr-ensemble. (runWordsEngine's Paddle/Azure legs call into the two files above, so they're covered indirectly — no separate treatment needed there.)
  • ocr/classifier.ts (classifyDocument) — stage doc-classifier, FormData.
  • services/doc-forensic-client.ts (analyzeDocumentForgery) — stage doc-forensic; confirmed a live caller (services/pp-perubahan-cross-validator.ts:510), so it gets headers per the brief's explicit instruction. FormData.

All four: contentHash = sha256Hex(<submitted buffer>), priority: "batch", documentId: "no-doc" (none of these functions receive one).

Deliverable 2 (continued) — the "any other direct fetch" sweep

grep fetch( across backend/src (excluding __tests__/scripts) turned up direct classifier/GPU egress that bypasses the canonical ocr/classifier.ts helper entirely — 5 route files reimplement the same fetch(config.classifierUrl, {method:"POST", body: FormData}) pattern, plus 2 direct one-shot gpuServerUrl LLM calls with no job-poll or chat-completions shape (so untouched by W2/W3). Verdict for each:

Call site Verdict Stage slug documentId used
routes/classifier.ts:59 POST /classify added classifier-route "no-doc" (stateless test-classify endpoint)
routes/klasifikasi.ts:282 POST /classify/:fileId added classifier-klasifikasi fileId (temp-upload id, in scope)
routes/pp-perbaikan-pp.ts:200 classifyBoIdDocument added classifier-pp-perbaikan-bo "no-doc" (helper takes no documentId; not widened)
routes/pp-perubahan.ts:707 classifyBoIdDocument (duplicate helper) added classifier-pp-perubahan-bo "no-doc"
routes/submissions.ts:1818 upload-classify added classifier-submissions-upload id (submission id, in scope)
routes/submissions.ts sendSummarize${gpuServerUrl}/summarize added submission-summarize "no-doc" (helper takes no documentId; not widened)
routes/perubahan.ts POST /cek-bukti${gpuServerUrl}/cek-bukti added pp-perubahan-cek-bukti aktaDocumentId (in scope, validated non-null)
routes/settings.ts (4× GPU/classifier cache stats+clear proxies) deliberately skipped admin/ops introspection endpoints, no document payload — not an AI operation with audit-worthy doc/trace attribution
routes/health.ts:58 /ready GPU/PaddleOCR liveness probe deliberately skipped liveness ping, not a model/OCR call

All "added" sites use priority: "interactive" (a human/browser is synchronously waiting on the HTTP response), unlike the pipeline-stage "batch" calls in Deliverable 1/2's main files.

Deliverable 3 — representative tests

3 new files under backend/src/ocr/__tests__/, following the captureFetch idiom from lib/__tests__/gateway-chat-client.test.ts:

  • azure-on-prem-layout-gateway-headers.test.ts — mocks submit (200 + Operation-Location header) then one poll tick (succeeded); asserts URL unchanged, existing Content-Type/Ocp-Apim-Subscription-Key preserved, submit carries X-Tenant-Id/X-Request-Id/X-Priority/Idempotency-Key, poll carries the header set minus Idempotency-Key, and X-Doc-Hash on both equals sha256Hex of the submitted buffer.
  • classifier-gateway-headers.test.ts — asserts URL unchanged, gateway header set present on the single FormData call, X-Doc-Hash matches, and Content-Type is never set manually (FormData's own boundary survives).
  • paddleocr-layout-gateway-headers.test.ts — same shape as the classifier test, for the /layout FormData call.

All 3 pass (6 tests total, runWithTrace used to pin a deterministic X-Request-Id for exact-match assertions).

Deliverable 4 — dormancy sweep

  • git grep -nE "http://(localhost|127\.|192\.168\.)" backend/src (excluding __tests__/scripts): only 3 hits, all process.env.X || "http://localhost:..." config defaults (paddleOcrUrl, gpuServerUrl, voteBaseUrl — the last isn't AI egress) — exactly the CONVENTIONS §4.2-compliant shape (env var wins, hardcoded value is only the fallback). No fix needed. scripts/*.ts hits (2× 192.168.83.20:800{1,2}) are excluded per the brief and are dev-only CLI scripts, not engine runtime code.
  • .env.example's W1 gateway block already lists commented Phase-B lines for GPU_SERVER_URL, AZURE_ON_PREM_BASE_URL, PADDLE_OCR_URL, and CLASSIFIER_URL — accurate, no additions needed.

Fitness-test ceiling bumps (file-size.test.ts)

The header-merge instrumentation pushed 4 already-grandfathered route files past their recorded ceilings (routes/submissions.ts +29, routes/perubahan.ts +16, routes/pp-perubahan.ts +18, routes/pp-perbaikan-pp.ts +18 lines). Bumped each ceiling with a dated comment following the precedent already set by W2's document-processor.ts +4 bump — instrumentation, not feature growth. routes/klasifikasi.ts/routes/classifier.ts/services/doc-forensic-client.ts stayed well under the 800-line new-file cap (400/120/50 lines respectively).

Concerns for the reviewer

  • Scope beyond the brief's named file list: Deliverable 2's "any other direct fetch" clause is read broadly here — I added headers to 5 classifier-bypass route call sites and 2 non-job-shaped GPU LLM calls (/summarize, /cek-bukti) that were never migrated by W2 (chat-completions shape) or W3 (job-poll shape). This is headers-only (no URL/behavior change) and low-risk (no existing test mocked fetch on any of these 7 call sites), but it does widen W4's footprint past "OCR/classifier + doc identity" into two ad-hoc LLM endpoints. Flagging in case the coordinator wants these reverted/deferred to a separate pass.
  • azure-on-prem-domisili.ts header-set asymmetry: its W3-era submit headers omit idempotencyKey/docHash that every other Azure file in this wave carries, and its poll carries none at all. Not touched (out of this wave's literal scope per the brief), but noted for whoever does the Phase-B gateway flip.
  • No test coverage existed for any of the 7 catch-all route/GPU call sites before this wave (confirmed by grep before editing), so their header additions are verified by tsc + the unchanged full suite passing, not by a dedicated header-assertion test — same "verified by the reviewer reading the diff" arrangement the brief accepted for the 6 non-representative Azure files.