cu analyze: default analyzer by file type, markdown anchors, --format both, cu resolve, result deletion - #26
Arlind Nocaj (ArlindNocaj) wants to merge 3 commits into
Conversation
… both, cu resolve, result deletion (#2) Makes cu-cli usable by an agent with nothing but CU_ENDPOINT. Additive on top of the existing analyze command; no flags or behaviour removed. - Without --analyzer, cu analyze uses CU_DEFAULT_ANALYZER, the profile default_analyzer, or a default by file type (prebuilt-documentSearch for documents, prebuilt-*Search otherwise) instead of failing. - Markdown output carries <!--sN-->/<!--tN-->/<!--fN--> anchors before sections, tables and figures; --level paragraph adds <!--pN-->. Ids are the service-result array indices; stripping the anchors yields the previous markdown. Page and classification-segment spans are shifted so the SDK still slices correctly. - --format md|json|both selects the view (--json = --format json, --llm-input stays as a hidden alias of --format md). `--format both --output-file NAME` writes NAME.md and NAME.json from one billed call; with --output-dir every input gets .result.md and .result.json. Written paths are printed to stdout, one per line (diagnostics stay on stderr). - New `cu resolve RESULT.json ID... [--around N]`: page, bbox, text and neighbouring blocks, offline. Sections report the pages of their blocks. - Results are deleted on the service after retrieval (--keep-result opts out), using the poller's operation id; the id is omitted from JSON output. - Root README: Quickstart section. cu-cli README/CHANGELOG: short additions. Analyze cassettes re-recorded with the DELETE call. Co-authored-by: Arlind Nocaj <2978097+ArlindNocaj@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 28e0a742-7dd0-43e4-8531-72d7aa37ff9a
…n GitHub (#3) Co-authored-by: Arlind Nocaj <2978097+ArlindNocaj@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 28e0a742-7dd0-43e4-8531-72d7aa37ff9a
Co-authored-by: Arlind Nocaj <2978097+ArlindNocaj@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 28e0a742-7dd0-43e4-8531-72d7aa37ff9a
|
@microsoft-github-policy-service agree company="Microsoft" |
| Without `--analyzer`, `cu analyze` uses `CU_DEFAULT_ANALYZER`, the profile's | ||
| `default_analyzer`, or — by file type — `prebuilt-documentSearch` for documents | ||
| and `prebuilt-imageSearch` / `-audioSearch` / `-videoSearch` otherwise. Every | ||
| result is deleted from the service right after retrieval (`--keep-result` keeps it). |
There was a problem hiding this comment.
Given that the result will still deleted automatically by CU service after 24 hours, --keep-result can be misleading. Another possible name is --delay-delete
| ## Quickstart | ||
|
|
||
| ```bash | ||
| pip install cu-cli |
There was a problem hiding this comment.
Given that we want this to be a quickstart, we can add the following step to allow user to optionally use a multi-page pdf that they can try right away and show the benefit prebuilt-documentSearch
# Uncomment the following to use a sample file
# curl -L https://raw.githubusercontent.com/Azure-Samples/azure-ai-content-understanding-assets/main/document/financial_table_and_chart.pdf -o doc.pdf
cu analyze doc.pdf
| | I want to… | Command | You get | | ||
| | --- | --- | --- | | ||
| | Read a document as markdown | `cu analyze doc.pdf` | Text, tables, summary, figure descriptions, anchors on sections/tables/figures. Default `-a prebuilt-documentSearch`. | | ||
| | …and cite any paragraph later | `cu analyze doc.pdf --level paragraph --format both --output-file doc` | `doc.md` with `<!--p3-->` per paragraph (+6.5 % tokens) and `doc.json`, one call; paths printed to stdout. | |
There was a problem hiding this comment.
We should not quote the 6.5% token increase as it's not a universal ratio for all files
| | I want to… | Command | You get | | ||
| | --- | --- | --- | | ||
| | Read a document as markdown | `cu analyze doc.pdf` | Text, tables, summary, figure descriptions, anchors on sections/tables/figures. Default `-a prebuilt-documentSearch`. | | ||
| | …and cite any paragraph later | `cu analyze doc.pdf --level paragraph --format both --output-file doc` | `doc.md` with `<!--p3-->` per paragraph (+6.5 % tokens) and `doc.json`, one call; paths printed to stdout. | |
There was a problem hiding this comment.
We should explain what 'p3' means as it's not clear from the context for readers
| | Page + bbox + context of an anchor | `cu resolve doc.json p3 --around 1` | Page, bbox (inches), text, block before/after. Local, no endpoint. | | ||
| | Cheapest OCR + layout, no LLM | `cu analyze doc.pdf -a prebuilt-layout` | Markdown + tables, no summary/figure text. `-a prebuilt-read` for text only. | | ||
| | Which fields are in here? | `cu analyze doc.pdf -a prebuilt-documentFields` | LLM-proposed key/values. `-a prebuilt-documentFieldSchema` proposes a schema. | | ||
| | Extract fields (invoice, receipt, ID…) | `cu analyze invoice.pdf -a prebuilt-invoice --json` | Typed fields with **confidence**, **bounding box** (`source`) and **span**. | |
There was a problem hiding this comment.
In the "You get" column, please add the following link for users to check out supported prebuilt analyzers:
https://learn.microsoft.com/azure/ai-services/content-understanding/concepts/prebuilt-analyzers
| | Cheapest OCR + layout, no LLM | `cu analyze doc.pdf -a prebuilt-layout` | Markdown + tables, no summary/figure text. `-a prebuilt-read` for text only. | | ||
| | Which fields are in here? | `cu analyze doc.pdf -a prebuilt-documentFields` | LLM-proposed key/values. `-a prebuilt-documentFieldSchema` proposes a schema. | | ||
| | Extract fields (invoice, receipt, ID…) | `cu analyze invoice.pdf -a prebuilt-invoice --json` | Typed fields with **confidence**, **bounding box** (`source`) and **span**. | | ||
| | Extract *my* fields | `cu analyzer schema create --from-sample f.pdf …` → `cu analyzer create` | Custom analyzer — [Create a custom analyzer][cu_custom_analyzer]. | |
There was a problem hiding this comment.
Please direct user to this section in README for more details in creating custom analyzer:
https://github.com/Azure/content-understanding-toolkit/blob/main/cu-cli/README.md#create-a-custom-analyzer
| | …and cite any paragraph later | `cu analyze doc.pdf --level paragraph --format both --output-file doc` | `doc.md` with `<!--p3-->` per paragraph (+6.5 % tokens) and `doc.json`, one call; paths printed to stdout. | | ||
| | Page + bbox + context of an anchor | `cu resolve doc.json p3 --around 1` | Page, bbox (inches), text, block before/after. Local, no endpoint. | | ||
| | Cheapest OCR + layout, no LLM | `cu analyze doc.pdf -a prebuilt-layout` | Markdown + tables, no summary/figure text. `-a prebuilt-read` for text only. | | ||
| | Which fields are in here? | `cu analyze doc.pdf -a prebuilt-documentFields` | LLM-proposed key/values. `-a prebuilt-documentFieldSchema` proposes a schema. | |
There was a problem hiding this comment.
We should not mention prebuilt-documentFieldSchema here as it's specifically used as an optional helper when creating custom analyzer, and it's advanced usage, so we do not need to mention it in a quickstart
| | Which fields are in here? | `cu analyze doc.pdf -a prebuilt-documentFields` | LLM-proposed key/values. `-a prebuilt-documentFieldSchema` proposes a schema. | | ||
| | Extract fields (invoice, receipt, ID…) | `cu analyze invoice.pdf -a prebuilt-invoice --json` | Typed fields with **confidence**, **bounding box** (`source`) and **span**. | | ||
| | Extract *my* fields | `cu analyzer schema create --from-sample f.pdf …` → `cu analyzer create` | Custom analyzer — [Create a custom analyzer][cu_custom_analyzer]. | | ||
| | Batch a folder | `cu analyze --source docs/ --format both --output-dir out/ --yes` | `NAME.result.md` + `.result.json` per file, paths on stdout. | |
There was a problem hiding this comment.
For the description for per file, paths on stdout, it is a little too short to be clear for what they mean.
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect Markdown anchors, format validation, output collisions, modality selection, and result cleanup.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR makes cu-cli more agent-friendly with automatic analyzer selection, anchored Markdown, offline resolution, dual-format output, and result cleanup.
Changes:
- Adds modality-based defaults and
CU_DEFAULT_ANALYZER. - Adds rich Markdown anchors and
cu resolve. - Adds
--format both, documentation, tests, recordings, and cleanup behavior.
File summaries
| File | Review summary |
|---|---|
README.md |
Adds Quickstart guidance. |
cu-cli/README.md |
Documents the new CLI workflow and options. |
cu-cli/packages/standalone/tests/unit/test_cli.py |
Tests CLI defaults and output behavior. |
cu-cli/packages/standalone/tests/unit/commands/test_analyze_contract.py |
Updates analyze contract tests. |
cu-cli/packages/standalone/tests/integration/recordings/analyze_single.yaml |
Refreshes single-file playback. |
cu-cli/packages/standalone/tests/integration/recordings/analyze_batch.yaml |
Refreshes batch playback. |
cu-cli/packages/standalone/src/cu_cli/output.py |
Critical (3 votes): SDK result models are not converted before Markdown rendering, so normal Markdown output lacks anchors. |
cu-cli/packages/standalone/src/cu_cli/modality.py |
Moderate (2 votes): .webm is classified as audio despite documented video precedence. |
cu-cli/packages/standalone/src/cu_cli/commands/resolve.py |
Adds offline anchor resolution. |
cu-cli/packages/standalone/src/cu_cli/commands/analyze.py |
Moderate findings: conflicting --llm-input and format flags are not rejected (1 vote); dual-output collision checks cover only JSON (3 votes); conflicting --json and --format md silently selects Markdown (2 votes). |
cu-cli/packages/standalone/src/cu_cli/cli.py |
Registers resolve and updates help. |
cu-cli/packages/core/tests/test_rich_markdown.py |
Tests anchor mapping and resolution. |
cu-cli/packages/core/tests/test_environment.py |
Tests environment registration. |
cu-cli/packages/core/tests/test_analysis.py |
Tests result deletion. |
cu-cli/packages/core/src/cu_cli_core/rich_markdown.py |
Moderate findings (1 vote each): paragraph anchors are omitted inside tables or figures; coarse sections may lack page mappings; malformed coordinates can raise ValueError. |
cu-cli/packages/core/src/cu_cli_core/environment.py |
Registers analyzer environment configuration. |
cu-cli/packages/core/src/cu_cli_core/command_spec.py |
Defines the new command options. |
cu-cli/packages/core/src/cu_cli_core/analysis.py |
Moderate (3 votes): cleanup failures are ignored, so retained service results can be reported as successful deletion. |
cu-cli/CHANGELOG.md |
Records the unreleased changes. |
Review details
Suppressed comments (4)
cu-cli/packages/core/src/cu_cli_core/rich_markdown.py:167
--level paragraphpromises an anchor for every paragraph, but this condition drops every paragraph whose span starts inside a table or figure. Those paragraphs remain in the serviceparagraphsarray and are not resolvable by theirp<i>ids, so the advertised paragraph-level citation coverage is incomplete. Either emit paragraph anchors for these entries or change the public contract and examples to document the exception.
for index, paragraph in enumerate(content.get("paragraphs") or []):
if "span" not in paragraph:
continue
start = int(paragraph["span"]["offset"])
if any(lo <= start < hi for lo, hi in containers):
cu-cli/packages/core/src/cu_cli_core/rich_markdown.py:192
- In coarse mode, paragraph ids are intentionally absent, so a section containing only paragraphs has no child with a bbox. The page calculation therefore leaves
pagesunset for that section even though the section is anchored andcu resolveis supposed to map anchors back to document location. Derive section pages frompages[].spans(or retain enough paragraph/page metadata) instead of relying only on ids present in the coarse map.
for entry in ids.values():
if entry["kind"] == "section":
pages = sorted({int(ids[c]["bbox"]["page"]) for c in _child_ids(entry, ids) if ids[c].get("bbox")})
if pages:
entry["pages"] = pages
cu-cli/packages/core/src/cu_cli_core/rich_markdown.py:57
- A source that matches
_SOURCE_REbut contains non-numeric coordinates, such asD(1,a,b,c,d), raisesValueErrorfrom this conversion instead of returningNone. That aborts rich-markdown rendering for a malformed optionalsource; catchValueErrorso malformed sources are handled like the missing/malformed cases this parser is intended to tolerate.
values = [float(v) for v in match.group(2).split(",") if v.strip()]
cu-cli/packages/standalone/src/cu_cli/commands/analyze.py:337
--llm-inputis documented as an alias for--format md, but this validation does not reject--llm-input --format both. That combination reaches thebothbranch, writes JSON and Markdown, and silently ignores the alias instead of enforcing the selected view. Include"both"in the conflict check (and use a format-neutral error message).
if llm_input and (json_output or output_format == "json"):
raise CuCliError("--llm-input and --json cannot be combined.", exit_code=VALIDATION_FAILURE)
- Files reviewed: 19/20 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if isinstance(result, Mapping): # raw service JSON: inject the <!--id--> anchors | ||
| from cu_cli_core.rich_markdown import unwrap_result, with_rich_markdown | ||
|
|
||
| result = _as_sdk_result(with_rich_markdown(unwrap_result(result), level)) |
| except Exception: # noqa: BLE001 - a failed cleanup must not fail the analysis | ||
| return False |
| if json_output: | ||
| output_format = "json" if output_format in (None, "json") else output_format | ||
| both = output_format == "both" | ||
| json_output = output_format in ("json", "both") |
| if both: | ||
| sibling = md_path or _md_sibling(job.out_path) | ||
| dump_markdown(result, out=sibling, level=level) |
| def modality_of(path: "str | os.PathLike[str]") -> str: | ||
| """Best-effort modality from the file extension (documents when unknown).""" | ||
| ext = os.path.splitext(os.fspath(path))[1].lower() | ||
| if ext in VIDEO_EXTS: |
|
|
||
| `cu analyze` picks the analyzer by file type (`prebuilt-documentSearch` for documents) and | ||
| prints LLM-ready markdown: text, tables, a document summary, a description of every figure, | ||
| and small anchors — `<!--s1-->` section, `<!--t0-->` table, `<!--f0-->` figure — that |
There was a problem hiding this comment.
We cannot assume that all users want to save the JSON, and we have many customers mainly use markdown. Currently, we don't have direct customers asking anchor from markdown back to JSON for grounding purpose as far as I know (also checked with PMs).
However, the anchors in the md seems to be a useful addition.
While I like that anchor format, I think:
- It should not be default
- It should be merged as an option into CU SDK to_llm_input (Chien Yuan Chang (@chienyuanchang) can help with this for all SDK languages, starting with Python first)
- We should control the generation of anchor with --anchor-level none (default) | coarse | paragraph
Makes
cu-cliusable by a coding agent with nothing butCU_ENDPOINT(+az login). Built fresh on the latestmain; additive — no existing flag, command or doc text removed. One commit, 16 files (+3 re-recorded cassettes), no new dependencies.Key improvements
cu analyze doc.pdfjust works: the analyzer is chosen by file type (prebuilt-documentSearchfor documents), no profile setup, no model choice.--level paragraphevery paragraph) carries<!--s3-->-style ids. Without them an agent has no way to point back to where in the document a statement came from.cu resolve. Turns an anchor into page, bounding box, text and surrounding blocks from the saved JSON — no service call. An agent reads the markdown, cites ids, and resolves them on demand only when it needs to look at a figure, a table region or the page itself.--format both --output-file docwritesdoc.mdanddoc.jsonand lists the paths on stdout. The agent's context is not polluted with the whole document: it cangrep/headthe markdown for the parts it needs and keep the JSON on disk forcu resolve.cu analyze doc.pdferrors: no default_analyzer configuredCU_DEFAULT_ANALYZER› profile ›prebuilt-documentSearch(documents) /*Searchby file type--llm-inputxor--json— two billed calls for two files--format md|json|both(--json= shorthand,--llm-inputhidden alias);--format both --output-file NAME→NAME.md+NAME.json, one call; batch gets both sidecars. Written paths go to stdout, one per line (gog-style: data on stdout, diagnostics on stderr)<!--s3-->/<!--t0-->/<!--f0-->anchors (+<!--p30-->with--level paragraph) = service-result array indices; stripping them yields the previous markdown; +0.2 % tokens coarse, +6 % paragraphcu resolve RESULT.json ID… [--around N]— local, no endpoint--keep-resultopts out); id omittedDocs: Quickstart section in the root README ("which command when"), two short additive paragraphs in
cu-cli/README.md, CHANGELOGUnreleased.Deliberately left out (follow-ups): separate
--md-rich/--md/--mapoutput flags,cu resolve --page/--images, grounded field front matter,--schemaephemeral extraction, agent skill.Verification:
cu-cli/scripts/ci.shgreen (headers, ruff, mypy, unit, integration playback) on Python 3.12.test_validate_release.py::test_cli_requires_stable_core_dependencyfails onmainalready (not inci.sh), unchanged here.