Coverage for src / quber / providers / landing / client.py: 25%
64 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Landing.AI ADE parse client.
3`parse_document` submits one PDF or image through the async Parse Jobs API,
4polls the job to completion, and returns the full parse response as a dict.
5Polling is the only completion signal the platform offers; there is no
6webhook. Results over the inline size limit arrive as a presigned URL that
7expires shortly after the poll that returned it, so the download happens
8immediately.
10Two API generations are in play. The generally-available dpt-2 model line
11rides the v1 jobs API; the preview dpt-3 line rides the v2 jobs API. The
12model name picks the route.
14`custom_prompts` passes per-chunk-type instruction to the v1 model line, keyed
15by chunk type (`figure`, `text`, ...). It cannot change the shape of what comes
16back — an instruction to emit a chart as an HTML table returns the same prose
17description — so it is for content-level instruction only, and it is the
18caller's to supply.
20A parse that reports failed pages raises instead of returning: a silently
21missing page in a financial document is a liability, so a partial parse
22never looks like a success.
23"""
25from __future__ import annotations
27import json
28import time
29import urllib.request
30from pathlib import Path
31from typing import Any, Optional
33from loguru import logger
35from quber.settings import get_settings
37#: Generally-available default. The dpt-3 preview names route to the v2 API.
38DEFAULT_MODEL = "dpt-2"
40POLL_SECONDS = 5.0
41TIMEOUT_SECONDS = 1800.0
44def parse_document(
45 source: Path,
46 model: str = DEFAULT_MODEL,
47 custom_prompts: Optional[dict[str, str]] = None,
48 poll_seconds: float = POLL_SECONDS,
49) -> dict[str, Any]:
50 """Parse one local PDF or image through ADE; return the raw parse response.
52 `custom_prompts` reaches the v1 model line only; the v2 API takes no such
53 argument, so it is dropped for a dpt-3 model. `poll_seconds` sets how often
54 the job is polled — a single-page submission finishes in seconds and wants
55 a shorter interval than a whole document.
56 """
57 client = build_client()
58 if is_v2_model(model):
59 jobs = client.v2.parse_jobs
60 job = jobs.create(document=source, model=model)
61 else:
62 jobs = client.parse_jobs
63 extra = {"custom_prompts": custom_prompts} if custom_prompts else {}
64 job = jobs.create(document=source, model=model, **extra)
65 logger.info(
66 "ADE parse job {} submitted: {} ({} bytes, model={})",
67 job.job_id,
68 source.name,
69 source.stat().st_size,
70 model,
71 )
72 return _await_result(jobs, job.job_id, poll_seconds)
75def is_v2_model(model: str) -> bool:
76 """True for the dpt-3 model line, which lives on the v2 parse API."""
77 return model.startswith("dpt-3")
80def build_client() -> Any:
81 """An authenticated SDK client, keyed from settings (ADE_API_KEY)."""
82 from landingai_ade import LandingAIADE
84 key = get_settings().landing.api_key
85 if not key:
86 raise RuntimeError("No Landing.AI key: set ADE_API_KEY in the environment or .env")
87 return LandingAIADE(apikey=key)
90def _await_result(jobs: Any, job_id: str, poll_seconds: float = POLL_SECONDS) -> dict[str, Any]:
91 """Poll one parse job to completion and return its response dict."""
92 t0 = time.time()
93 last = None
94 while True:
95 r = jobs.get(job_id)
96 state = (r.status, r.progress)
97 if state != last:
98 logger.info(
99 "ADE job {}: status={} progress={:.0%} elapsed={:.0f}s",
100 job_id,
101 r.status,
102 r.progress or 0.0,
103 time.time() - t0,
104 )
105 last = state
106 if r.status in ("completed", "failed", "cancelled"):
107 break
108 if time.time() - t0 > TIMEOUT_SECONDS:
109 raise RuntimeError(f"ADE job {job_id} timed out after {TIMEOUT_SECONDS:.0f}s")
110 time.sleep(poll_seconds)
112 if r.status != "completed":
113 # v1 jobs report a failure_reason string; v2 jobs report an error object.
114 reason = getattr(r, "failure_reason", None) or getattr(r, "error", None)
115 raise RuntimeError(f"ADE job {job_id} {r.status}: {reason}")
117 # The completed payload is `data` on a v1 job and `result` on a v2 job.
118 payload = getattr(r, "data", None) or getattr(r, "result", None)
119 output_url = getattr(r, "output_url", None) or (getattr(r, "raw", None) or {}).get("output_url")
120 if payload is not None:
121 doc = json.loads(payload.model_dump_json())
122 elif output_url:
123 logger.info("ADE job {}: result over inline limit, downloading from output_url", job_id)
124 with urllib.request.urlopen(output_url) as resp:
125 doc = json.loads(resp.read())
126 else:
127 raise RuntimeError(f"ADE job {job_id} completed with neither inline payload nor output_url")
129 meta = doc.get("metadata") or {}
130 failed = meta.get("failed_pages") or []
131 if failed:
132 raise RuntimeError(f"ADE parse failed on pages {failed}; refusing to return a partial parse")
133 # v1 metadata reports credit_usage and version; v2 reports
134 # billing.total_credits and model_version.
135 credits = meta.get("credit_usage")
136 if credits is None:
137 credits = (meta.get("billing") or {}).get("total_credits")
138 logger.info(
139 "ADE parse complete: pages={} credits={} version={}",
140 meta.get("page_count"),
141 credits,
142 meta.get("version") or meta.get("model_version"),
143 )
144 return doc