pdf-modifier 1.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. pdf_modifier/__init__.py +26 -0
  2. pdf_modifier/__main__.py +13 -0
  3. pdf_modifier/ai/__init__.py +1 -0
  4. pdf_modifier/ai/client.py +296 -0
  5. pdf_modifier/ai/exceptions.py +29 -0
  6. pdf_modifier/ai/models.py +81 -0
  7. pdf_modifier/ai/prompts/__init__.py +39 -0
  8. pdf_modifier/ai/prompts/classify_doc.j2 +23 -0
  9. pdf_modifier/ai/prompts/detect_fields.j2 +20 -0
  10. pdf_modifier/ai/prompts/redact_pii.j2 +24 -0
  11. pdf_modifier/ai/reviewer.py +134 -0
  12. pdf_modifier/ai/router.py +112 -0
  13. pdf_modifier/ai/throttle.py +109 -0
  14. pdf_modifier/core/__init__.py +50 -0
  15. pdf_modifier/core/analyzer.py +322 -0
  16. pdf_modifier/core/consistency.py +224 -0
  17. pdf_modifier/core/exceptions.py +61 -0
  18. pdf_modifier/core/font_resolver.py +234 -0
  19. pdf_modifier/core/models.py +193 -0
  20. pdf_modifier/core/modifier.py +674 -0
  21. pdf_modifier/interfaces/__init__.py +3 -0
  22. pdf_modifier/interfaces/cli.py +535 -0
  23. pdf_modifier/interfaces/mcp.py +384 -0
  24. pdf_modifier/logger.py +35 -0
  25. pdf_modifier/web/__init__.py +1 -0
  26. pdf_modifier/web/app.py +72 -0
  27. pdf_modifier/web/config.py +39 -0
  28. pdf_modifier/web/deps.py +45 -0
  29. pdf_modifier/web/routes/__init__.py +7 -0
  30. pdf_modifier/web/routes/ai.py +34 -0
  31. pdf_modifier/web/routes/health.py +11 -0
  32. pdf_modifier/web/routes/pdf.py +225 -0
  33. pdf_modifier/web/session.py +129 -0
  34. pdf_modifier/web/storage.py +153 -0
  35. pdf_modifier-1.9.1.dist-info/METADATA +193 -0
  36. pdf_modifier-1.9.1.dist-info/RECORD +39 -0
  37. pdf_modifier-1.9.1.dist-info/WHEEL +4 -0
  38. pdf_modifier-1.9.1.dist-info/entry_points.txt +3 -0
  39. pdf_modifier-1.9.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,26 @@
