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.
- pdf_modifier/__init__.py +26 -0
- pdf_modifier/__main__.py +13 -0
- pdf_modifier/ai/__init__.py +1 -0
- pdf_modifier/ai/client.py +296 -0
- pdf_modifier/ai/exceptions.py +29 -0
- pdf_modifier/ai/models.py +81 -0
- pdf_modifier/ai/prompts/__init__.py +39 -0
- pdf_modifier/ai/prompts/classify_doc.j2 +23 -0
- pdf_modifier/ai/prompts/detect_fields.j2 +20 -0
- pdf_modifier/ai/prompts/redact_pii.j2 +24 -0
- pdf_modifier/ai/reviewer.py +134 -0
- pdf_modifier/ai/router.py +112 -0
- pdf_modifier/ai/throttle.py +109 -0
- pdf_modifier/core/__init__.py +50 -0
- pdf_modifier/core/analyzer.py +322 -0
- pdf_modifier/core/consistency.py +224 -0
- pdf_modifier/core/exceptions.py +61 -0
- pdf_modifier/core/font_resolver.py +234 -0
- pdf_modifier/core/models.py +193 -0
- pdf_modifier/core/modifier.py +674 -0
- pdf_modifier/interfaces/__init__.py +3 -0
- pdf_modifier/interfaces/cli.py +535 -0
- pdf_modifier/interfaces/mcp.py +384 -0
- pdf_modifier/logger.py +35 -0
- pdf_modifier/web/__init__.py +1 -0
- pdf_modifier/web/app.py +72 -0
- pdf_modifier/web/config.py +39 -0
- pdf_modifier/web/deps.py +45 -0
- pdf_modifier/web/routes/__init__.py +7 -0
- pdf_modifier/web/routes/ai.py +34 -0
- pdf_modifier/web/routes/health.py +11 -0
- pdf_modifier/web/routes/pdf.py +225 -0
- pdf_modifier/web/session.py +129 -0
- pdf_modifier/web/storage.py +153 -0
- pdf_modifier-1.9.1.dist-info/METADATA +193 -0
- pdf_modifier-1.9.1.dist-info/RECORD +39 -0
- pdf_modifier-1.9.1.dist-info/WHEEL +4 -0
- pdf_modifier-1.9.1.dist-info/entry_points.txt +3 -0
- pdf_modifier-1.9.1.dist-info/licenses/LICENSE +21 -0
pdf_modifier/__init__.py
ADDED
|
@@ -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
|
+
]
|
pdf_modifier/__main__.py
ADDED
|
@@ -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
|