|
1 | 1 | # E03 — Azure Content Understanding |
2 | 2 |
|
3 | 3 | **Branch**: `experiment/03-content-understanding` — branched from `experiment/02-mistral-doc-ai-azure` (chained stack) |
4 | | -**Read first**: `experiments/briefs/_shared-rules.md` |
| 4 | +**Read first**: `experiments/briefs/_shared-rules.md` — the lessons-learned subsections (production-grade prompts, iteration kit pattern, force-resync runbook entry, sync-provider cache emission, and Foundry quota retry tuning) all apply directly. **Read those before writing any code.** |
5 | 5 |
|
6 | 6 | ## Goal |
7 | 7 |
|
8 | | -Test Azure Content Understanding (CU) as a product against the seeded handwritten-form dataset. Add a new provider at `apps/temporal/src/ocr-providers/azure-content-understanding/` that deploys our canonical schema as a CU "analyzer," submits documents to it, and maps results to canonical `OCRResult`. |
| 8 | +Test Azure Content Understanding (CU) as a product against the seeded handwritten-form dataset. Add a new provider at `apps/temporal/src/ocr-providers/azure-content-understanding/` that deploys our canonical schema as a CU "analyzer," submits documents to it, and maps results to canonical `OCRResult`. Compare against E01 (custom-trained neural Azure DI) and E02 (Mistral on Foundry) in the cross-engine consolidation after E05. |
9 | 9 |
|
10 | 10 | ## What CU is (and isn't) |
11 | 11 |
|
12 | 12 | CU is **not** something you build on top of Azure Foundry. It's a generative AI product where you POST a JSON "analyzer" describing your schema, then submit documents to its analyze endpoint. CU internally: |
13 | 13 |
|
14 | | -1. Performs OCR (its own ML-based layout extraction) |
15 | | -2. Sends extracted content + the analyzer schema to a generative model (GPT-Vision or similar) |
16 | | -3. Returns structured JSON conforming to the schema, with confidence scores and grounding citations |
| 14 | +1. Performs OCR (its own ML-based layout extraction). |
| 15 | +2. Sends extracted content + the analyzer schema to a generative model (GPT-Vision or similar). |
| 16 | +3. Returns structured JSON conforming to the schema, with confidence scores and grounding citations. |
17 | 17 |
|
18 | 18 | Pricing splits content-extraction charges (OCR layer) from generative-model token charges (LLM layer). Source: [Microsoft Learn — Content Understanding overview](https://learn.microsoft.com/en-us/azure/ai-services/content-understanding/overview). |
19 | 19 |
|
20 | 20 | ## Tasks |
21 | 21 |
|
22 | | -1. **Read the Mistral Azure provider** (E02 should be done first; if not, read the public Mistral provider). The Mistral Azure provider is the closest pattern: Foundry-style auth, schema-aware engine, async polling. |
| 22 | +1. **Read the Mistral Azure provider** thoroughly — it's the closest pattern: Foundry-style auth, schema-aware engine, structured-output schema with per-field descriptions + global prompt + nullable-numerics flag. The activity at `apps/temporal/src/ocr-providers/mistral-azure/mistral-azure-ocr-process.ts` and the schema converter at `apps/temporal/src/ocr-providers/mistral/field-definitions-to-mistral-annotation-format.ts` are the references. CU is async (poll-based) instead of sync, but the rest of the pattern transfers. |
23 | 23 |
|
24 | | -2. **Create `apps/temporal/src/ocr-providers/azure-content-understanding/`** with: |
25 | | - - `analyzer-schema-builder.ts` — converts our `FieldDefinition[]` to CU analyzer JSON. CU's analyzer schema vocabulary is documented in Microsoft Learn; map our `field_type` (string/number/date/etc.) to CU's field-type vocabulary, and our `field_format` to CU's value-format hints. |
| 24 | +2. **Capture a real CU response BEFORE writing the mapper** (per the runbook: "Don't trust the brief's preamble until you've inspected one real response"). Either: |
| 25 | + - Use the smoke-test script you'll write in step 3 below, OR |
| 26 | + - One-shot `curl` against the analyze endpoint with one sample. |
| 27 | + Save the raw response to `apps/temporal/src/__fixtures__/experiment-03/cu-response-1-81.json`. Build the mapper against the actual shape, not against assumed fields. CU's response includes `documents[*].fields[*]` with `type`, `valueString` / `valueNumber` / `valueArray`, `confidence`, and `spans` (grounding citations) — but the exact shape varies by API version. Anchor the mapper to the captured fixture. |
| 28 | + |
| 29 | +3. **Set up the iteration kit before any benchmark run.** |
| 30 | + - Copy `experiments/results/02-mistral-doc-ai-azure/iteration/` to `experiments/results/03-content-understanding/iteration/`. Replace the Mistral-specific bits in `prompt.md` with CU equivalents (CU's analyzer schema accepts a top-level `description` plus per-field `description` strings — same conceptual surface, different keys). |
| 31 | + - Add `apps/temporal/src/scripts/iterate-cu-extraction.ts` lifted from `iterate-mistral-extraction.ts`. Same skeleton: load image + ground truth + iteration files, build the analyzer schema, call CU, compare, dump diff. |
| 32 | + - Smoke-test on `synth-full (1)` (synth samples are typed and clean — ideal for prompt tuning). Iterate until per-field accuracy ≥ 95% before triggering the full benchmark. |
| 33 | + |
| 34 | +4. **Create `apps/temporal/src/ocr-providers/azure-content-understanding/`** with: |
| 35 | + - `analyzer-schema-builder.ts` — converts our `FieldDefinition[]` to CU analyzer JSON. CU's analyzer schema vocabulary is documented in Microsoft Learn; map our `field_type` (string/number/date/etc.) to CU's field-type vocabulary, and our `field_format` to CU's value-format hints. **Accept a `descriptions` overlay (per-field) and a global instruction string.** Pattern from `field-definitions-to-mistral-annotation-format.ts`. |
26 | 36 | - `azure-cu-deploy-analyzer.ts` — idempotently deploy an analyzer named `${AZURE_CU_ANALYZER_PREFIX}-${templateModelKey}`. POST if not present; PATCH if schema differs; no-op if identical. Cache deployment status in memory. |
27 | | - - `azure-cu-analyze.ts` (the activity) — submit a document to the analyze endpoint, poll for completion (CU is async), fetch the result. |
| 37 | + - `azure-cu-analyze.ts` (the activity) — submit a document to the analyze endpoint, poll for completion (CU is async), fetch the result. Activity must accept `documentAnnotationPrompt`, `fieldDescriptions`, and any nullable-numerics flag, and forward them to the schema builder. Return both `ocrResult` and `cuResponse` (raw, for the cache-persistence path). |
28 | 38 | - `cu-to-ocr-result.ts` — map CU response (which already has structured fields) to canonical `OCRResult`. The `documents[0].fields` shape carries the structured fields; populate `keyValuePairs` for downstream compatibility too. |
29 | 39 |
|
30 | | -3. **Register activity types** in `apps/temporal/src/activity-registry.ts`: |
31 | | - - `azureContentUnderstanding.deployAnalyzer` (one-time per template, idempotent) |
32 | | - - `azureContentUnderstanding.analyze` (per document; async polling internally) |
| 40 | +5. **Register activity types** in all three registries (per `ADDING_OCR_PROVIDERS.md`): |
| 41 | + - `azureContentUnderstanding.deployAnalyzer` (one-time per template, idempotent; short timeout, ~3 retries) |
| 42 | + - `azureContentUnderstanding.analyze` (per document; **30 attempts × 15 s × 1.5x × 60 s cap retry** for the same Foundry-quota reasons as `mistralAzureOcr.process`; generous startToClose, ~20 m to absorb slow analyses) |
| 43 | + |
| 44 | +6. **Define a workflow graph** at `docs-md/graph-workflows/templates/experiment-03-content-understanding-workflow.json`. CU is async (analyze → poll → fetch), but if the SDK exposes a single "wait for completion" call you can use the sync pattern. Either way: |
| 45 | + - Start from `experiment-02-mistral-doc-ai-azure-workflow.json` for the post-processing chain (cleanup → checkConfidence → reviewSwitch → store) and the **`ocrResponse` output port** for cache persistence. |
| 46 | + - Replace `mistralAzureOcr.process` with `azureContentUnderstanding.analyze` (and add `azureContentUnderstanding.deployAnalyzer` upstream if needed, gated on a one-shot ctx flag). |
| 47 | + - Set `templateModelId` default to `seed-sdpr-monthly-report-template` (NOT a stale UUID — see the runbook entry). |
| 48 | + - Embed `documentAnnotationPrompt` + `fieldDescriptions` + any flags as `parameters` on the analyze activity node, sourced from the iteration kit content. |
| 49 | + |
| 50 | +7. **Pre-flight check after the first sample completes.** Per the runbook's "verify the structured-output mode is actually running" item: pull ONE row from `benchmark_ocr_cache`, confirm CU's response has populated structured fields. If empty, search Microsoft Q&A for the analyzer-config strictness flag (CU likely has its own equivalent of `strict: true`). |
| 51 | + |
| 52 | +8. **Run the workflow on one real document end-to-end** through the iteration kit. Verify per-field accuracy ≥ 90% on `synth-full (1)`. If not, iterate prompts before scaling up. |
| 53 | + |
| 54 | +9. **Run a benchmark programmatically** via `./scripts/run-experiment-benchmarks.sh 03`. Tag is auto-set by the script. Save the export to `experiments/results/03-content-understanding/benchmark-run.json`. |
| 55 | + |
| 56 | +10. **Mock-based tests** at `apps/temporal/src/experiment-03-content-understanding.test.ts` — same two-layer pattern (static template assertions + runtime against local Temporal cluster, gated on `process.env.CI`). Replay the recorded fixture from step 2. |
| 57 | + |
| 58 | +11. **Write `experiments/results/03-content-understanding/SUMMARY.md`** including: |
| 59 | + - Analyzer schema deployed, including which CU vocabulary maps to which of our field types |
| 60 | + - Fields with grounding-citation hits vs misses (CU's distinguishing feature vs E02) |
| 61 | + - Observations on CU's confidence scores vs other engines |
| 62 | + - Per-experiment retrospective subsection (lessons learned during E03; same template as E02's retrospective) |
| 63 | + - Concrete `_shared-rules.md` updates and E04 implications |
| 64 | + |
| 65 | +## Pre-emptive guidance from E02 lessons |
| 66 | + |
| 67 | +These specific gotchas tripped us up on E02 — apply ahead of time: |
| 68 | + |
| 69 | +1. **Verify the strict-mode equivalent immediately.** Mistral on Foundry needed `json_schema.strict: true`; Foundry silently skipped annotation otherwise. CU's analyzer config has its own analogue. Look for `strict`, `allowAdditionalFields`, or similar flags. The single-document smoke test will surface this in seconds. |
33 | 70 |
|
34 | | -4. **New env vars** are already declared in `.env.sample` on the parent: `AZURE_CU_ENDPOINT`, `AZURE_CU_KEY`, `AZURE_CU_ANALYZER_PREFIX`. User has populated their override file. |
| 71 | +2. **`Authorization: Bearer` is the Foundry header.** Don't reach for `api-key` until you've checked an existing client. |
35 | 72 |
|
36 | | -5. **Define a workflow graph** at `docs-md/graph-workflows/templates/experiment-03-content-understanding-workflow.json`. CU is async (analyze → poll → fetch), so use the **async pattern from `standard-ocr-workflow.json`** as the template (file.prepare → submit → pollUntil(poll) → extract → cleanup → checkConfidence → reviewSwitch → store). Replace the Azure DI nodes with `azureContentUnderstanding.deployAnalyzer` (one-time, idempotent) → `azureContentUnderstanding.analyze`. Most post-processors expect text-level `OCRResult` shape; CU's structured output may bypass some — document which post-processors apply and which don't in `SUMMARY.md`. The auto-discovery seed picks up the JSON automatically. |
| 73 | +3. **Foundry RPM quota is ~10 by default.** The per-activity retry policy MUST be 30 × 15 s / 1.5x / 60 s cap (or equivalent) before the first 33+ sample benchmark. The activity-registry default for `mistralAzureOcr.process` is the canonical example. |
37 | 74 |
|
38 | | -6. **Run the workflow** on one real document end-to-end. |
| 75 | +4. **Numeric-field nullability.** Our SDPR ground truth uses `""` for blank cells and `"0"` for explicit zeros — distinct values. If CU's analyzer schema requires a number, you can't represent blank. Add `numericFieldsNullable` support to the analyzer-schema-builder; emit `["number", "null"]` (or CU's equivalent) when the toggle is on. |
39 | 76 |
|
40 | | -7. **Run a benchmark programmatically**. Tag with `experiment-03-content-understanding`. |
| 77 | +5. **Sync vs async cache emission.** Whether CU's activity is sync or async, return `{ ocrResult, cuResponse }` and wire `cuResponse → ctx.ocrResponse` so `benchmark-sample-workflow.ts`'s `persistOcrCache` step writes a row per sample. Without this, fixture capture and replay break. |
41 | 78 |
|
42 | | -8. **Mock-based tests** — record CU's response once, replay. |
| 79 | +6. **Force-resync after dataset edits.** If you add or rename samples in `data/datasets/samples-mix/private/`, run `FORCE_RESYNC_LOCAL_DATASETS=true npm run start:dev` once. Drop the env var on the next start. |
43 | 80 |
|
44 | | -9. **Write `experiments/results/03-azure-content-understanding/SUMMARY.md`** including: analyzer schema deployed, fields with grounding-citation hits vs misses, observations on CU's confidence scores vs other engines. |
| 81 | +7. **Capture a real CU response before writing the mapper.** The brief's preamble below describes what CU *should* return; reality may differ by API version. Run a one-shot curl or the iteration script first; build the mapper against the captured fixture; replay it in the unit tests. |
45 | 82 |
|
46 | 83 | ## Architecture pattern note |
47 | 84 |
|
48 | | -E05 will explicitly recreate the CU pattern (Azure DI Read + LLM + schema) with our own components, so we can compare CU-as-a-product against our self-assembled equivalent. |
| 85 | +E05 will explicitly recreate the CU pattern (Azure DI Read + LLM + schema) with our own components, so we can compare CU-as-a-product against our self-assembled equivalent. Keep the analyzer-schema-builder in E03 cleanly separated so E05 can study it. |
49 | 86 |
|
50 | 87 | ## Watch for |
51 | 88 |
|
52 | | -- **Analyzer schema deployment is a learning curve**. The vocabulary (`type`, `description`, `valueFormat`, `methodCallout`) is specific. Test with a small subset first. |
| 89 | +- **Analyzer schema deployment is a learning curve.** The vocabulary (`type`, `description`, `valueFormat`, `methodCallout`) is specific. Test with a small subset first (3–5 fields) before deploying the full 74-field SDPR schema. |
53 | 90 | - **Cost on long documents** is surprising — both layers bill. |
54 | | -- **Confidence scores aren't directly comparable** to DI's or Mistral's. The audit doc on the parent describes this; ensure your `OCRResult` mapper normalizes to 0–1 for `check-ocr-confidence.ts`. |
55 | | -- **CU is async** — analyze endpoint returns 202 + operation-location header. Poll with exponential backoff; respect the `Retry-After` header. Default timeout should be generous (30s+) per page. |
| 91 | +- **Confidence scores aren't directly comparable** to DI's or Mistral's. Ensure your `OCRResult` mapper normalises to 0–1 for `check-ocr-confidence.ts`. |
| 92 | +- **CU is async** — analyze endpoint returns 202 + operation-location header. Poll with exponential backoff; respect the `Retry-After` header. Default timeout should be generous (30 s+) per page. |
56 | 93 | - **Multiple input documents** in a single request is supported in CU "Pro mode" (preview API version) but not needed here. Stick with single-doc-per-call. |
57 | 94 |
|
58 | 95 | ## Cross-engine audit follow-through |
59 | 96 |
|
60 | | -Parent-branch `docs-md/EXTRACTION_PROVIDER_ARCHITECTURE.md` lists gaps in existing engines. CU is the third engine to land; if any "shared-concern" gaps surface during this work that the parent audit missed, raise them back rather than silently fixing. |
| 97 | +Parent-branch `docs-md/EXTRACTION_PROVIDER_ARCHITECTURE.md` lists gaps in existing engines. CU is the third engine to land; if any "shared-concern" gaps surface during this work that the parent audit missed, raise them in your SUMMARY.md retrospective rather than silently fixing. |
0 commit comments