1
+ """
2
+ PDF Modifier - A dual-interface PDF modification tool.
3
+
4
+ Provides both CLI (pdf-mod) and MCP server interfaces for PDF manipulation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import version
10
+
11
+ from .core import (
12
+ PDFAnalyzer,
13
+ PDFModifier,
14
+ PDFModifierError,
15
+ ReplacementSpec,
16
+ )
17
+
18
+ __version__ = version("pdf-modifier")
19
+
20
+ __all__ = [
21
+ "PDFAnalyzer",
22
+ "PDFModifier",
23
+ "PDFModifierError",
24
+ "ReplacementSpec",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,13 @@
1
+ """
2
+ Entry point for `python -m pdf_modifier`.
3
+
4
+ Launches the MCP server by default.
5
+ Use `pdf-mod` command for CLI access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .interfaces.mcp import main
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1 @@
1
+ """AI layer package — NaN Cloud integration."""
@@ -0,0 +1,296 @@
1
+ """NaN Cloud API client using httpx async + null client for testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from ..logger import setup_logging
11
+ from .exceptions import AIAuthenticationError, AIError, AIRateLimitError, AIServerError
12
+
13
+ logger = setup_logging(__name__)
14
+
15
+ DEFAULT_BASE_URL = "https://api.nan.builders/v1"
16
+ DEFAULT_TIMEOUT = 30.0
17
+
18
+
19
+ class NaNClient:
20
+ """Async HTTP client for NaN Cloud API.
21
+
22
+ Handles authentication, rate limiting retries, and error mapping.
23
+
24
+ Example:
25
+ >>> async with NaNClient() as client:
26
+ ... msgs = [{"role": "user", "content": "Hello"}]
27
+ ... result = await client.chat("mimo-v2.6-flash", msgs)
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ api_key: str | None = None,
33
+ base_url: str | None = None,
34
+ timeout: float = DEFAULT_TIMEOUT,
35
+ max_retries: int = 3,
36
+ ) -> None:
37
+ # Accept either NaN Cloud or any OpenAI-compatible provider so the AI
38
+ # features work for more people. NaN key wins when both are present.
39
+ nan_key = os.environ.get("NAN_API_KEY")
40
+ openai_key = os.environ.get("OPENAI_API_KEY")
41
+ self._api_key = api_key or nan_key or openai_key or ""
42
+ # Base URL: explicit > NAN_BASE_URL > OPENAI_BASE_URL > provider default.
43
+ raw_url: str | None = (
44
+ base_url or os.environ.get("NAN_BASE_URL") or os.environ.get("OPENAI_BASE_URL")
45
+ )
46
+ if raw_url is None:
47
+ if nan_key:
48
+ raw_url = DEFAULT_BASE_URL
49
+ elif openai_key:
50
+ raw_url = "https://api.openai.com/v1"
51
+ else:
52
+ raw_url = DEFAULT_BASE_URL
53
+ self._base_url = (raw_url or DEFAULT_BASE_URL).rstrip("/")
54
+ self._is_nan = bool(nan_key)
55
+ self._timeout = timeout
56
+ self._max_retries = max_retries
57
+ self._client: httpx.AsyncClient | None = None
58
+
59
+ @property
60
+ def is_configured(self) -> bool:
61
+ """Whether an API key is available."""
62
+ return bool(self._api_key)
63
+
64
+ async def _get_client(self) -> httpx.AsyncClient:
65
+ """Lazy-initialize the httpx client."""
66
+ if self._client is None:
67
+ self._client = httpx.AsyncClient(
68
+ base_url=self._base_url,
69
+ timeout=self._timeout,
70
+ headers={
71
+ "Authorization": f"Bearer {self._api_key}",
72
+ "Content-Type": "application/json",
73
+ },
74
+ )
75
+ return self._client
76
+
77
+ async def chat(
78
+ self,
79
+ model: str,
80
+ messages: list[dict[str, str]],
81
+ temperature: float = 0.0,
82
+ **extra_kwargs: Any,
83
+ ) -> dict[str, Any]:
84
+ """Send a chat completion request to NaN Cloud."""
85
+ client = await self._get_client()
86
+
87
+ payload: dict[str, Any] = {
88
+ "model": model,
89
+ "messages": messages,
90
+ "temperature": temperature,
91
+ "chat_template_kwargs": {"enable_thinking": False},
92
+ **extra_kwargs,
93
+ }
94
+
95
+ last_exception: Exception | None = None
96
+
97
+ for attempt in range(self._max_retries):
98
+ try:
99
+ response = await client.post("/chat/completions", json=payload)
100
+
101
+ if response.status_code == 401:
102
+ raise AIAuthenticationError("Invalid API key")
103
+ if response.status_code == 429:
104
+ raise AIRateLimitError(
105
+ f"Rate limit exceeded (attempt {attempt + 1}/{self._max_retries})"
106
+ )
107
+ if response.status_code >= 500:
108
+ raise AIServerError(f"Server error: {response.status_code}")
109
+ response.raise_for_status()
110
+
111
+ data: dict[str, Any] = response.json()
112
+ logger.debug("AI chat completed for model=%s", model)
113
+ return data
114
+
115
+ except (AIAuthenticationError, AIError):
116
+ raise
117
+ except httpx.HTTPStatusError:
118
+ raise
119
+ except httpx.RequestError as e:
120
+ last_exception = e
121
+ logger.warning(
122
+ "AI chat attempt %d/%d failed for model=%s: %s",
123
+ attempt + 1,
124
+ self._max_retries,
125
+ model,
126
+ e,
127
+ )
128
+ if attempt < self._max_retries - 1:
129
+ import asyncio
130
+
131
+ await asyncio.sleep(2**attempt)
132
+
133
+ if last_exception:
134
+ raise last_exception
135
+ raise AIError("Chat request failed after retries")
136
+
137
+ async def embed(
138
+ self,
139
+ model: str,
140
+ input_text: str | list[str],
141
+ ) -> dict[str, Any]:
142
+ """Send an embedding request with retry logic."""
143
+ client = await self._get_client()
144
+
145
+ payload = {
146
+ "model": model,
147
+ "input": input_text,
148
+ }
149
+
150
+ last_exception: Exception | None = None
151
+
152
+ for attempt in range(self._max_retries):
153
+ try:
154
+ response = await client.post("/embeddings", json=payload)
155
+
156
+ if response.status_code == 401:
157
+ raise AIAuthenticationError("Invalid API key")
158
+ if response.status_code == 429:
159
+ raise AIRateLimitError(
160
+ f"Rate limit exceeded (attempt {attempt + 1}/{self._max_retries})"
161
+ )
162
+ if response.status_code >= 500:
163
+ raise AIServerError(f"Server error: {response.status_code}")
164
+ response.raise_for_status()
165
+
166
+ data: dict[str, Any] = response.json()
167
+ logger.debug("AI embed completed for model=%s", model)
168
+ return data
169
+
170
+ except (AIAuthenticationError, AIError):
171
+ raise
172
+ except httpx.HTTPStatusError:
173
+ raise
174
+ except httpx.RequestError as e:
175
+ last_exception = e
176
+ logger.warning(
177
+ "AI embed attempt %d/%d failed for model=%s: %s",
178
+ attempt + 1,
179
+ self._max_retries,
180
+ model,
181
+ e,
182
+ )
183
+ if attempt < self._max_retries - 1:
184
+ import asyncio
185
+
186
+ await asyncio.sleep(2**attempt)
187
+
188
+ if last_exception:
189
+ raise last_exception
190
+ raise AIError("Embed request failed after retries")
191
+
192
+ async def close(self) -> None:
193
+ """Close the HTTP client."""
194
+ if self._client is not None:
195
+ await self._client.aclose()
196
+ self._client = None
197
+
198
+ async def __aenter__(self) -> NaNClient:
199
+ return self
200
+
201
+ async def __aexit__(self, *args: Any) -> None:
202
+ await self.close()
203
+
204
+
205
+ class NullAIClient:
206
+ """Mock AI client that returns predefined responses.
207
+
208
+ Used in unit tests to avoid real API calls.
209
+ """
210
+
211
+ def __init__(self) -> None:
212
+ self._call_count = 0
213
+ self._responses: dict[str, dict[str, Any]] = {
214
+ "chat": {
215
+ "choices": [
216
+ {
217
+ "message": {
218
+ "content": (
219
+ '{"fields": [{'
220
+ '"name": "invoice_number", '
221
+ '"value": "INV-2024-001", '
222
+ '"confidence": 0.95}], '
223
+ '"document_type": "invoice"}'
224
+ )
225
+ }
226
+ }
227
+ ]
228
+ },
229
+ "classify": {
230
+ "choices": [
231
+ {
232
+ "message": {
233
+ "content": (
234
+ '{"document_type": "invoice", '
235
+ '"confidence": 0.92, '
236
+ '"alternatives": [{'
237
+ '"type": "receipt", '
238
+ '"confidence": 0.05}]}'
239
+ )
240
+ }
241
+ }
242
+ ]
243
+ },
244
+ "redact": {
245
+ "choices": [
246
+ {
247
+ "message": {
248
+ "content": (
249
+ '{"items": [{'
250
+ '"pii_type": "email", '
251
+ '"value": "test@example.com", '
252
+ '"suggested_replacement": "[REDACTED]"}]}'
253
+ )
254
+ }
255
+ }
256
+ ]
257
+ },
258
+ }
259
+
260
+ @property
261
+ def is_configured(self) -> bool:
262
+ return True
263
+
264
+ @property
265
+ def call_count(self) -> int:
266
+ return self._call_count
267
+
268
+ async def chat(
269
+ self,
270
+ model: str,
271
+ messages: list[dict[str, str]],
272
+ temperature: float = 0.0,
273
+ **extra_kwargs: Any,
274
+ ) -> dict[str, Any]:
275
+ self._call_count += 1
276
+ return self._responses["chat"]
277
+
278
+ async def embed(
279
+ self,
280
+ model: str,
281
+ input_text: str | list[str],
282
+ ) -> dict[str, Any]:
283
+ self._call_count += 1
284
+ return {
285
+ "data": [{"embedding": [0.1] * 1536}],
286
+ "model": model,
287
+ }
288
+
289
+ async def close(self) -> None:
290
+ pass
291
+
292
+ async def __aenter__(self) -> NullAIClient:
293
+ return self
294
+
295
+ async def __aexit__(self, *args: Any) -> None:
296
+ await self.close()
@@ -0,0 +1,29 @@
1
+ """AI exceptions for NaN Cloud operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core.exceptions import PDFModifierError
6
+
7
+
8
+ class AIError(PDFModifierError):
9
+ """Base exception for AI operations."""
10
+
11
+ code = "AI_ERROR"
12
+
13
+
14
+ class AIAuthenticationError(AIError):
15
+ """Raised when API authentication fails."""
16
+
17
+ code = "AI_AUTH_ERROR"
18
+
19
+
20
+ class AIRateLimitError(AIError):
21
+ """Raised when rate limit is exceeded."""
22
+
23
+ code = "AI_RATE_LIMIT"
24
+
25
+
26
+ class AIServerError(AIError):
27
+ """Raised when AI server returns an error."""
28
+
29
+ code = "AI_SERVER_ERROR"
@@ -0,0 +1,81 @@
1
+ """AI-specific Pydantic models for NaN Cloud responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class DetectField(BaseModel):
9
+ """A detected field from document analysis."""
10
+
11
+ name: str = Field(description="Field name (e.g., 'invoice_number', 'date')")
12
+ value: str = Field(description="Extracted value")
13
+ confidence: float = Field(ge=0.0, le=1.0, description="Detection confidence (0-1)")
14
+ page: int | None = None
15
+ bbox: tuple[float, float, float, float] | None = None
16
+
17
+
18
+ class DetectResult(BaseModel):
19
+ """Result of AI-powered field detection."""
20
+
21
+ success: bool = True
22
+ fields: list[DetectField] = Field(default_factory=list)
23
+ document_type: str | None = None
24
+ model: str = Field(description="Model used for detection")
25
+
26
+
27
+ class ClassificationAlternative(BaseModel):
28
+ """Alternative classification with confidence."""
29
+
30
+ type: str = Field(description="Alternative document type")
31
+ confidence: float = Field(ge=0.0, le=1.0, description="Confidence score")
32
+
33
+
34
+ class ClassifyResult(BaseModel):
35
+ """Result of document classification."""
36
+
37
+ success: bool = True
38
+ document_type: str = Field(description="Classified type (invoice, contract, CV, etc.)")
39
+ confidence: float = Field(ge=0.0, le=1.0)
40
+ alternatives: list[ClassificationAlternative] = Field(
41
+ default_factory=list,
42
+ description="Alternative classifications with confidence scores",
43
+ )
44
+ model: str = Field(description="Model used for classification")
45
+
46
+
47
+ class RedactItem(BaseModel):
48
+ """A PII item found for redaction."""
49
+
50
+ pii_type: str = Field(description="Type of PII (email, phone, nif, credit_card, etc.)")
51
+ value: str = Field(description="The PII value found")
52
+ page: int | None = None
53
+ bbox: tuple[float, float, float, float] | None = None
54
+ confidence: float = Field(ge=0.0, le=1.0)
55
+ suggested_replacement: str = Field(
56
+ default="[REDACTED]", description="Suggested replacement text"
57
+ )
58
+
59
+
60
+ class RedactResult(BaseModel):
61
+ """Result of PII redaction detection."""
62
+
63
+ success: bool = True
64
+ items: list[RedactItem] = Field(default_factory=list)
65
+ model: str = Field(description="Model used for redaction")
66
+
67
+
68
+ class EmbeddingResult(BaseModel):
69
+ """Result of text embedding."""
70
+
71
+ success: bool = True
72
+ model: str
73
+ dimensions: int
74
+ embeddings: list[list[float]] = Field(description="List of embedding vectors")
75
+
76
+
77
+ class EmbeddingQuery(BaseModel):
78
+ """Query for generating embeddings."""
79
+
80
+ model: str = Field(default="qwen3-embedding")
81
+ input: str | list[str] = Field(..., description="Text or list of texts to embed")
@@ -0,0 +1,39 @@
1
+ """Jinja2 prompt template renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from jinja2 import Environment, FileSystemLoader
10
+
11
+ PROMPTS_DIR = Path(__file__).parent
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def get_template_env() -> Environment:
16
+ """Get a cached Jinja2 environment for prompt templates."""
17
+ return Environment(
18
+ loader=FileSystemLoader(str(PROMPTS_DIR)),
19
+ keep_trailing_newline=True,
20
+ )
21
+
22
+
23
+ def render_prompt(template_name: str, **kwargs: Any) -> str:
24
+ """Render a prompt template with given variables.
25
+
26
+ Args:
27
+ template_name: Template file name (e.g., "detect_fields.j2").
28
+ **kwargs: Variables to substitute in the template.
29
+
30
+ Returns:
31
+ Rendered prompt string.
32
+
33
+ Raises:
34
+ FileNotFoundError: If template doesn't exist.
35
+ """
36
+ env = get_template_env()
37
+ template = env.get_template(template_name)
38
+ rendered: str = template.render(**kwargs)
39
+ return rendered
@@ -0,0 +1,23 @@
1
+ You are a document classification AI. Classify the following document.
2
+
3
+ Document text:
4
+ {{ pdf_text }}
5
+
6
+ Classify into one of these types:
7
+ - invoice
8
+ - contract
9
+ - cv_resume
10
+ - report
11
+ - letter
12
+ - receipt
13
+ - form
14
+ - other
15
+
16
+ Return a JSON object:
17
+ {
18
+ "document_type": "one_of_the_types_above",
19
+ "confidence": 0.95,
20
+ "alternatives": [
21
+ {"type": "second_choice", "confidence": 0.1}
22
+ ]
23
+ }
@@ -0,0 +1,20 @@
1
+ You are a document analysis AI. Analyze the following PDF text and extract structured fields.
2
+
3
+ Document text:
4
+ {{ pdf_text }}
5
+
6
+ Extract all identifiable fields (invoice numbers, dates, amounts, names, addresses, etc.).
7
+ Return a JSON object with this exact structure:
8
+ {
9
+ "fields": [
10
+ {"name": "field_name", "value": "extracted_value", "confidence": 0.95}
11
+ ],
12
+ "document_type": "type_or_null"
13
+ }
14
+
15
+ Rules:
16
+ - Only extract fields that are clearly present in the text
17
+ - Confidence should reflect how certain you are (0.0-1.0)
18
+ - Use snake_case for field names
19
+ - If no fields are found, return empty fields array
20
+ - Return only valid JSON (no markdown, no code fences, no extra prose)
@@ -0,0 +1,24 @@
1
+ You are a PII redaction AI. Find all personally identifiable information in this document.
2
+
3
+ Document text:
4
+ {{ pdf_text }}
5
+
6
+ Find and return all PII items including:
7
+ - Names (person names)
8
+ - Emails
9
+ - Phone numbers
10
+ - NIF/Tax IDs
11
+ - Credit card numbers
12
+ - Addresses
13
+ - Dates of birth
14
+
15
+ Return a JSON object:
16
+ {
17
+ "items": [
18
+ {
19
+ "pii_type": "email|phone|nif|credit_card|name|address|dob|other",
20
+ "value": "the_found_value",
21
+ "suggested_replacement": "[REDACTED]"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,134 @@
1
+ """Optional AI consistency reviewer (NaN Cloud or any OpenAI-compatible API).
2
+
3
+ The reviewer runs ONLY when an API key is configured in the environment
4
+ (``NAN_API_KEY`` for NaN, or an OpenAI-compatible endpoint via ``NAN_BASE_URL`` +
5
+ ``AI_REVIEW_MODEL``). Without a key it ``skip``s — it is a second, semantic
6
+ opinion layered on top of the deterministic ``ConsistencyValidator``, never a
7
+ hard dependency, and it never blocks the replacement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from .client import NaNClient
19
+ from .exceptions import AIError
20
+ from .router import ModelRouter, TaskType
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _PROMPT = """Strictly review the target values a PDF text-replacement will write. Flag ONLY
25
+ semantic/plausibility inconsistencies that deterministic arithmetic cannot catch, for example:
26
+ - a total that is implausible for the stated number of nights,
27
+ - a cancellation fee / refund that contradicts the stated policy ("cost of the first night"),
28
+ - a date whose declared weekday is wrong for that calendar date,
29
+ - a monetary figure that looks inconsistent with the others (a tax far from the stated rate).
30
+
31
+ The values are already mutually consistent by arithmetic — do NOT report arithmetic mismatches.
32
+ Return ONLY JSON, no prose:
33
+ {"issues": [{"severity": "error" | "warning", "message": "...", "field": "..."}]}
34
+
35
+ Target values:
36
+ {values}
37
+ """
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AIReviewFinding:
42
+ """A finding raised by the AI reviewer."""
43
+
44
+ severity: str
45
+ message: str
46
+ field: str
47
+
48
+ def __str__(self) -> str:
49
+ return f"[{self.severity}] {self.field}: {self.message}"
50
+
51
+
52
+ @dataclass
53
+ class AIReviewResult:
54
+ """Outcome of an AI review pass."""
55
+
56
+ skipped: bool = False
57
+ model: str | None = None
58
+ findings: list[AIReviewFinding] = field(default_factory=list)
59
+ error: str | None = None
60
+
61
+
62
+ class AIReviewer:
63
+ """Runs a semantic consistency pass via an OpenAI-compatible chat API.
64
+
65
+ ``client`` may be injected (e.g. ``NullAIClient`` for tests). By default it
66
+ builds a ``NaNClient``, which reads ``NAN_API_KEY`` / ``NAN_BASE_URL`` from the
67
+ environment. The model defaults to the ``REVIEW`` routing (``mimo-v2.6-flash``) and
68
+ can be overridden via ``AI_REVIEW_MODEL``.
69
+ """
70
+
71
+ def __init__(self, client: Any | None = None, model: str | None = None) -> None:
72
+ self.client = client if client is not None else NaNClient()
73
+ self.model = model or os.environ.get("AI_REVIEW_MODEL") or self._default_model()
74
+
75
+ @staticmethod
76
+ def _default_model() -> str:
77
+ """Pick a sensible default model per provider (overridable via AI_REVIEW_MODEL)."""
78
+ if os.environ.get("NAN_API_KEY"):
79
+ return ModelRouter().get_model(TaskType.REVIEW) # mimo-v2.6-flash
80
+ if os.environ.get("OPENAI_API_KEY"):
81
+ return "gpt-4o-mini"
82
+ return ModelRouter().get_model(TaskType.REVIEW)
83
+
84
+ def is_available(self) -> bool:
85
+ """Whether an API key is present (and the reviewer can actually run)."""
86
+ return bool(self.client.is_configured)
87
+
88
+ async def review(
89
+ self,
90
+ values: dict[str, object],
91
+ deterministic: dict[str, bool] | None = None,
92
+ ) -> AIReviewResult:
93
+ """Run a review pass. Returns ``skipped`` when no API key is configured."""
94
+ if not self.is_available():
95
+ return AIReviewResult(skipped=True, model=self.model)
96
+
97
+ # .replace() — the prompt contains literal braces in the JSON example, so
98
+ # str.format() would try to substitute them and raise KeyError.
99
+ prompt = _PROMPT.replace("{values}", json.dumps(values, default=str))
100
+ try:
101
+ data = await self.client.chat(
102
+ self.model,
103
+ [{"role": "user", "content": prompt}],
104
+ temperature=0.0,
105
+ )
106
+ content = data["choices"][0]["message"]["content"]
107
+ return AIReviewResult(model=self.model, findings=self._parse(content))
108
+ except AIError as exc:
109
+ logger.warning("AI review error: %s", exc)
110
+ return AIReviewResult(model=self.model, error=str(exc))
111
+ except Exception as exc: # noqa: BLE001 — a failure must never block the caller
112
+ logger.warning("AI review failed unexpectedly: %s", exc)
113
+ return AIReviewResult(model=self.model, error=str(exc))
114
+
115
+ @staticmethod
116
+ def _parse(content: str) -> list[AIReviewFinding]:
117
+ """Best-effort parse of the model's JSON response into findings."""
118
+ try:
119
+ obj: dict[str, Any] = json.loads(content)
120
+ except (ValueError, TypeError):
121
+ return []
122
+ issues = obj.get("issues") or []
123
+ findings: list[AIReviewFinding] = []
124
+ for item in issues:
125
+ if not isinstance(item, dict):
126
+ continue
127
+ findings.append(
128
+ AIReviewFinding(
129
+ severity=str(item.get("severity", "warning")),
130
+ message=str(item.get("message", "")),
131
+ field=str(item.get("field", "")),
132
+ )
133
+ )
134
+ return findings