Files
0pt/src/adapters/document-intelligence-adapter.ts
T
adminandClaude Opus 4.8 e6d3101501 fix: 17 confirmed bugs from full-repo audit (edge + Python engine) (#5)
* docs: correct stale quality-check-service.ts comment references

Two source comments still cited src/services/quality-check-service.ts as an
existing precedent. It doesn't exist anywhere in this repo's history (git
log --all: zero commits, any branch, ever) -- confirmed during the earlier
full-doc-corpus accuracy pass. Points each comment at the real precedent
instead (document-extraction-service.ts / iceberg-write-service.ts).

Applied from task_05ec36aa, spun off during that pass.

* fix: 17 confirmed bugs from full-repo audit (edge + Python engine)

Found by an 8-finder bug hunt across app/api, src/services, src/clients,
frontend, the Python engine, the data layer, config/infra, and tests, then
independently adversarially verified per finding (32 raw findings -> 27
confirmed real bugs; 5 refuted). This commit applies the 17 confirmed as
safe, mechanical fixes -- no architecture/design decision required. The
other 10 need a decision and are reported separately, not touched here.

Most consequential, app/api/payments/[order_id]/deposit/route.ts:
the client-supplied Origin request header was trusted unsanitized both as
a server-side fetch target (SSRF) and as Stripe Checkout success/cancel
redirect URLs (open redirect tied to a real payment flow), and a failed
order lookup silently fell through to a hardcoded deposit amount instead
of 404. Fixed by reading the order directly via lib/orders-store.ts
(mirroring the sibling release/route.ts, which already did this correctly)
instead of both trusting the Origin header and self-issuing an HTTP fetch.

Also fixed:
- 5 routes (orders, orders/[id], estimates, subscriptions, ai/parse) that
  500'd instead of 400ing on a literal JSON `null` body, since req.json()
  resolves (not rejects) for that input and bypasses the try/catch guard.
- app/api/0pt/lakehouse/mappings/{,[mappingId]/{,writes/}}route.ts each
  instantiated their own private service instances, so a mapping created
  via POST was invisible to the sibling GET and writes routes even before
  the separately-tracked dataset-registration gap. Extracted a shared
  singleton module, src/services/lakehouse-mapping-registry.ts.
- app/api/0pt/documents/[documentId]/extraction/route.ts collapsed every
  error (not-configured, policy-denied, policy-approval-required,
  not-found) into a blanket 400; now status-mapped like the sibling
  sources/[sourceId]/query route.
- app/api/vehicles/route.ts: plate query param wasn't URL-encoded before
  being concatenated into the outbound RDW request, allowing extra query
  parameters to be injected; added a strict allowlist + encodeURIComponent.
- app/api/uploads/route.ts: POST returned a fake 200 success with
  put_url: null even when "configured", instead of continuing to report
  501 until presign generation is actually implemented.
- src/adapters/document-intelligence-adapter.ts: the result-polling loop
  never checked response.ok, so a non-2xx poll response (auth expiry,
  429, 5xx) was silently retried until a generic timeout masked the real
  error.
- app/order/[id]/page.tsx: finish() never cleared the production-simulation
  setInterval, so it kept firing (and re-rendering) forever after delivery.
- app/admin/page.tsx: the metrics fetch had no response/shape validation
  (unlike the adjacent orders fetch), rendering literal "undefined" stat
  tiles on a malformed response instead of falling back to <ErrorState/>.
- services/api/workers/pipeline.py: `config.get("seeds") or DEMO_SEEDS`
  used truthy-or, so an explicit empty seeds dict was silently replaced
  with hardcoded demo org numbers instead of being respected as empty.
- services/api/workers/resolve.py: corroboration (confidence/agreed_sources
  bump) no longer fires for two hits from the same source under fuzzy
  match -- only a genuinely independent source now counts, per docs/06's
  own ">=2 independent sources agree" definition -- and a merged record's
  provenance array now actually includes the corroborating source's
  fields instead of only a bare source-name string.
- services/api/orchestrator/main.py: create_order's int(...) on
  unvalidated client volume/confidence fields raised on any
  decimal-formatted input (int("92.5") -> ValueError); now coerced via
  float() first with a safe default. /vehicles/{plate} and /screen also
  now catch connector exceptions and degrade gracefully, matching
  pipeline.py's existing pattern for the same connectors.
- lib/orders-store.ts: postgres.js (pinned v3.4.9) returns bigint/numeric
  columns as strings by default; getOrder/listOrders/setStatus were
  silently handing callers stringified money fields. Added a narrow
  per-row coercion instead of touching the shared client's global type
  map (which would affect unrelated tables' bigint/numeric columns too).
- lib/subscriptions-store.ts: the in-memory fallback returned
  oldest-first while the Postgres branch explicitly orders newest-first;
  now consistent, mirroring the identical fix already in orders-store.ts.
- app/api/auth/login/route.ts: DEV_LOGIN_KEY was compared with a plain
  !==, a non-constant-time comparison; replaced with crypto.timingSafeEqual.

Verified before commit: tsc --noEmit clean; vitest run 88 passed, 1 todo,
0 failures (13 files, unchanged from before this commit -- no existing
test's assertions needed to change). services/api/workers/resolve.py's
source-novelty and provenance-merge logic additionally verified by direct
execution against synthetic same-source/cross-source inputs (no Python
test suite exists in this repo to add to). services/api/workers/pipeline.py
and services/api/orchestrator/main.py verified with python -m py_compile
(no httpx/fastapi installed in this environment to run them directly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(engine): treat explicit seeds:null as missing to avoid 500 in run_job

Four-eyes follow-up to this PR's own seeds change. Switching from
`config.get('seeds') or DEMO_SEEDS` to `config['seeds'] if 'seeds' in config`
correctly stopped an explicit empty {} from being backfilled with DEMO_SEEDS,
but regressed on client-controlled `{"seeds": null}`: seeds became None and
the downstream `seeds.get(market, [])` raised AttributeError -> 500. That path
is reachable (POST /orders stores raw body.config; POST /orders/{id}/run passes
it straight to run_job). Using `config.get('seeds') is not None` keeps the
intended behaviour (explicit {} honoured, never silently seeded with demo data)
while restoring the None-tolerance the old idiom had.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 18:52:19 +02:00

213 lines
8.7 KiB
TypeScript

/**
* src/adapters/document-intelligence-adapter.ts
* ================================================
* Tenant-agnostic Azure AI Document Intelligence client.
*
* Generalized from v0-datahub/lib/invoice-inbox/doc-intelligence.ts, which
* hardcoded the `prebuilt-invoice` model and a fixed invoice-shaped return
* type (amount_gross, vendor, vat, ...). Here `modelId` is a CALLER-SUPPLIED
* parameter — "prebuilt-invoice", "prebuilt-receipt", "prebuilt-layout",
* "prebuilt-read", or any custom-trained model id — and the adapter returns
* a generic field map instead of assuming any particular document shape.
*
* Evidence discipline (correctness-critical, not a style choice): a field is
* only ever populated with the value + confidence Document Intelligence
* actually reported. If DI did not return a value, the field comes back as
* { value: null, confidence: <DI's confidence if any, else null>, found: false }.
* Nothing here ever defaults, guesses, or backfills a value.
*/
export interface DocumentIntelligenceConfig {
endpoint: string;
apiKey: string;
/** Default: "2024-11-30" — the API version the production invoice pipeline used. */
apiVersion?: string;
/** Delay between result-polling attempts, ms. Default 2000. (Tests override to run fast.) */
pollIntervalMs?: number;
/** Delay before retrying after a 429, ms. Default 6000. (Tests override to run fast.) */
rateLimitRetryMs?: number;
/** Max result-polling attempts before timing out. Default 40. */
maxPollAttempts?: number;
/** Max analyze-request attempts (each 429 counts as one). Default 4. */
maxAnalyzeAttempts?: number;
}
export interface ExtractedField {
value: string | number | boolean | null;
confidence: number | null;
found: boolean;
}
export interface DocumentIntelligenceResult {
modelId: string;
apiVersion: string;
/** Full extracted text, when the API returns one (present across prebuilt models). */
content: string | null;
/** Top-level structured fields (documents[0].fields), normalized. Empty for pure layout/read. */
fields: Record<string, ExtractedField>;
/** Array-of-object fields (e.g. invoice/receipt line items), each item pre-normalized. */
arrayFields: Record<string, Array<Record<string, ExtractedField>>>;
/** documents[0].confidence, when the model reports a whole-document confidence. */
documentConfidence: number | null;
pageCount: number | null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Normalize one Document Intelligence field object into an evidence-based ExtractedField. */
function normalizeField(raw: unknown): ExtractedField {
const f = raw as Record<string, unknown> | null | undefined;
if (!f) return { value: null, confidence: null, found: false };
let value: string | number | boolean | null = null;
switch (f.type) {
case "string":
value = (f.valueString as string | undefined) ?? (f.content as string | undefined) ?? null;
break;
case "number":
value = (f.valueNumber as number | undefined) ?? null;
break;
case "integer":
value = (f.valueInteger as number | undefined) ?? null;
break;
case "date":
value = (f.valueDate as string | undefined) ?? null;
break;
case "time":
value = (f.valueTime as string | undefined) ?? null;
break;
case "phoneNumber":
value = (f.valuePhoneNumber as string | undefined) ?? null;
break;
case "countryRegion":
value = (f.valueCountryRegion as string | undefined) ?? null;
break;
case "selectionMark":
value = (f.valueSelectionMark as string | undefined) ?? null;
break;
case "currency": {
const cur = f.valueCurrency as Record<string, unknown> | undefined;
value = typeof cur?.amount === "number" ? (cur.amount as number) : null;
break;
}
case "boolean":
value = typeof f.valueBoolean === "boolean" ? (f.valueBoolean as boolean) : null;
break;
default:
value = (f.valueString as string | undefined) ?? (f.content as string | undefined) ?? null;
}
const found = value !== null && value !== undefined && value !== "";
const confidence = typeof f.confidence === "number" ? (f.confidence as number) : null;
return { value: found ? value : null, confidence, found };
}
function normalizeObjectFields(obj: Record<string, unknown> | null | undefined): Record<string, ExtractedField> {
const out: Record<string, ExtractedField> = {};
for (const [key, raw] of Object.entries(obj ?? {})) {
out[key] = normalizeField(raw);
}
return out;
}
export class DocumentIntelligenceAdapter {
constructor(private config: DocumentIntelligenceConfig) {}
static isConfigured(config: Partial<DocumentIntelligenceConfig>): boolean {
return !!(config.endpoint && config.apiKey);
}
/**
* Analyze a document buffer with the given model. Throws on any failure —
* callers must never interpret a caught error as "no fields found". A
* genuinely empty field is represented by { found: false } *inside a
* successful result*; a failed call must propagate as an error, not a
* fabricated empty success.
*/
async analyzeDocument(buf: Buffer, modelId: string): Promise<DocumentIntelligenceResult> {
const apiVersion = this.config.apiVersion || "2024-11-30";
const base = this.config.endpoint.replace(/\/$/, "");
const analyzeUrl = `${base}/documentintelligence/documentModels/${encodeURIComponent(modelId)}:analyze?api-version=${apiVersion}`;
const maxAnalyzeAttempts = this.config.maxAnalyzeAttempts ?? 4;
const rateLimitRetryMs = this.config.rateLimitRetryMs ?? 6000;
const pollIntervalMs = this.config.pollIntervalMs ?? 2000;
const maxPollAttempts = this.config.maxPollAttempts ?? 40;
let operationLocation = "";
for (let attempt = 0; attempt < maxAnalyzeAttempts; attempt++) {
const res = await fetch(analyzeUrl, {
method: "POST",
headers: { "Ocp-Apim-Subscription-Key": this.config.apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ base64Source: buf.toString("base64") }),
});
if (res.status === 429) {
await sleep(rateLimitRetryMs);
continue;
}
if (res.status !== 202) {
const text = await res.text().catch(() => "");
throw new Error(`Document Intelligence analyze request failed: ${res.status} ${text.slice(0, 300)}`);
}
operationLocation = res.headers.get("operation-location") || "";
break;
}
if (!operationLocation) {
throw new Error(
"Document Intelligence analyze did not return an operation-location (rate-limited after retries, or misconfigured)",
);
}
let payload: Record<string, unknown> | null = null;
for (let i = 0; i < maxPollAttempts; i++) {
await sleep(pollIntervalMs);
const pollRes = await fetch(operationLocation, { headers: { "Ocp-Apim-Subscription-Key": this.config.apiKey } });
if (!pollRes.ok) {
const text = await pollRes.text().catch(() => "");
throw new Error(`Document Intelligence poll request failed: ${pollRes.status} ${text.slice(0, 300)}`);
}
const json = (await pollRes.json()) as Record<string, unknown>;
if (json.status === "succeeded") {
payload = json;
break;
}
if (json.status === "failed") {
throw new Error(`Document Intelligence analyze failed: ${JSON.stringify(json.error ?? json).slice(0, 500)}`);
}
}
if (!payload) {
throw new Error("Document Intelligence analyze timed out waiting for a result");
}
const analyzeResult = (payload.analyzeResult as Record<string, unknown>) ?? {};
const documents = analyzeResult.documents as Array<Record<string, unknown>> | undefined;
const doc = documents?.[0];
const rawFields = (doc?.fields as Record<string, unknown>) ?? {};
const fields: Record<string, ExtractedField> = {};
const arrayFields: Record<string, Array<Record<string, ExtractedField>>> = {};
for (const [key, raw] of Object.entries(rawFields)) {
const f = raw as Record<string, unknown>;
if (f?.type === "array" && Array.isArray(f.valueArray)) {
arrayFields[key] = (f.valueArray as Array<Record<string, unknown>>).map((item) =>
normalizeObjectFields(item?.valueObject as Record<string, unknown> | undefined),
);
} else {
fields[key] = normalizeField(f);
}
}
const pages = analyzeResult.pages as unknown[] | undefined;
return {
modelId,
apiVersion,
content: typeof analyzeResult.content === "string" ? (analyzeResult.content as string) : null,
fields,
arrayFields,
documentConfidence: typeof doc?.confidence === "number" ? (doc.confidence as number) : null,
pageCount: Array.isArray(pages) ? pages.length : null,
};
}
}