From 1c0a8f7afde87ca066f75205aa3be877f60de576 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Thu, 17 Sep 2026 10:44:05 +0800 Subject: [PATCH 1/2] feat(v2): expose extract grounding as a top-level keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `options.grounding` skips the grounding stage, so every `extraction_metadata` leaf comes back with `ranges: null` and the request finishes faster. It is nested inside `options`, which extract does not surface, so it needed a hand-written top-level shorthand next to `strict` — a cross-SDK decision CONTRIBUTING.md reserves for a maintainer. The API owners asked for it in both SDKs, so `ade-typescript` gets the identical keyword with identical semantics. Both shorthands now fold into the SAME `options` object, so `_build_extract_body` collects them and attaches it once; assigning `body["options"]` per shorthand (what the code did while `strict` was alone) would drop whichever landed first. `bool()` rather than truthiness, since `grounding=False` is the only value the flag is ever passed for. Omitted or `None` leaves the key out and the server default applies. The live check asserts the returned `ranges` are all null rather than a 2xx: a shorthand that never reached the wire is not an error staging reports — it grounds anyway and comes back with real ranges. Co-Authored-By: Claude Opus 5 --- .github/workflows/spec-sync.yml | 4 +- CONTRIBUTING.md | 18 ++++- README.md | 2 + api.md | 8 +- docs/v2-testing.md | 89 +++++++++++----------- src/landingai_ade/resources/v2/extract.py | 44 +++++++++-- src/landingai_ade/resources/v2/v2.py | 4 + tests/api_resources/v2/test_async_smoke.py | 23 ++++++ tests/api_resources/v2/test_extract.py | 79 +++++++++++++------ tests/contract/test_v2_smoke.py | 19 ++++- 10 files changed, 206 insertions(+), 84 deletions(-) diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index 2d5b119..aba9c22 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -516,8 +516,8 @@ jobs: /v1/ade/parse and /v1/ade/parse/jobs — same name, different level, different route family — and lifting a `/v1/*` top-level field onto a /v2 operation is the field-level version of translating a /v1 route into a /v2 URL; (b) inventing a top-level - convenience parameter that folds one wire field into another. Exactly two exist - (`password` on parse, `strict` on extract); they are cross-SDK contract decisions + convenience parameter that folds one wire field into another. Exactly three exist + (`password` on parse, `strict` and `grounding` on extract); they are cross-SDK decisions shared with ade-typescript and written down in CONTRIBUTING.md, and changing one is a maintainer's call — do NOT add, remove or re-target an alias. Wire the spec's own shape, and if a spec change looks like it needs a new one, leave that field unwired diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab95fc4..ae88d77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -223,9 +223,21 @@ conveniences that fold into nested fields, and these must stay identical in `ade its key beats the caller's own kwarg. `ade-typescript` carries the mirror-image trap — there `{password: undefined}` is how JS spells "absent" and `JSON.stringify` drops the key, so testing key *presence* rather than value would suppress the shorthand **and** send no password at all. -- `strict` on `/v2/extract` and `/v2/extract/jobs` → `options.strict`. No conflict is possible — - neither SDK exposes `options` on extract, so the shorthand is the only way to reach the field. If - either SDK ever exposes it, that becomes a real tie-break and needs a rule here first. +- `strict` and `grounding` on `/v2/extract` and `/v2/extract/jobs` → `options.strict` and + `options.grounding`. No conflict is possible — neither SDK exposes `options` on extract, so the + shorthands are the only way to reach those fields. If either SDK ever exposes it, that becomes a + real tie-break and needs a rule here first. Three rules bind both repos: both shorthands fold into + the **same** `options` object, so build it once and attach it once (a per-shorthand + `body["options"] = {...}` drops whichever landed first); coerce with `bool()` / `Boolean()` rather + than truthiness, because `grounding=False` is the only value that flag is ever passed; and an + omitted or `None`/`undefined` value leaves its key out so the server default applies (`strict` + false, `grounding` true), with `options` omitted entirely when neither is given. + + `grounding` was wired in both SDKs together, at the API owners' request, after three snapshots of + arriving/being dropped/being re-added while unwired (it skips the grounding stage, so every + `extraction_metadata` leaf comes back with `ranges: null`). It is a Preview flag: it reached + staging ahead of production, and with grounding off the `extraction` itself can differ slightly + from a grounded run. **Nothing in CI checks this** — `check-v2-paths` covers routes only, and a hand-written alias is not spec-derived, so neither repo's CI can see the other's choice. A PR that adds or touches a top-level diff --git a/README.md b/README.md index 0ca580d..0d1cbd6 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ The response is a `V2ExtractResult`: By default, unsupported schema fields are skipped and extraction continues. Pass `strict=True` to reject such schemas with an error (HTTP 422) instead. +Pass `grounding=False` to skip the grounding stage: extraction runs as usual and finishes faster, but every `extraction_metadata` field comes back with `ranges: None` instead of pointing into the Markdown. Preview — with grounding off the `extraction` itself can differ slightly from a grounded run. `extract_jobs.create` takes both options too. + ## Process Large Documents Asynchronously (Jobs) For documents that take longer than a synchronous request allows, create a job and wait for it. `client.v2.parse_jobs` and `client.v2.extract_jobs` share the same shape: `create`, `get`, `list`, and `wait`. diff --git a/api.md b/api.md index 5f0c897..3974a1a 100644 --- a/api.md +++ b/api.md @@ -120,11 +120,11 @@ Methods: Blocks, polling `.get(job_id)` with exponential backoff, until the job reaches a terminal status. Raises `JobWaitTimeoutError` if `timeout` seconds elapse first, and `JobFailedError` if `raise_on_failure=True` and the terminal job carries an `error` (not simply every `failed`/`cancelled` status). -- client.v2.extract(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., save_to=...) -> V2ExtractResult +- client.v2.extract(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., grounding=..., save_to=...) -> V2ExtractResult - Synchronous extract. `schema` accepts a pydantic `BaseModel` subclass, a `dict`, or a JSON-encoded string -- all are coerced to a JSON Schema object. Provide exactly one of `markdown` or `markdown_url`. `strict=True` rejects schemas with unsupported fields (HTTP 422) instead of silently pruning them. Raises `V2SyncTimeoutError` on a 504; use `extract_jobs` for long-running documents. + Synchronous extract. `schema` accepts a pydantic `BaseModel` subclass, a `dict`, or a JSON-encoded string -- all are coerced to a JSON Schema object. Provide exactly one of `markdown` or `markdown_url`. `strict=True` rejects schemas with unsupported fields (HTTP 422) instead of silently pruning them. `grounding=False` skips the grounding stage, so every `extraction_metadata` leaf comes back with `ranges: None` and the request finishes faster -- Preview, and with grounding off the `extraction` itself can differ slightly from a grounded run (empty leaves are not nulled, all-empty array rows are not dropped). Both are sent inside the same nested `options` object and `extract_jobs.create` takes them with the same semantics. Raises `V2SyncTimeoutError` on a 504; use `extract_jobs` for long-running documents. -- client.v2.extract_jobs.create(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., output_save_url=..., service_tier=...) -> Job +- client.v2.extract_jobs.create(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., grounding=..., output_save_url=..., service_tier=...) -> Job - client.v2.extract_jobs.get(job_id) -> Job - client.v2.extract_jobs.list(\*, page=..., page_size=..., status=...) -> JobList[Job] - client.v2.extract_jobs.wait(job_id, \*, timeout=600, poll_interval=None, raise_on_failure=False) -> Job @@ -138,5 +138,5 @@ Methods: Notes: - `parse_jobs.list` and `extract_jobs.list` both return a `JobList` (a `list[Job]` subclass) carrying pagination metadata: `.has_more`, `.org_id`, `.page`, `.page_size`. The `page_size=` keyword is sent on the wire as the `pageSize` query parameter (the spec renamed it, matching the V1 list routes); the keyword and the response envelope's `page_size` field are unchanged. -- `options.grounding` (added upstream to `/v2/extract` and `/v2/extract/jobs`) is **not** exposed. It is nested inside `options`, which `client.v2.extract` does not surface — reaching it would need a third hand-written top-level shorthand alongside `password` and `strict`, and per CONTRIBUTING.md that is a cross-SDK decision to make with `ade-typescript` first. Until then, send it with `extra_body={"options": {"grounding": False}}` — note that `extra_body` merges at the top level only, so that replaces the whole `options` object and you must repeat `strict` in it rather than passing `strict=`. +- `strict=` and `grounding=` on extract are hand-written top-level shorthands: the spec declares both inside the nested `options` object, which `client.v2.extract` does not surface. They fold into one `options` and `ade-typescript` exposes the same two with the same semantics (CONTRIBUTING.md pins that parity). Omitting one — or passing `None` — leaves its key out, so the server default applies (`strict` false, `grounding` true). An `options` supplied through `extra_body` replaces the assembled one outright rather than merging into it, since `extra_body` merges at the top level only. - All `client.v2.*` methods accept the usual `extra_headers`, `extra_query`, `extra_body`, and `timeout` overrides; sync methods additionally accept `save_to` (parse/extract only, not the job-creation methods) to write the response to disk, mirroring V1's `save_to`. diff --git a/docs/v2-testing.md b/docs/v2-testing.md index 8c8da88..6648919 100644 --- a/docs/v2-testing.md +++ b/docs/v2-testing.md @@ -112,50 +112,51 @@ fields the model could not extract — the extraction is partial), `warnings` `output_extraction_chars` char counts moved from `billing` onto `metadata` upstream; both are retained on `V2ExtractBilling` for backward compatibility. -### Extraction options (`strict`) - -`/v2/extract` and `/v2/extract/jobs` take a nested `options` object. Unlike parse, -extract does **not** expose `options` as a keyword — the only nested option the SDK -reaches is `strict`, through a hand-written top-level shorthand that folds into -`options.strict`. - -That object now has a second member again: this snapshot **re-adds** -`options.grounding`, a Preview flag that skips the grounding stage so every -`extraction_metadata` leaf comes back with `ranges: null` (and the request finishes -faster). It has now been added, dropped, and re-added across three snapshots; the -SDK's answer has not moved, and `strict` remains the only option the SDK itself -folds in. - -`grounding` stays **unwired**, for the same reason as before: it is nested inside -`options`, not a top-level `requestBody` property, so surfacing it would mean a -third hand-written alias next to `password` and `strict`. CONTRIBUTING.md pins that -as a maintainer's call to be made jointly with `ade-typescript`, not a spec-sync -one. Exposing `options` on extract instead is not a way around it — CONTRIBUTING.md -calls that out explicitly, because it would turn the `strict` shorthand into a real -precedence tie-break that needs a written rule in that file first. - -To reach the flag today, use `extra_body`: - -```python -client.v2.extract( - schema=..., markdown="# doc", - extra_body={"options": {"grounding": False, "strict": False}}, -) -``` - -`extra_body` merges at the **top level only**, so it replaces the whole `options` -object — repeat `strict` inside it rather than also passing `strict=`, or the -keyword's value is silently discarded. (The escape hatch was rejected by the gateway -under the previous snapshot, where `grounding` was absent and `options` is -`additionalProperties: false`; it works again now.) - -`test_extract_options_carries_only_strict` pins the exact key set the SDK folds in, -so an unreviewed `grounding` alias cannot start riding along unnoticed, and -`test_extract_grounding_reachable_only_via_extra_body` pins the snippet above — -including the shallow merge that makes repeating `strict` necessary. - -Nothing about the **response** changed — `extraction_metadata` is unaffected, and -`V2ExtractResult` already models it as an untyped `Dict[str, object]`. +### Extraction options (`strict`, `grounding`) + +`/v2/extract` and `/v2/extract/jobs` take a nested `options` object with exactly two +members, `strict` and `grounding`. Unlike parse, extract does **not** expose `options` +as a keyword: both are reached through hand-written top-level shorthands that fold +into it. + +`grounding` is the newer of the two. It is a Preview flag that skips the grounding +stage, so every `extraction_metadata` leaf comes back with `ranges: null` and the +request finishes faster. It arrived, was dropped, and was re-added across three +snapshots while staying unwired; it is wired now because the API owners asked both +SDKs to expose it as a top-level keyword, matching `strict` (the decision, and the +`ade-typescript` half of it, is recorded in CONTRIBUTING.md — that file is where a +shorthand like this has to be agreed before it ships). + +Two behaviors are worth knowing: + +- **Both shorthands fold into the SAME `options` object**, so `_build_extract_body` + collects them and attaches the object once. Assigning `body["options"]` per + shorthand — which is what the code did while `strict` was alone — would silently + drop whichever landed first. `test_extract_options_carries_only_the_wired_shorthands` + pins the assembled object exactly, which also catches a third key riding along: + `options` is `additionalProperties: false` upstream, so an extra one is a 422. +- **`False` must survive.** The fold is `bool(value)`, not a truthiness test, and an + omitted or `None` value leaves the key out entirely so the server default applies + (`strict` false, `grounding` true). `grounding=False` is the only value anyone + passes this flag for, so a truthiness regression would make the keyword a silent + no-op. + +`extra_body` still merges at the **top level only**, so an `options` passed there +replaces the assembled one rather than merging into it +(`test_extract_extra_body_options_replaces_the_shorthands`). Nobody needs it to reach +`grounding` any more, but a caller who mixes the two gets the override, not a union. + +The live check is `test_extract_grounding_false_nulls_every_range` in +`tests/contract/test_v2_smoke.py`: it asserts the returned `ranges` are all `null` +rather than asserting a 2xx. A shorthand that never reached the wire — wrong nesting, +a dropped `False` — is not an error staging reports; it just grounds anyway and comes +back with real ranges. + +Nothing about the **response** changed — `extraction_metadata` is still an untyped +`Dict[str, object]` on `V2ExtractResult`. Note that with grounding off the +`extraction` itself can differ slightly from a grounded run (empty leaves are not +nulled and all-empty array rows are not dropped), which is upstream behavior, not +something the SDK normalizes. The async `extract_jobs.create` also accepts `output_save_url` (async jobs only): when set, the finished result is delivered to that URL and the completed job diff --git a/src/landingai_ade/resources/v2/extract.py b/src/landingai_ade/resources/v2/extract.py index f4d722d..1c6a332 100644 --- a/src/landingai_ade/resources/v2/extract.py +++ b/src/landingai_ade/resources/v2/extract.py @@ -28,6 +28,7 @@ def _build_extract_body( markdown_url: object, model: object, strict: object, + grounding: object = omit, service_tier: object = omit, ) -> Dict[str, Any]: provided = [ @@ -52,8 +53,19 @@ def _build_extract_body( ): if value is not omit and value is not None: body[key] = value - if strict is not omit and strict is not None: - body["options"] = {"strict": bool(strict)} + # `strict` and `grounding` are hand-written top-level shorthands that both fold into + # the SAME nested `options` object (CONTRIBUTING.md -> "V2 request fields"), so they + # are collected and attached once: assigning `body["options"]` per key would drop + # whichever landed first. `options` is `additionalProperties: false` upstream, so + # nothing beyond these two may ride along. An omitted or `None` value leaves the key + # out entirely and the server default applies (`strict` false, `grounding` true) -- + # `bool()` rather than truthiness so an explicit `False` is sent, not dropped. + options: Dict[str, Any] = {} + for key, value in (("strict", strict), ("grounding", grounding)): + if value is not omit and value is not None: + options[key] = bool(value) + if options: + body["options"] = options return body @@ -66,6 +78,7 @@ def run( markdown_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, strict: Optional[bool] | Omit = omit, + grounding: Optional[bool] | Omit = omit, save_to: str | Path | None = None, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -96,6 +109,12 @@ def run( False, prune unsupported fields and continue. Sent as `options.strict`. + grounding: If False, skip the grounding stage: every `extraction_metadata` + leaf comes back with `ranges: None` and the request finishes faster. + Defaults to True server-side. Sent as `options.grounding`. Preview -- + the extraction itself can differ slightly from a grounded run (empty + leaves are not nulled and all-empty array rows are not dropped). + save_to: Optional output path. If a directory, auto-generates the filename (e.g. {input_file}_extract_output.json, or extract_output.json when no input filename is available). If a full path ending in .json, saves there @@ -109,7 +128,7 @@ def run( timeout: Override the client-level default timeout for this request, in seconds """ - body = _build_extract_body(schema, markdown, markdown_url, model, strict) + body = _build_extract_body(schema, markdown, markdown_url, model, strict, grounding=grounding) try: result = self._post( self._v2_url("/v2/extract"), @@ -139,6 +158,7 @@ async def run( markdown_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, strict: Optional[bool] | Omit = omit, + grounding: Optional[bool] | Omit = omit, save_to: str | Path | None = None, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -148,7 +168,7 @@ async def run( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> V2ExtractResult: """Async mirror of `ExtractResource.run`. See there for full documentation.""" - body = _build_extract_body(schema, markdown, markdown_url, model, strict) + body = _build_extract_body(schema, markdown, markdown_url, model, strict, grounding=grounding) try: result = await self._post( self._v2_url("/v2/extract"), @@ -178,6 +198,7 @@ def create( markdown_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, strict: Optional[bool] | Omit = omit, + grounding: Optional[bool] | Omit = omit, output_save_url: Optional[str] | Omit = omit, service_tier: Optional[Literal["standard", "priority"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -208,6 +229,12 @@ def create( False, prune unsupported fields and continue. Sent as `options.strict`. + grounding: If False, skip the grounding stage: every `extraction_metadata` + leaf comes back with `ranges: None` and the request finishes faster. + Defaults to True server-side. Sent as `options.grounding`. Preview -- + the extraction itself can differ slightly from a grounded run (empty + leaves are not nulled and all-empty array rows are not dropped). + output_save_url: URL the result should be saved to (e.g. a presigned S3 PUT URL) instead of being returned inline. Async jobs only. When set, the completed job reports `output_url` instead of an inline `result`. @@ -222,7 +249,9 @@ def create( timeout: Override the client-level default timeout for this request, in seconds """ - body = _build_extract_body(schema, markdown, markdown_url, model, strict, service_tier) + body = _build_extract_body( + schema, markdown, markdown_url, model, strict, grounding=grounding, service_tier=service_tier + ) if is_given(output_save_url) and output_save_url is not None: body["output_save_url"] = output_save_url raw = self._post( @@ -338,6 +367,7 @@ async def create( markdown_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, strict: Optional[bool] | Omit = omit, + grounding: Optional[bool] | Omit = omit, output_save_url: Optional[str] | Omit = omit, service_tier: Optional[Literal["standard", "priority"]] | Omit = omit, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. @@ -348,7 +378,9 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> Job: """Async mirror of `ExtractJobsResource.create`. See there for full documentation.""" - body = _build_extract_body(schema, markdown, markdown_url, model, strict, service_tier) + body = _build_extract_body( + schema, markdown, markdown_url, model, strict, grounding=grounding, service_tier=service_tier + ) if is_given(output_save_url) and output_save_url is not None: body["output_save_url"] = output_save_url raw = await self._post( diff --git a/src/landingai_ade/resources/v2/v2.py b/src/landingai_ade/resources/v2/v2.py index 422de36..fe61b09 100644 --- a/src/landingai_ade/resources/v2/v2.py +++ b/src/landingai_ade/resources/v2/v2.py @@ -92,6 +92,7 @@ def extract( markdown_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, strict: Optional[bool] | Omit = omit, + grounding: Optional[bool] | Omit = omit, save_to: str | Path | None = None, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -107,6 +108,7 @@ def extract( markdown_url=markdown_url, model=model, strict=strict, + grounding=grounding, save_to=save_to, extra_headers=extra_headers, extra_query=extra_query, @@ -208,6 +210,7 @@ async def extract( markdown_url: Optional[str] | Omit = omit, model: Optional[str] | Omit = omit, strict: Optional[bool] | Omit = omit, + grounding: Optional[bool] | Omit = omit, save_to: str | Path | None = None, # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. @@ -223,6 +226,7 @@ async def extract( markdown_url=markdown_url, model=model, strict=strict, + grounding=grounding, save_to=save_to, extra_headers=extra_headers, extra_query=extra_query, diff --git a/tests/api_resources/v2/test_async_smoke.py b/tests/api_resources/v2/test_async_smoke.py index 96cd1da..b98a470 100644 --- a/tests/api_resources/v2/test_async_smoke.py +++ b/tests/api_resources/v2/test_async_smoke.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from typing import Any, Dict import httpx @@ -44,6 +45,28 @@ async def test_async_extract() -> None: assert isinstance(r, V2ExtractResult) +@respx.mock +@pytest.mark.asyncio +async def test_async_extract_folds_both_option_shorthands() -> None: + # The async mirrors share `_build_extract_body` with the sync methods, but the + # kwargs are declared four times over (sync/async x run/create) -- a shorthand + # added to only some of them is exactly the kind of drift that reads fine in a + # diff. Pin both async entry points. + client = AsyncLandingAIADE(apikey=APIKEY) + + sync_route = respx.post("https://api.ade.landing.ai/v2/extract").mock( + return_value=httpx.Response(200, json=EXTRACT_BODY) + ) + await client.v2.extract(schema={"type": "object"}, markdown="m", strict=True, grounding=False) + assert json.loads(sync_route.calls.last.request.content)["options"] == {"strict": True, "grounding": False} + + job_route = respx.post("https://api.ade.landing.ai/v2/extract/jobs").mock( + return_value=httpx.Response(202, json={"job_id": "e1", "status": "pending"}) + ) + await client.v2.extract_jobs.create(schema={"type": "object"}, markdown="m", grounding=False) + assert json.loads(job_route.calls.last.request.content)["options"] == {"grounding": False} + + @respx.mock @pytest.mark.asyncio async def test_async_extract_jobs_create_get_and_wait() -> None: diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py index 8894ba6..f8f8f22 100644 --- a/tests/api_resources/v2/test_extract.py +++ b/tests/api_resources/v2/test_extract.py @@ -52,32 +52,61 @@ def test_extract_sync_strict_option() -> None: @respx.mock -def test_extract_options_carries_only_strict() -> None: - # `V2ExtractOptions` is `additionalProperties: false` upstream and now carries - # two members, `strict` and the re-added Preview flag `grounding`. Only - # `strict` is wired: `grounding` is nested rather than a top-level - # `requestBody` property, so reaching it would need a third hand-written alias, - # which CONTRIBUTING.md reserves for a joint call with `ade-typescript`. Pin - # the exact key set rather than just `strict`, so neither a stray option nor an - # unreviewed `grounding` alias can start riding along unnoticed. +def test_extract_sync_grounding_option() -> None: + # `grounding=False` skips the grounding stage server-side (every + # `extraction_metadata` leaf comes back with `ranges: null`). It folds into + # `options.grounding`, the second hand-written top-level shorthand on extract + # alongside `strict` -- `bool()`, not truthiness, so `False` is SENT rather + # than dropped, which is the only value anyone passes this for. client = LandingAIADE(apikey=APIKEY) route = respx.post("https://api.ade.landing.ai/v2/extract").mock( return_value=httpx.Response(200, json=EXTRACT_BODY) ) - client.v2.extract(schema={"type": "object"}, markdown="# doc", strict=False) + client.v2.extract(schema={"type": "object"}, markdown="# doc", grounding=False) req = json.loads(route.calls.last.request.content) - assert set(req["options"]) == {"strict"} + assert req["options"] == {"grounding": False} @respx.mock -def test_extract_grounding_reachable_only_via_extra_body() -> None: - # `options.grounding` is deliberately unwired (nested, not top-level), so - # `extra_body` is the documented escape hatch -- api.md and docs/v2-testing.md - # both point at it. `extra_body` merges at the top level only - # (`_merge_mappings` is a shallow `{**a, **b}`), so the caller's `options` - # replaces the one `_build_extract_body` assembled and must repeat `strict`. - # Pin that here: if the merge ever went deep, the documented snippet would - # start sending a different body than it advertises. +def test_extract_options_carries_only_the_wired_shorthands() -> None: + # `V2ExtractOptions` is `additionalProperties: false` upstream and carries + # exactly two members, `strict` and `grounding`, both wired as top-level + # shorthands. Pin the exact object rather than one key: the two fold into the + # SAME nested object, so a regression that assigns `options` per shorthand + # instead of merging would silently drop one, and any third key riding along + # would be rejected by the gateway. + client = LandingAIADE(apikey=APIKEY) + route = respx.post("https://api.ade.landing.ai/v2/extract").mock( + return_value=httpx.Response(200, json=EXTRACT_BODY) + ) + client.v2.extract(schema={"type": "object"}, markdown="# doc", strict=False, grounding=False) + req = json.loads(route.calls.last.request.content) + assert req["options"] == {"strict": False, "grounding": False} + + +@respx.mock +def test_extract_omits_options_when_no_shorthand_given() -> None: + # Neither shorthand given -> no `options` at all, so the server's own defaults + # apply (`strict` false, `grounding` true). `None` means "not given" for both, + # the same as omitting them: there is no null to send here -- `options` is + # where a caller would express "default", and leaving it out IS that. + client = LandingAIADE(apikey=APIKEY) + route = respx.post("https://api.ade.landing.ai/v2/extract").mock( + return_value=httpx.Response(200, json=EXTRACT_BODY) + ) + client.v2.extract(schema={"type": "object"}, markdown="# doc", strict=None, grounding=None) + req = json.loads(route.calls.last.request.content) + assert "options" not in req + + +@respx.mock +def test_extract_extra_body_options_replaces_the_shorthands() -> None: + # `extra_body` merges at the top level only (`_merge_mappings` is a shallow + # `{**a, **b}`), so an `options` supplied there replaces the one the shorthands + # assembled rather than merging into it. Both shorthands are now keywords, so + # nobody needs `extra_body` to reach `grounding` -- but the shallow merge is + # still the documented behavior of `extra_body` everywhere, and a caller who + # mixes the two gets the override, not a union. client = LandingAIADE(apikey=APIKEY) route = respx.post("https://api.ade.landing.ai/v2/extract").mock( return_value=httpx.Response(200, json=EXTRACT_BODY) @@ -86,15 +115,17 @@ def test_extract_grounding_reachable_only_via_extra_body() -> None: schema={"type": "object"}, markdown="# doc", strict=True, - extra_body={"options": {"grounding": False, "strict": False}}, + grounding=True, + extra_body={"options": {"grounding": False}}, ) req = json.loads(route.calls.last.request.content) - assert req["options"] == {"grounding": False, "strict": False} + assert req["options"] == {"grounding": False} -def test_extract_job_create_options_carries_only_strict(monkeypatch: pytest.MonkeyPatch) -> None: +def test_extract_job_create_carries_both_shorthands(monkeypatch: pytest.MonkeyPatch) -> None: # Same `additionalProperties: false` contract on the async job route, which - # shares `_build_extract_body` with the sync one. + # shares `_build_extract_body` with the sync one -- including the merge that + # keeps both shorthands in one `options` object. client = LandingAIADE(apikey=APIKEY) captured: Dict[str, Any] = {} @@ -103,8 +134,8 @@ def fake_post(path: str, *, cast_to: Any, body: Any = None, options: Any = None, return {"job_id": "e1", "status": "pending"} monkeypatch.setattr(client.v2.extract_jobs, "_post", fake_post) - client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x", strict=True) - assert set(captured["body"]["options"]) == {"strict"} + client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x", strict=True, grounding=False) + assert captured["body"]["options"] == {"strict": True, "grounding": False} @respx.mock diff --git a/tests/contract/test_v2_smoke.py b/tests/contract/test_v2_smoke.py index da2b4a4..524aa69 100644 --- a/tests/contract/test_v2_smoke.py +++ b/tests/contract/test_v2_smoke.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import List, Iterator, Optional +from typing import Any, Dict, List, Iterator, Optional, cast from pathlib import Path import pytest @@ -227,3 +227,20 @@ def test_job_lists_honor_page_size(staging_client: LandingAIADE) -> None: # route) included; an undocumented one falls back to `pending`. assert isinstance(job.status, JobStatus) assert job.job_id + + +def test_extract_grounding_false_nulls_every_range(staging_client: LandingAIADE) -> None: + # `grounding=False` folds into `options.grounding` and switches the grounding + # stage off, so every `extraction_metadata` leaf comes back with `ranges: null`. + # That is the whole point of a live check here: a shorthand that never reached + # the wire -- wrong nesting, dropped `False`, a name the gateway doesn't know -- + # is not an error staging reports. `options` is `additionalProperties: false`, + # so a misspelled key 422s, but a silently ignored one just grounds anyway and + # comes back with real ranges. Assert the ranges, not the status code. + res = staging_client.v2.extract(schema=RevenueSchema, markdown=SAMPLE_MARKDOWN, grounding=False) + assert isinstance(res, V2ExtractResult) + metadata = cast(Dict[str, Any], res.extraction_metadata) + leaves: List[Dict[str, Any]] = [cast(Dict[str, Any], value) for value in metadata.values()] + ranges = [leaf["ranges"] for leaf in leaves if "ranges" in leaf] + assert ranges, f"no grounded leaves to check in {metadata}" + assert all(value is None for value in ranges) From 736ddf21fa8e687810135e3ac2e8052d84875aa1 Mon Sep 17 00:00:00 2001 From: Lan Tian Date: Thu, 17 Sep 2026 15:15:18 +0800 Subject: [PATCH 2/2] test(v2): assert every metadata leaf carries ranges: null, not just the ones that do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: filtering the live check to leaves that HAVE `ranges` let a leaf that dropped the key pass as if it had been checked. The schema is flat, so every top-level entry is a leaf and the documented contract is that all of them carry `ranges: null` with grounding off — assert the key's presence separately from its value. Co-Authored-By: Claude Opus 5 --- tests/contract/test_v2_smoke.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/contract/test_v2_smoke.py b/tests/contract/test_v2_smoke.py index 524aa69..bec3aba 100644 --- a/tests/contract/test_v2_smoke.py +++ b/tests/contract/test_v2_smoke.py @@ -240,7 +240,12 @@ def test_extract_grounding_false_nulls_every_range(staging_client: LandingAIADE) res = staging_client.v2.extract(schema=RevenueSchema, markdown=SAMPLE_MARKDOWN, grounding=False) assert isinstance(res, V2ExtractResult) metadata = cast(Dict[str, Any], res.extraction_metadata) - leaves: List[Dict[str, Any]] = [cast(Dict[str, Any], value) for value in metadata.values()] - ranges = [leaf["ranges"] for leaf in leaves if "ranges" in leaf] - assert ranges, f"no grounded leaves to check in {metadata}" - assert all(value is None for value in ranges) + assert metadata, "extraction returned no metadata leaves to check" + # `RevenueSchema` is flat, so every top-level entry IS a leaf. With grounding off + # each one carries `ranges: null` -- a null value, not a missing key. Assert the + # key's presence separately from its value: skipping leaves that lack `ranges` + # would let a leaf that dropped it pass as if it had been checked. + for name, value in metadata.items(): + leaf = cast(Dict[str, Any], value) + assert "ranges" in leaf, f"leaf {name!r} carries no `ranges`: {leaf}" + assert leaf["ranges"] is None, f"leaf {name!r} was grounded anyway: {leaf}"