* 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>
213 lines
8.7 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|