Author a Classify-and-Route Analyzer (mixed document packets)
[EXPERIMENTAL - REQUIRED FIRST MESSAGE]: Before asking questions, probing the environment, or running commands, tell the user: "This analyzer-authoring skill is experimental and may change. Review generated schemas and test results before relying on them."
Build a classify-and-route pipeline: one outer classifier analyzer that segments and labels a multi-document packet, plus one inner extractor analyzer per document type. The packet flows through the outer analyzer once; each segment is automatically routed to the matching inner analyzer for field extraction.
This is an iterative, human-in-the-loop workflow. You will typically run the schema → test → review cycle multiple times to refine both the outer classifier descriptions and each inner schema's field descriptions before you're happy with both classification accuracy and extraction quality.
[USE INSTEAD]: If every page in the user's documents is the same type (only invoices, only contracts, etc.), use
cu-sdk-author-analyzerinstead. Classify-and-route is for mixed packets.
[ASK USER] Modality check (first thing to confirm):
"Are you working with document files — PDFs or images of pages? This skill supports document modalities only."
- If the user says document → continue with this skill.
- If the user says audio, video, or image → stop this skill. Point them at the REST tutorial.
[COPILOT INTERACTION MODEL]: At each step marked with [ASK USER], pause execution and prompt the user before proceeding.
Prerequisites
Required: venv active, SDK installed, .env with CONTENTUNDERSTANDING_ENDPOINT
(plus CONTENTUNDERSTANDING_KEY or az login), and samples/sample_update_defaults.py
run once for this resource. Full setup lives in cu-sdk-setup.
[COPILOT] Probe first, then route on failure — do not duplicate setup logic here.
python -c "import sys; print('venv:', 'ok' if sys.prefix != sys.base_prefix else 'INACTIVE')" ( [ -f .env ] && grep -E '^CONTENTUNDERSTANDING_ENDPOINT=https?://' .env >/dev/null && echo 'endpoint: ok' ) || echo 'endpoint: MISSING' ( [ -f .env ] && grep -E '^CONTENTUNDERSTANDING_KEY=.+' .env >/dev/null && echo 'key: set' ) || echo 'key: empty' az account show >/dev/null 2>&1 && echo 'az: ok' || echo 'az: not logged in'
Failure Route to venv INACTIVEorazure.ai.contentunderstandingimport failscu-sdk-setupSteps 2–3, then resumeendpoint MISSINGcu-sdk-setupStep 4 (env vars), then resumeendpoint ok, keyempty,az: not logged incu-sdk-setupStep 4 auth section (runaz loginor addCONTENTUNDERSTANDING_KEYto.env), then resumeAll env checks pass but service calls fail with model errors cu-sdk-setupStep 6 (sample_update_defaults.py), then resumeAll ok ✅ Proceed to the Packet check below. Never ask the user to paste an endpoint or API key into chat — they edit
.envdirectly or runaz login.
[ASK USER] Packet check:
- "Does each document in your packet contain more than one type of form (e.g. an invoice page followed by a bank statement page)?" — if no, route to
cu-sdk-author-analyzer.- "What types of documents appear in your packets?" — capture as the list of inner analyzers.
Architecture
┌──────────────────────────────┐
mixed packet ───► │ outer (classifier) analyzer │
│ baseAnalyzerId: prebuilt-… │
│ config.enableSegment: true │
│ config.contentCategories: │
│ invoice ────────►│──┐
│ bank_statement ────────►│──┼──► per-segment fields
│ loan_application ────────►│──┘
└──────────────────────────────┘
│
inner analyzers (1 per type) │
─────────────────────────── ▼
invoice extractor ◄──── routes here for invoice pages
bank statement ext. ◄──── routes here for bank pages
loan app extractor ◄──── routes here for loan pages
Key rules (also captured in
cu-sdk-common-knowledge §
"classify-and-route"):
- Category descriptions reference text anchors, not visual cues (matches the two-stage pipeline rule for fields).
config.enableSegmentmust betrueso the classifier can carve up the packet before routing.- Inner analyzers must exist before the outer classifier is created. The provided script handles ordering automatically.
- Category fill rate is per-category, not packet-wide. The script's stdout summary uses the right denominator.
Package directory
sdk/contentunderstanding/azure-ai-contentunderstanding
Scripts and templates
.github/skills/cu-sdk-author-analyzer-classify-route/
├── scripts/
│ ├── create_and_test_router.py
│ └── create_and_test_router.sh
└── templates/
└── classifier_template.json # Starter outer-classifier schema for Step 3
Workflow
Step 1 — Identify the document types
Run layout extraction (same as the single-type skill) on a representative packet to see the section headings:
python .github/skills/cu-sdk-author-analyzer/scripts/extract_layout.py \
--input <packet.pdf> \
--output .local_only/layout/
[COPILOT] Read
.local_only/layout/<packet>.layout.mdyourself and identify section headings, page breaks, and content shifts that suggest different document types. Then present your analysis to the user for confirmation — do not ask the user to read the layout cold.Example presentation:
"Based on the layout in
<packet>.layout.mdI see these document types:
- Pages 1–2 — appears to be an invoice (top heading 'Invoice', contains 'Invoice #' label and a line-item table)
- Pages 3–4 — appears to be a bank statement (top heading 'Account Statement', contains account number and a transaction table)
- Page 5 — appears to be a loan application (top heading 'Loan Application', applicant fields)
Does this look right? Anything to add, remove, or rename before I draft schemas?"
Only fall back to a blank
[ASK USER]("What types do you see?") if the layout is too ambiguous to suggest types confidently.
Step 2 — Draft one inner schema per type
Treat each type as a single-doc-type analyzer (pick baseAnalyzerId from
the table in
cu-sdk-common-knowledge § Choosing baseAnalyzerId,
then add fieldSchema.fields). Field descriptions follow the
two-stage pipeline rule
— reference text and structure, never visual appearance. See
cu-sdk-author-analyzer Step 2 for
the full field-schema walkthrough.
[COPILOT] Read best practices before drafting fields. Before writing any field description (in any inner schema or the outer classifier's category descriptions), fetch the official CU best-practices page and apply its guidance:
🔗 https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/best-practices
The same key principles apply here as in
cu-sdk-author-analyzer: be specific and concrete, use text anchors (not visual cues), include alternative labels and format examples, preferextractfor verbatim values, and keep the field count focused.
Reference:
samples/sample_create_classifier.pyships a complete worked example usingsamples/sample_files/mixed_financial_docs.pdfwith three categories — Invoice, Bank_Statement, Loan_Application.
Step 3 — Draft the outer classifier schema
The outer schema has no fieldSchema. Its job is classification + routing.
Start from the template:
mkdir -p .local_only/schemas
cp .github/skills/cu-sdk-author-analyzer-classify-route/templates/classifier_template.json \
.local_only/schemas/<name>_classifier_v1.json
Example after editing (descriptions intentionally generic so they survive minor wording variants — see the Category description rule below the example for guidance on writing your own):
{
"baseAnalyzerId": "prebuilt-document",
"description": "Classify mixed financial packets and route to per-type extractors.",
"config": {
"enableSegment": true,
"omitContent": true,
"contentCategories": {
"invoice": {
"description": "A commercial invoice. Look for an invoice / bill heading or label, an invoice number, line-item table with quantities and prices, and a total amount.",
"analyzerId": "invoice"
},
"bank_statement": {
"description": "A bank or account statement. Look for an account-statement heading, an account number, a statement period or date range, and a transaction table with running balance.",
"analyzerId": "bank_statement"
},
"loan_application": {
"description": "A loan or credit application form. Look for an application heading, applicant or borrower fields, requested loan amount, and applicant signature.",
"analyzerId": "loan_application"
}
}
},
"models": {
"completion": "gpt-5.2",
"embedding": "text-embedding-3-large"
}
}
The analyzerId value in each category is an alias that the script
resolves at runtime, matching the --inner-schema alias=path flags you
pass. Two exceptions skip alias resolution:
- Values starting with
prebuilt-(e.g.prebuilt-invoice) are used as-is — no inner schema needed. Useful for routing a category at a service prebuilt extractor. - Categories without an
analyzerIdat all are classification-only — the segment is labelled but no fields are extracted.
Why
omitContent: true? When omitted, the service also returns the raw, un-segmented document content as an extra entry incontents. That entry has no category, no fields, and shows up in the summary as a confusing(uncategorized)row. SettingomitContent: trueremoves it.
Category description rule — the example values above are demo-specific.
The category descriptions in the JSON above are tied to the demo packet
samples/sample_files/mixed_financial_docs.pdf(which uses the literal headingsInvoice,Bank Statement,Loan Application). Do not copy them verbatim. When authoring for the user's own packet, write descriptions based on what you observed in the user's layout output from Step 1 — use the actual headings, labels, and structural markers from their documents.Keep descriptions:
- Generic over surface form — describe the kind of content ("contains a transaction table with running balance") rather than hardcoding one specific header string, so minor wording variants still classify correctly.
- Concrete enough to be discriminative — include at least one anchor that distinguishes this category from the others in the packet.
- Text-anchored, not visual — reference headings, labels, and neighbouring text, never colour / font / position-without-text. Same reason as the field-description rule in
cu-sdk-common-knowledge.
Step 4 — Validate, create, and batch-test
python .github/skills/cu-sdk-author-analyzer-classify-route/scripts/create_and_test_router.py \
--outer-schema .local_only/schemas/classifier.json \
--inner-schema invoice=.local_only/schemas/invoice.json \
--inner-schema bank_statement=.local_only/schemas/bank_statement.json \
--inner-schema loan_application=.local_only/schemas/loan_application.json \
--input samples/sample_files/mixed_financial_docs.pdf \
--output .local_only/test_results/v1
Shortcut —
--schema-dir: if your inner schema filenames match the outer-schema category aliases (e.g..local_only/schemas/invoice_v1.jsonfor categoryinvoice), replace every--inner-schema alias=pathwith a single--schema-dir .local_only/schemas/. The script picks the newest matching file per alias (alphabetical sort, soinvoice_v2.jsonwins overinvoice_v1.json).
Iteration helper —
--reuse: add--reuseto name analyzers by a sha1 of their schema (<stem>_<hash[:8]>) and skip creation when an analyzer with that ID already exists. Re-running with the same schemas is a no-op on the create side, so you don't pile up stale analyzers while iterating. Edit a schema → hash changes → new analyzer is created.
The script:
- Validates every schema (exits with code 2 if any fails — no service call made).
- Errors out if the outer schema references an alias that has no matching
--inner-schema, or if you supply an--inner-schemathat no category uses. - Creates inner analyzers first, then patches and creates the outer classifier.
- Analyzes every input file, writing one JSON per file under
--output. - Prints a category-aware stdout summary (per-category fill rate uses each category's segment count, not the packet-wide total).
Step 5 — Read the category-aware summary
Example output:
========================================================================
[SUMMARY] (category-aware)
category: bank_statement (1 segments)
--------------------------------------
field fill rate avg conf
AccountNumber 100.0% 0.918
StatementPeriod 100.0% 0.882
category: invoice (1 segments)
-------------------------------
field fill rate avg conf
InvoiceNumber 100.0% 0.962
TotalAmount 100.0% 0.531
category: loan_application (1 segments)
----------------------------------------
field fill rate avg conf
ApplicantName 100.0% 0.875
LoanAmount 100.0% 0.799
lowest-confidence fields across all categories:
0.531 [invoice] TotalAmount (mixed_financial_docs)
0.799 [loan_application] LoanAmount (mixed_financial_docs)
0.875 [loan_application] ApplicantName (mixed_financial_docs)
========================================================================
For each input document the script writes two files into --output:
<doc>.json— fullAnalysisResultwith all per-segment fields and grounding.<doc>.llm.md— the same result rendered via the SDK'sto_llm_inputhelper. For classify-and-route, the helper expands each classified segment into its own block with the category in the YAML front matter, separated by*****dividers — drop it into an LLM prompt or skim it in VS Code.
Template comment keys: any key whose name starts with
_(e.g._comment,_optional_returnDetails) is stripped from both the outer and inner schemas before the request body is sent. Use them freely as inline documentation.
models.completionfor inner schemas: each inner schema (which has afieldSchema) needsmodels.completionset unless the resource has defaults configured viasamples/sample_update_defaults.py.create_and_test_router.pyprints a[WARN]per inner schema that is missing it, before any service call.
Step 6 — Agent review and iterate
[COPILOT] After the category-aware summary prints, do the following automatically before asking the user anything:
- For each entry in
result.contents[]in<doc>.json, verify the assignedcategorymatches what the page actually is. Use.local_only/layout/<doc>.layout.mdas ground truth.
- Misclassified segment → the outer classifier's
contentCategories.<key>.descriptionneeds strengthening, not the inner schema. Add a discriminating anchor from the layout to the category description and recreate the outer classifier.- For each correctly-classified segment, compare extracted field values against the layout the same way as the single-type skill (see
cu-sdk-author-analyzer). Flag fields where:
- The extracted value looks wrong or is
nullwhen a value should be present- Confidence is below 0.7
- The grounding location is unexpected
- Present a per-category diff to the user:
- "In the
invoicesegment, fieldTotalAmountextracted'1234'— I see'$1,234.56'near the 'Total' label. Should I tighten the description?"- "Page 5 was classified as
loan_applicationbut the heading is actually 'Borrower Agreement' — should I add that wording to theloan_applicationcategory description?"- Record user-confirmed corrections in per-category ground-truth files (
.local_only/ground_truth_<category>.json) so the agent remembers correct answers across iterations:[ { "doc": "mixed_packet_1.pdf", "segment": 0, "field": "TotalAmount", "correct_value": "1234.56" } ]- Update the affected schema(s) — outer classifier for classification fixes, inner schemas for field fixes — as a new version (
*_v2.json).- Re-run Step 4 with the new schemas.
--reuserebuilds only the analyzers whose schema hash changed.Repeat until every category has:
- All segments classified correctly
- Key fields fill rate ≥ 80% and avg confidence ≥ 0.85 (computed per-category, as the summary already does)
Stop and report to the user when any of:
- All targets met (success) — then proceed to Step 7 to hand off.
- Three consecutive iterations show no improvement on a given category (escalate — may need a different
baseAnalyzerId, schema redesign, or a different category split).- The user signals they're done.
Step 7 — Hand off the finished pipeline
This step is required when Step 6 succeeds. Do not skip it. Without a clean handoff the user has a working classify-and-route pipeline in their resource but no idea what the outer classifier ID is, which inner analyzers it routes to, where the final schemas live, or how to call it from their own code.
[COPILOT] When Step 6 reaches success, report the following to the user in one message before stopping:
All analyzer IDs built — list both the outer classifier and every inner extractor, with the actual IDs printed by
create_and_test_router.py(e.g.classifier_v2_a1b2c3d4→ routes toinvoice_v3_b2c3d4e5,bank_statement_v2_c3d4e5f6,loan_application_v3_d4e5f6a7). The user only needs to call the outer analyzer in their own code; it routes to the inner ones automatically.All schema files — list the path to every schema JSON in the final iteration: the outer classifier and each inner schema (e.g.
.local_only/schemas/classifier_v2.json,.local_only/schemas/invoice_v3.json, etc.). Recommend the user save them somewhere outside.local_only/for future reference; they can also inspect any existing analyzer's schema directly via the SDK (seesamples/sample_get_analyzer.py).Next-step samples — point the user to:
samples/sample_create_classifier.py— how to (re)build the full classify-and-route pipeline from schema JSON in their own code (handles inner-first creation ordering andcontentCategories.analyzerIdwiring the same way our script did).samples/sample_analyze_binary.pyandsamples/sample_analyze_url.py— how to call the analyzer on real input from their own code. Use the outer classifier's analyzer ID asanalyzer_id.Remind the user that the analyzers you just built are already deployed to their resource and ready to use directly via their IDs — they only need to re-create them from the schemas if they want to bootstrap into another resource.
Step 8 — Clean up (optional)
By default the script leaves both the outer classifier and all inner
analyzers in your resource so you can re-use them. Pass --ephemeral to
delete all of them at the end of the run.
Exit codes
| Code | Meaning |
|---|---|
0 |
Every document analyzed successfully. |
1 |
At least one service-side failure. |
2 |
Local user error — schema validation, missing inner alias, bad path. No service call made. |
Related skills
cu-sdk-author-analyzer— single doc type.cu-sdk-common-knowledge— service concepts, two-stage pipeline,baseAnalyzerIdtable, classify-and-route rules.cu-sdk-sample-run— run individual SDK samples (e.g.sample_create_classifier.py) for deeper reference.cu-sdk-setup— install the SDK, configure env.