think
16px
820px

Gateway P2.7 — Native inbound façades: Azure DI, PaddleOCR, doc-classifier

Date: 2026-07-07 · Status: approved scope (Efran: "full sweep", staged as Phase 2 after the gpu-server flip) · Depends on: P2.5/P2.6 façade machinery (live), the ahu-ocr flip of GPU_SERVER_URL (done 2026-07-07).

Goal

Route the OCR engine's remaining AI egress seams through the gateway so every model/OCR call is pool-admitted, audited, and tenant-attributed — with the engine unchanged (base-URL env swaps only, per the hands-off rule). After P2.7 the OCR engine has zero un-audited AI egress.

Ground-truth contracts (read from ahu-ocr-tidyup engine code — the façades must mirror these byte-for-byte)

1. Azure DI on-prem (AZURE_ON_PREM_BASE_URL, async, the big one)

  • Submit: POST {base}{apiPath}/{dialect}/documentModels/{model}:analyze?api-version={2023-07-31|2024-11-30} — body is the document (binary or JSON per caller), auth header Ocp-Apim-Subscription-Key.
  • Four apiPath prefixes are in live use (each caller hardcodes its own): /api/custom-template (bukti-setor/domisili/contact-info/NPWP/SP customs), /api/id-document (prebuilt-idDocument), /api/layout-v4 + /api/read-v4 (azureOcr() mode). Two dialects: formrecognizer/documentModels (v2023-07-31 callers, ×7) and documentintelligence/documentModels (v4 callers, ×5). The façade must treat prefix+dialect+model as opaque and forward {prefix}{rest} verbatim — configured as facade_prefixes: ["/api/custom-template","/api/id-document","/api/layout-v4","/api/read-v4"].
  • Models used: custom (sp_pendirian_pp_v4, bukti-setor/domisili/contact-info/NPWP customs from AZURE_MODEL_* envs) + prebuilt-idDocument, etc. The façade must treat {model} as an opaque path segment (wildcard), never an allowlist.
  • Response: 202 + Operation-Location header; the engine rewrites that URL onto its own base by taking the path from /documentintelligence/ onward and prefixing ${azureOnPremBaseUrl}${apiPath} (ocr/azure-on-prem-layout.ts:27-42). After the flip azureOnPremBaseUrl = the gateway, so the gateway only needs to (a) serve the same poll path shape and (b) return an Operation-Location containing /documentintelligence/... — the engine maps it home itself.
  • Poll: GET the rewritten URL (same key header) until status: succeeded|failedanalyzeResult.
  • Façade shape: mount POST /documentintelligence/documentModels/{model}:analyze (any api-version query, forwarded verbatim) + GET /documentintelligence/** poll passthrough on an azure-di-facade upstream. Pool-admit the submit (batch/interactive by X-Priority); polls are免 admission (cheap). Audit: one event per analyze submit (operation=ocr, model={azure model}) and one on terminal poll (status + upstream_ms) — mirror the SyncForward/emitSync pattern with a small in-memory submit→terminal correlation (keyed by the operation id in the poll path; TTL ~1h; correlation loss degrades to submit-only events, never an error).
  • Auth: forward Ocp-Apim-Subscription-Key verbatim (the engine holds the key; the gateway does NOT need it in env for the passthrough).

2. PaddleOCR (PADDLE_OCR_URL, sync) — build flip-ready, live flip DEFERRED

  • Single endpoint: POST /layout, multipart form (file), → sync JSON {rawText, pageCount, imageWidth, imageHeight, words[], ...} (ocr/paddleocr-layout.ts). All paddle callers go through this one path.
  • Live state 2026-07-07: dormant. OCR_LAYOUT_PROVIDER unset (default azure-di) and PADDLE_OCR_URL unset, so no live traffic uses paddle. Ship the façade + gateway upstream (endpoints: ["http://192.168.83.20:8108"] — the container is host-published) so enabling paddle later is PADDLE_OCR_URL={gateway} in one step; do NOT count it in live-flip verification.
  • Façade shape: reuse the existing SyncForward machinery (P2.6) generalized to serve per-upstream sync paths: POST /layout on a paddleocr upstream (operation=ocr, model=paddle-layout). Timeout: engine uses PADDLE_OCR_TIMEOUT_MS default 300 000 — the façade's per-call timeout must be ≥ that (make syncForwardTimeout per-upstream-configurable; today it is a 120s const, too short for paddle).

3. Doc-classifier (CLASSIFIER_URL, sync)

  • Engine calls the FULL url .../api/classifier/classify with multipart FormData → {classification, confidence} (ocr/classifier.ts:27). It ALREADY sends the §2 gateway headers (tenant/trace/idempotency) on this call.
  • Façade shape: SyncForward path POST /api/classifier/classify (serve the full engine-side path so the flip is exactly CLASSIFIER_URL={gateway}/api/classifier/classify) on a doc-classifier upstream (operation=classify, model=doc-classifier, interactive default per the classify priority rule in classFromPriority).
  • Verified 2026-07-07: the audit stream has no self-emitted doc-classifier events, so this adds real coverage (not redundant with the SDK pattern — that remains unimplemented in the classifier).

NOT in P2.7 (folds into config / later)

  • akta-txn-classifier + apostille-classifier: LLM chat calls through the engine's existing gateway-chat-client (guided_json to a vLLM /v1). Flip = point AKTA_TXN_CLASSIFIER_URL / CLEANUP_LLM_URL at the gateway /v1 when those features are enabled (they are env-disabled today). No build.
  • doc-forensic: unused project (Efran 2026-07-07) — dropped entirely.
  • vote: not an AI egress (shareholder e-voting links) — never in scope.
  • PADDLEOCR_VL_URL / MINERU_URL: ensemble engines, env-empty today; the paddle façade pattern covers them later if enabled.

Architecture

Generalize the P2.5/P2.6 façade from "the one gpu-server upstream" to per-upstream inbound façades declared in config:

upstreams:
  - id: azure-di-onprem
    type: ocr-http
    class: on_prem
    adapter: azuredi-facade        # new: inbound native async façade
    operation: ocr
    endpoints: ["https://x056.ahu-azure.val.id"]   # the same nginx base the engine uses today — no per-container knowledge needed
    facade_prefixes: ["/api/custom-template", "/api/id-document", "/api/layout-v4", "/api/read-v4"]
    models: ["azure-di"]           # registry key only (config requires non-empty models); the façade is a wildcard, not an allowlist
    slots: {total: 6, batch_max: 4}
  - id: paddleocr
    type: ocr-http
    class: on_prem
    adapter: sync-facade           # new: generic sync passthrough (P2.6 SyncForward, generalized)
    operation: ocr
    endpoints: ["<real paddle url>"]
    models: ["paddle-layout"]      # registry key only (config requires non-empty models)
    sync_paths: [{path: /layout, model: paddle-layout}]
    sync_timeout_ms: 300000
    slots: {total: 4, batch_max: 4}
  - id: doc-classifier
    type: classify
    class: on_prem
    adapter: sync-facade
    operation: classify
    endpoints: ["<real classifier base>"]
    models: ["doc-classifier"]     # registry key only (config requires non-empty models)
    sync_paths: [{path: /api/classifier/classify, model: doc-classifier}]
    slots: {total: 4, batch_max: 2}

Key decisions:
1. Sync façade = generalize SyncForward (pool admission → verbatim forward → header relay minus hop-by-hop → emitSync audit). New config: sync_paths (path→model label) + sync_timeout_ms. The gpu-server's hardcoded /summarize//cek-bukti become the same mechanism (no behavior change — keep their existing mount for compat).
2. Azure DI façade is async-passthrough, NOT job-store-backed: the engine drives its own poll loop against Operation-Location; wrapping it in the P2 job store would change the wire contract. The gateway forwards submit + polls, audits submit and terminal, and pool-admits only the submit.
3. Route collision safety: façade mounts must not shadow /v1, /jobs, /metrics, /health, or the gpu-server façade paths; config validation rejects a sync_paths entry that collides with a reserved or already-mounted path.
4. Dormancy invariant (contract §6): no azuredi-facade/sync-facade upstream configured ⇒ binary is byte-identical to P2.6 behavior.
5. On-prem enforcement + tenant auth: same predicates as SyncForward (class check, header-trust/token mode) on every new inbound path.

Flip plan (after deploy)

~/ahu-ai-staging/ocr/infra/env/shared.env:
- AZURE_ON_PREM_BASE_URL=http://192.168.83.20:8200 (engine's apiPath+rewrite logic maps polls onto the gateway automatically)
- PADDLE_OCR_URL=http://192.168.83.20:8200
- CLASSIFIER_URL=http://192.168.83.20:8200/api/classifier/classify
Real backends move to gateway config (endpoints:). Backups + one-seam-at-a-time flips with a real-document smoke between each; instant revert = restore env line + recreate web.

Verification

  • Unit: per-façade adapter/handler tests mirroring P2.5/P2.6 suites (auth, admission/429+slot-release, verbatim forward incl. multipart bodies + query strings, header relay, audit emission incl. truncation, Operation-Location poll correlation, JOB-style error mapping).
  • Live: per seam — real document through the engine UI/API, confirm result parity + audit event (engine=ahu-ocr, correct operation/model), chatbot /v1 regression check after the redeploy.

Risks

  • Azure DI poll-path variety (api-version differences, absolute vs relative Operation-Location) — mitigated by wildcard passthrough + engine-side rewrite doing the heavy lifting.
  • Multipart bodies through the façade must be forwarded byte-verbatim with the original Content-Type boundary (SyncForward already does this — the gpu-server /jobs multipart path proved it live).
  • Paddle 300s calls holding slots — sized pool (4) matches the paddle service's real concurrency.