polis-recognizer 0.1.0__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.
- polis_recognizer/__init__.py +98 -0
- polis_recognizer/contract_field_extractor.py +564 -0
- polis_recognizer/exceptions.py +33 -0
- polis_recognizer/extraction/__init__.py +22 -0
- polis_recognizer/extraction/candidates.py +89 -0
- polis_recognizer/extraction/layout.py +163 -0
- polis_recognizer/extraction/negation.py +78 -0
- polis_recognizer/extraction/normalizer.py +133 -0
- polis_recognizer/extraction/numeric.py +103 -0
- polis_recognizer/extraction/parsers/__init__.py +42 -0
- polis_recognizer/extraction/parsers/base.py +56 -0
- polis_recognizer/extraction/parsers/franchise.py +311 -0
- polis_recognizer/extraction/parsers/limit.py +256 -0
- polis_recognizer/extraction/parsers/policy_number.py +223 -0
- polis_recognizer/extraction/parsers/policy_period.py +256 -0
- polis_recognizer/extraction/parsers/premium.py +215 -0
- polis_recognizer/extraction/parsers/repair_mode.py +214 -0
- polis_recognizer/extraction/parsers/sum_type.py +168 -0
- polis_recognizer/extraction/pipeline.py +130 -0
- polis_recognizer/extraction/ranker.py +57 -0
- polis_recognizer/extraction/tables.py +122 -0
- polis_recognizer/extractor.py +242 -0
- polis_recognizer/hybrid_ingestion.py +72 -0
- polis_recognizer/image_preprocessing.py +223 -0
- polis_recognizer/ocr_config.py +246 -0
- polis_recognizer/ocr_service.py +838 -0
- polis_recognizer/pdf_extraction_router.py +320 -0
- polis_recognizer/pdfplumber_ingestion.py +98 -0
- polis_recognizer/policy_ingestion.py +145 -0
- polis_recognizer-0.1.0.dist-info/METADATA +192 -0
- polis_recognizer-0.1.0.dist-info/RECORD +33 -0
- polis_recognizer-0.1.0.dist-info/WHEEL +4 -0
- polis_recognizer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""polis-recognizer — Russian insurance policy field extractor.
|
|
2
|
+
|
|
3
|
+
Extracts 7 structured fields from KASKO/insurance policy PDFs:
|
|
4
|
+
``policy_period``, ``franchise``, ``limit``, ``repair_mode``,
|
|
5
|
+
``premium``, ``sum_type``, ``policy_number``.
|
|
6
|
+
|
|
7
|
+
Quick start::
|
|
8
|
+
|
|
9
|
+
from polis_recognizer import PolicyExtractor
|
|
10
|
+
|
|
11
|
+
extractor = PolicyExtractor()
|
|
12
|
+
result = extractor.extract_from_pdf("/path/to/polis.pdf")
|
|
13
|
+
|
|
14
|
+
print(result.policy_number)
|
|
15
|
+
print(result.policy_period.start, result.policy_period.end)
|
|
16
|
+
print(result.franchise.value, result.franchise.currency)
|
|
17
|
+
|
|
18
|
+
The extractor combines a text-layer reader (pypdf) with optional
|
|
19
|
+
table-aware extraction (pdfplumber) and an OCR fallback (Tesseract).
|
|
20
|
+
See README.md for the full API and supported insurer formats.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .contract_field_extractor import (
|
|
24
|
+
ContractFieldExtractor,
|
|
25
|
+
ContractFieldsResult,
|
|
26
|
+
FieldDiagnostic,
|
|
27
|
+
MonetaryField,
|
|
28
|
+
PolicyPeriodField,
|
|
29
|
+
TextField,
|
|
30
|
+
)
|
|
31
|
+
from .exceptions import (
|
|
32
|
+
OCRProcessingError,
|
|
33
|
+
OCRTimeoutError,
|
|
34
|
+
UnsupportedFileTypeError,
|
|
35
|
+
)
|
|
36
|
+
from .extraction import (
|
|
37
|
+
Candidate,
|
|
38
|
+
ExtractionV2Result,
|
|
39
|
+
run_extraction,
|
|
40
|
+
)
|
|
41
|
+
from .extractor import ExtractedPolicy, PolicyExtractor
|
|
42
|
+
from .hybrid_ingestion import HybridIngestionService
|
|
43
|
+
from .ocr_config import (
|
|
44
|
+
OCRConfig,
|
|
45
|
+
OCRResult,
|
|
46
|
+
get_ocr_config,
|
|
47
|
+
reset_ocr_config,
|
|
48
|
+
validate_language_pack,
|
|
49
|
+
)
|
|
50
|
+
from .ocr_service import OCRService
|
|
51
|
+
from .pdf_extraction_router import (
|
|
52
|
+
PdfExtractionOutcome,
|
|
53
|
+
PdfExtractionRouter,
|
|
54
|
+
build_text_service,
|
|
55
|
+
)
|
|
56
|
+
from .pdfplumber_ingestion import PdfPlumberIngestionService
|
|
57
|
+
from .policy_ingestion import ExtractedTextResult, PolicyIngestionService
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__version__ = "0.1.0"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"__version__",
|
|
65
|
+
# Top-level facade (recommended entry point)
|
|
66
|
+
"PolicyExtractor",
|
|
67
|
+
"ExtractedPolicy",
|
|
68
|
+
# Field result dataclasses
|
|
69
|
+
"ContractFieldsResult",
|
|
70
|
+
"FieldDiagnostic",
|
|
71
|
+
"MonetaryField",
|
|
72
|
+
"PolicyPeriodField",
|
|
73
|
+
"TextField",
|
|
74
|
+
# Lower-level pipeline (for advanced use)
|
|
75
|
+
"ContractFieldExtractor",
|
|
76
|
+
"Candidate",
|
|
77
|
+
"ExtractionV2Result",
|
|
78
|
+
"run_extraction",
|
|
79
|
+
# OCR service
|
|
80
|
+
"OCRService",
|
|
81
|
+
"OCRConfig",
|
|
82
|
+
"OCRResult",
|
|
83
|
+
"get_ocr_config",
|
|
84
|
+
"reset_ocr_config",
|
|
85
|
+
"validate_language_pack",
|
|
86
|
+
# PDF ingestion
|
|
87
|
+
"PdfExtractionRouter",
|
|
88
|
+
"PdfExtractionOutcome",
|
|
89
|
+
"ExtractedTextResult",
|
|
90
|
+
"PolicyIngestionService",
|
|
91
|
+
"PdfPlumberIngestionService",
|
|
92
|
+
"HybridIngestionService",
|
|
93
|
+
"build_text_service",
|
|
94
|
+
# Exceptions
|
|
95
|
+
"OCRProcessingError",
|
|
96
|
+
"OCRTimeoutError",
|
|
97
|
+
"UnsupportedFileTypeError",
|
|
98
|
+
]
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contract Field Extraction Service.
|
|
3
|
+
|
|
4
|
+
Public surface: ``ContractFieldExtractor.extract_contract_fields(text)``
|
|
5
|
+
returns a ``ContractFieldsResult`` whose ``to_dict()`` shape is part of
|
|
6
|
+
the API contract (consumers: ``PreIngestJob.contract_fields``,
|
|
7
|
+
``PolicyContextBuilder``, admin renderers, downstream tests).
|
|
8
|
+
|
|
9
|
+
The implementation delegates to the deterministic v2 pipeline under
|
|
10
|
+
``apps.analyses.services.extraction``. The v2 pipeline produces typed
|
|
11
|
+
``Candidate`` objects with explicit confidence components and a
|
|
12
|
+
diagnostic trace; this module re-shapes them into the legacy dataclass
|
|
13
|
+
contract so existing consumers see no schema change. New v2-only
|
|
14
|
+
fields (premium, sum_type) live on
|
|
15
|
+
``ContractFieldsResult.additional_fields`` and surface through
|
|
16
|
+
``to_diagnostics_payload()`` without altering ``to_dict()``.
|
|
17
|
+
|
|
18
|
+
C4 removed the legacy v1 ``_extract_*`` private helpers (~1050 lines)
|
|
19
|
+
and their direct unit tests. v1 was dead in production for a while;
|
|
20
|
+
the file kept it alive only for tests. Corpus-level coverage of the
|
|
21
|
+
end-to-end behaviour stays in
|
|
22
|
+
``test_contract_field_extractor.py::TestPartialExtraction``,
|
|
23
|
+
``TestAdditionalDeterministicFields`` and
|
|
24
|
+
``test_contract_field_extractor_real_kasko.py``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
|
|
32
|
+
from .extraction import (
|
|
33
|
+
Candidate,
|
|
34
|
+
run_extraction,
|
|
35
|
+
)
|
|
36
|
+
from .extraction.parsers import ADDITIONAL_PARSERS
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_LEGACY_FIELD_DIAGNOSTIC_MESSAGES = {
|
|
42
|
+
"extracted": "Поле извлечено по детерминированному правилу.",
|
|
43
|
+
"absent_recognized": "Полис явно указывает отсутствие поля.",
|
|
44
|
+
"missing": "Подходящий паттерн для поля не найден.",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _legacy_status_and_reason(candidate, field_name: str) -> tuple[str, str, str]:
|
|
49
|
+
"""Map a v2 Candidate state to the legacy {stage, status, reason_code, message}."""
|
|
50
|
+
if candidate is None:
|
|
51
|
+
return ("missing", "no_pattern_match",
|
|
52
|
+
f"Подходящий паттерн для поля '{field_name}' не найден.")
|
|
53
|
+
if candidate.state == "found":
|
|
54
|
+
return ("extracted", candidate.pattern_id or "pattern_matched",
|
|
55
|
+
"Поле извлечено по детерминированному правилу.")
|
|
56
|
+
if candidate.state == "absent":
|
|
57
|
+
return ("extracted", candidate.pattern_id or "absent_recognized",
|
|
58
|
+
"Полис явно указывает отсутствие поля.")
|
|
59
|
+
return ("missing", candidate.pattern_id or "no_pattern_match",
|
|
60
|
+
f"Подходящий паттерн для поля '{field_name}' не найден.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _map_v2_to_legacy(v2_result, extractor: "ContractFieldExtractor") -> dict:
|
|
64
|
+
"""Convert v2 ExtractionV2Result Candidates into legacy dataclasses.
|
|
65
|
+
|
|
66
|
+
Also records per-field diagnostics on the extractor instance so the
|
|
67
|
+
legacy ``extraction_status`` / ``warning_codes`` semantics stay
|
|
68
|
+
intact for the surrounding tasks.py orchestration.
|
|
69
|
+
"""
|
|
70
|
+
legacy = v2_result.legacy_fields
|
|
71
|
+
|
|
72
|
+
def _record(field_name: str, candidate):
|
|
73
|
+
status, reason, message = _legacy_status_and_reason(candidate, field_name)
|
|
74
|
+
extractor._record_field_diagnostic(
|
|
75
|
+
field_name,
|
|
76
|
+
status,
|
|
77
|
+
reason,
|
|
78
|
+
message,
|
|
79
|
+
issue_warning=(status == "missing" and field_name in extractor.SUPPORTED_FIELD_NAMES),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# policy_period — dict {start, end} or None
|
|
83
|
+
pp_cand = legacy.get("policy_period")
|
|
84
|
+
_record("policy_period", pp_cand)
|
|
85
|
+
if pp_cand and pp_cand.state == "found" and isinstance(pp_cand.value, dict):
|
|
86
|
+
policy_period = PolicyPeriodField(
|
|
87
|
+
start=pp_cand.value.get("start"),
|
|
88
|
+
end=pp_cand.value.get("end"),
|
|
89
|
+
confidence=pp_cand.confidence,
|
|
90
|
+
source_fragment=pp_cand.source_fragment or None,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
policy_period = PolicyPeriodField(
|
|
94
|
+
start=None, end=None, confidence=0.0, source_fragment=None
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# franchise — MonetaryField, possibly absent=True
|
|
98
|
+
fr_cand = legacy.get("franchise")
|
|
99
|
+
_record("franchise", fr_cand)
|
|
100
|
+
franchise = _candidate_to_monetary(fr_cand)
|
|
101
|
+
|
|
102
|
+
# limit — MonetaryField (always non-absent path)
|
|
103
|
+
lim_cand = legacy.get("limit")
|
|
104
|
+
_record("limit", lim_cand)
|
|
105
|
+
limit = _candidate_to_monetary(lim_cand)
|
|
106
|
+
|
|
107
|
+
# repair_mode — TextField with .value as plain string ("dealer"/"service"/"cash")
|
|
108
|
+
rm_cand = legacy.get("repair_mode")
|
|
109
|
+
_record("repair_mode", rm_cand)
|
|
110
|
+
if rm_cand and rm_cand.state == "found":
|
|
111
|
+
repair_mode = TextField(
|
|
112
|
+
value=rm_cand.value if isinstance(rm_cand.value, str) else None,
|
|
113
|
+
confidence=rm_cand.confidence,
|
|
114
|
+
source_fragment=rm_cand.source_fragment or None,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
repair_mode = TextField(value=None, confidence=0.0, source_fragment=None)
|
|
118
|
+
|
|
119
|
+
# Additional v2 fields — passed through as plain dicts so the
|
|
120
|
+
# rest of the system can consume them without a new import.
|
|
121
|
+
additional_fields = {}
|
|
122
|
+
for parser_name, candidate in v2_result.additional_fields.items():
|
|
123
|
+
if candidate is None:
|
|
124
|
+
additional_fields[parser_name] = None
|
|
125
|
+
else:
|
|
126
|
+
additional_fields[parser_name] = candidate.to_dict()
|
|
127
|
+
|
|
128
|
+
fields_found_flags = {
|
|
129
|
+
"policy_period": policy_period.start is not None and policy_period.end is not None,
|
|
130
|
+
"franchise": (franchise.value is not None) or franchise.absent,
|
|
131
|
+
"limit": limit.value is not None,
|
|
132
|
+
"repair_mode": bool(repair_mode.value),
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
"policy_period": policy_period,
|
|
136
|
+
"franchise": franchise,
|
|
137
|
+
"limit": limit,
|
|
138
|
+
"repair_mode": repair_mode,
|
|
139
|
+
"additional_fields": additional_fields,
|
|
140
|
+
"fields_found_flags": fields_found_flags,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _candidate_to_monetary(candidate) -> "MonetaryField":
|
|
145
|
+
if candidate is None or candidate.state == "not_found" or not isinstance(candidate.value, dict):
|
|
146
|
+
return MonetaryField(
|
|
147
|
+
value=None, currency=None, confidence=0.0, source_fragment=None,
|
|
148
|
+
)
|
|
149
|
+
value = candidate.value.get("value")
|
|
150
|
+
currency = candidate.value.get("currency")
|
|
151
|
+
is_absent = bool(candidate.value.get("absent")) or candidate.state == "absent"
|
|
152
|
+
return MonetaryField(
|
|
153
|
+
value=0 if is_absent else value,
|
|
154
|
+
currency=currency or ("RUB" if is_absent else None),
|
|
155
|
+
confidence=candidate.confidence,
|
|
156
|
+
source_fragment=candidate.source_fragment or None,
|
|
157
|
+
absent=is_absent,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class PolicyPeriodField:
|
|
163
|
+
"""Policy period with start and end dates."""
|
|
164
|
+
start: Optional[str] # ISO format YYYY-MM-DD
|
|
165
|
+
end: Optional[str] # ISO format YYYY-MM-DD
|
|
166
|
+
confidence: float # 0.0 to 1.0
|
|
167
|
+
source_fragment: Optional[str] # Text fragment where found (max 200 chars)
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> dict:
|
|
170
|
+
"""Convert to dictionary for JSON serialization."""
|
|
171
|
+
return {
|
|
172
|
+
'start': self.start,
|
|
173
|
+
'end': self.end,
|
|
174
|
+
'confidence': self.confidence,
|
|
175
|
+
'source_fragment': self.source_fragment
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class MonetaryField:
|
|
181
|
+
"""Monetary value with currency.
|
|
182
|
+
|
|
183
|
+
`absent=True` means the policy explicitly states the field does NOT
|
|
184
|
+
apply (e.g. "франшиза - нет"). It is qualitatively different from a
|
|
185
|
+
null value, which means extraction did not find anything. Absent
|
|
186
|
+
fields render as "не предусмотрена" downstream, not as
|
|
187
|
+
"[данные не распознаны автоматически]".
|
|
188
|
+
"""
|
|
189
|
+
value: Optional[float]
|
|
190
|
+
currency: Optional[str] # RUB, USD, EUR, or None
|
|
191
|
+
confidence: float # 0.0 to 1.0
|
|
192
|
+
source_fragment: Optional[str] # Text fragment where found (max 200 chars)
|
|
193
|
+
absent: bool = False
|
|
194
|
+
|
|
195
|
+
def to_dict(self) -> dict:
|
|
196
|
+
"""Convert to dictionary for JSON serialization.
|
|
197
|
+
|
|
198
|
+
`absent` is included only when True so the default contract on
|
|
199
|
+
existing consumers stays unchanged.
|
|
200
|
+
"""
|
|
201
|
+
payload = {
|
|
202
|
+
'value': self.value,
|
|
203
|
+
'currency': self.currency,
|
|
204
|
+
'confidence': self.confidence,
|
|
205
|
+
'source_fragment': self.source_fragment,
|
|
206
|
+
}
|
|
207
|
+
if self.absent:
|
|
208
|
+
payload['absent'] = True
|
|
209
|
+
return payload
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class TextField:
|
|
214
|
+
"""Scalar text field with confidence and source fragment."""
|
|
215
|
+
value: Optional[str]
|
|
216
|
+
confidence: float
|
|
217
|
+
source_fragment: Optional[str]
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> dict:
|
|
220
|
+
return {
|
|
221
|
+
'value': self.value,
|
|
222
|
+
'confidence': self.confidence,
|
|
223
|
+
'source_fragment': self.source_fragment
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@dataclass
|
|
228
|
+
class FieldDiagnostic:
|
|
229
|
+
"""Safe per-stage diagnostic entry for extraction telemetry."""
|
|
230
|
+
stage: str
|
|
231
|
+
status: str
|
|
232
|
+
reason_code: str
|
|
233
|
+
message: str
|
|
234
|
+
|
|
235
|
+
def to_dict(self) -> dict:
|
|
236
|
+
return {
|
|
237
|
+
'stage': self.stage,
|
|
238
|
+
'status': self.status,
|
|
239
|
+
'reason_code': self.reason_code,
|
|
240
|
+
'message': self.message,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class ContractFieldsResult:
|
|
246
|
+
"""Complete result of field extraction.
|
|
247
|
+
|
|
248
|
+
``to_dict()`` shape is part of the public contract — the legacy
|
|
249
|
+
fields extracted from the polis itself. New v2-only fields land in
|
|
250
|
+
``additional_fields`` and surface through
|
|
251
|
+
``to_diagnostics_payload()`` instead, so ``contract_fields`` JSON
|
|
252
|
+
in the database keeps the same keys consumers know about.
|
|
253
|
+
|
|
254
|
+
Task 1.9 (POLICY_RECOGNITION_QUALITY_PLAN): ``notice_deadline`` and
|
|
255
|
+
``documents_from_policy`` were removed because that data is in the
|
|
256
|
+
Rules document, not in the polis. Old payloads that still carry
|
|
257
|
+
those keys are tolerated by readers (forward-compat).
|
|
258
|
+
"""
|
|
259
|
+
policy_period: PolicyPeriodField
|
|
260
|
+
franchise: MonetaryField
|
|
261
|
+
limit: MonetaryField
|
|
262
|
+
repair_mode: TextField
|
|
263
|
+
processing_time_ms: float
|
|
264
|
+
extraction_status: str = 'done'
|
|
265
|
+
diagnostics: list[dict] = field(default_factory=list)
|
|
266
|
+
warning_codes: list[str] = field(default_factory=list)
|
|
267
|
+
additional_fields: dict = field(default_factory=dict)
|
|
268
|
+
|
|
269
|
+
def to_dict(self) -> dict:
|
|
270
|
+
"""
|
|
271
|
+
Convert to dictionary matching contract_context_json schema.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
{
|
|
275
|
+
"policy_period": {
|
|
276
|
+
"start": "YYYY-MM-DD|null",
|
|
277
|
+
"end": "YYYY-MM-DD|null",
|
|
278
|
+
"confidence": 0.0-1.0,
|
|
279
|
+
"source_fragment": "string|null"
|
|
280
|
+
},
|
|
281
|
+
"franchise": {
|
|
282
|
+
"value": number|null,
|
|
283
|
+
"currency": "RUB|USD|EUR|null",
|
|
284
|
+
"confidence": 0.0-1.0,
|
|
285
|
+
"source_fragment": "string|null"
|
|
286
|
+
},
|
|
287
|
+
"limit": {
|
|
288
|
+
"value": number|null,
|
|
289
|
+
"currency": "RUB|USD|EUR|null",
|
|
290
|
+
"confidence": 0.0-1.0,
|
|
291
|
+
"source_fragment": "string|null"
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
"""
|
|
295
|
+
return {
|
|
296
|
+
'policy_period': self.policy_period.to_dict(),
|
|
297
|
+
'franchise': self.franchise.to_dict(),
|
|
298
|
+
'limit': self.limit.to_dict(),
|
|
299
|
+
'repair_mode': self.repair_mode.to_dict(),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def to_diagnostics_payload(self) -> dict:
|
|
303
|
+
"""Return safe diagnostics for task-level telemetry surfaces."""
|
|
304
|
+
payload = {
|
|
305
|
+
'status': self.extraction_status,
|
|
306
|
+
'processing_time_ms': round(float(self.processing_time_ms), 2),
|
|
307
|
+
'warning_codes': list(self.warning_codes),
|
|
308
|
+
'diagnostics': list(self.diagnostics),
|
|
309
|
+
}
|
|
310
|
+
if self.additional_fields:
|
|
311
|
+
payload['additional_fields'] = dict(self.additional_fields)
|
|
312
|
+
return payload
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class ContractFieldExtractor:
|
|
316
|
+
"""
|
|
317
|
+
Service for extracting contract fields from policy text.
|
|
318
|
+
|
|
319
|
+
Extracts fields using deterministic pattern matching:
|
|
320
|
+
- policy_period (start/end dates)
|
|
321
|
+
- franchise (deductible amount and currency)
|
|
322
|
+
- limit (insurance sum and currency)
|
|
323
|
+
- repair_mode (dealer/service/cash)
|
|
324
|
+
|
|
325
|
+
All extraction is rule-based without LLM usage.
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
def __init__(self):
|
|
329
|
+
self._correlation_id: Optional[str] = None
|
|
330
|
+
self._field_diagnostics: dict[str, FieldDiagnostic] = {}
|
|
331
|
+
self._warning_codes: list[str] = []
|
|
332
|
+
|
|
333
|
+
# Configuration constants
|
|
334
|
+
MAX_TEXT_LENGTH = 100_000 # Process first 100k chars
|
|
335
|
+
TIMEOUT_MS = 500 # Maximum processing time
|
|
336
|
+
SOURCE_FRAGMENT_MAX_LENGTH = 200 # Max length for logged fragments
|
|
337
|
+
SUPPORTED_FIELD_NAMES = (
|
|
338
|
+
'policy_period',
|
|
339
|
+
'franchise',
|
|
340
|
+
'limit',
|
|
341
|
+
'repair_mode',
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# NOTE: All `_extract_*` per-field helpers and their pattern lists
|
|
345
|
+
# used to live here (~1050 lines). C4 removed them — extraction is
|
|
346
|
+
# delegated to `apps.analyses.services.extraction` (the v2 pipeline)
|
|
347
|
+
# and its result is mapped back into the dataclass shapes below by
|
|
348
|
+
# `_map_v2_to_legacy`. The legacy methods were dead in production
|
|
349
|
+
# for a while; only direct unit tests kept them alive. Those tests
|
|
350
|
+
# were dropped together with the methods (corpus regressions live
|
|
351
|
+
# in `test_contract_field_extractor.py::TestExtractContractFields`,
|
|
352
|
+
# `TestPartialExtraction`, `TestAdditionalDeterministicFields` and
|
|
353
|
+
# `test_contract_field_extractor_real_kasko.py`).
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def empty_contract_fields_payload(cls) -> dict:
|
|
357
|
+
"""Return stable null structure for contract_fields contract."""
|
|
358
|
+
return cls()._create_null_result(0.0).to_dict()
|
|
359
|
+
|
|
360
|
+
def _reset_run_state(self, correlation_id: Optional[str] = None) -> None:
|
|
361
|
+
self._correlation_id = correlation_id
|
|
362
|
+
self._field_diagnostics = {}
|
|
363
|
+
self._warning_codes = []
|
|
364
|
+
|
|
365
|
+
def _append_warning_code(self, code: Optional[str]) -> None:
|
|
366
|
+
if not code or code in self._warning_codes:
|
|
367
|
+
return
|
|
368
|
+
self._warning_codes.append(code)
|
|
369
|
+
|
|
370
|
+
def _serialize_diagnostics(self) -> list[dict]:
|
|
371
|
+
diagnostics = []
|
|
372
|
+
for field_name in self.SUPPORTED_FIELD_NAMES:
|
|
373
|
+
diagnostic = self._field_diagnostics.get(field_name)
|
|
374
|
+
if diagnostic is not None:
|
|
375
|
+
diagnostics.append(diagnostic.to_dict())
|
|
376
|
+
|
|
377
|
+
for stage_name, diagnostic in self._field_diagnostics.items():
|
|
378
|
+
if stage_name in self.SUPPORTED_FIELD_NAMES:
|
|
379
|
+
continue
|
|
380
|
+
diagnostics.append(diagnostic.to_dict())
|
|
381
|
+
|
|
382
|
+
return diagnostics
|
|
383
|
+
|
|
384
|
+
def _log_stage_issue(
|
|
385
|
+
self,
|
|
386
|
+
stage: str,
|
|
387
|
+
reason_code: str,
|
|
388
|
+
message: str,
|
|
389
|
+
*,
|
|
390
|
+
level: str = 'warning',
|
|
391
|
+
exc_info: bool = False,
|
|
392
|
+
extra: Optional[dict] = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
log_extra = {
|
|
395
|
+
'stage': stage,
|
|
396
|
+
'reason_code': reason_code,
|
|
397
|
+
}
|
|
398
|
+
if self._correlation_id:
|
|
399
|
+
log_extra['correlation_id'] = self._correlation_id
|
|
400
|
+
if extra:
|
|
401
|
+
log_extra.update(extra)
|
|
402
|
+
getattr(logger, level)(message, extra=log_extra, exc_info=exc_info)
|
|
403
|
+
|
|
404
|
+
def _record_field_diagnostic(
|
|
405
|
+
self,
|
|
406
|
+
field_name: str,
|
|
407
|
+
status: str,
|
|
408
|
+
reason_code: str,
|
|
409
|
+
message: str,
|
|
410
|
+
*,
|
|
411
|
+
issue_warning: bool = False,
|
|
412
|
+
log_level: Optional[str] = None,
|
|
413
|
+
exc_info: bool = False,
|
|
414
|
+
) -> None:
|
|
415
|
+
self._field_diagnostics[field_name] = FieldDiagnostic(
|
|
416
|
+
stage=field_name,
|
|
417
|
+
status=status,
|
|
418
|
+
reason_code=reason_code,
|
|
419
|
+
message=message,
|
|
420
|
+
)
|
|
421
|
+
if issue_warning:
|
|
422
|
+
self._append_warning_code('contract_field_extraction_partial')
|
|
423
|
+
self._append_warning_code(f'field_{field_name}_{reason_code}')
|
|
424
|
+
if log_level:
|
|
425
|
+
self._log_stage_issue(
|
|
426
|
+
field_name,
|
|
427
|
+
reason_code,
|
|
428
|
+
message,
|
|
429
|
+
level=log_level,
|
|
430
|
+
exc_info=exc_info,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def extract_contract_fields(
|
|
435
|
+
self,
|
|
436
|
+
text: str,
|
|
437
|
+
correlation_id: Optional[str] = None,
|
|
438
|
+
*,
|
|
439
|
+
tables: Optional[list] = None,
|
|
440
|
+
) -> ContractFieldsResult:
|
|
441
|
+
"""Extract contract fields via the deterministic v2 pipeline.
|
|
442
|
+
|
|
443
|
+
The body of this method is intentionally tiny: all the heavy
|
|
444
|
+
lifting (normalization, layout analysis, per-field parsers,
|
|
445
|
+
candidate ranking) lives in
|
|
446
|
+
``apps.analyses.services.extraction``. We translate the
|
|
447
|
+
pipeline's ``Candidate`` objects back into the legacy dataclass
|
|
448
|
+
shapes that the rest of the system already understands.
|
|
449
|
+
|
|
450
|
+
Never raises — pipeline-level exceptions surface as a
|
|
451
|
+
``failed`` ContractFieldsResult with nulls.
|
|
452
|
+
"""
|
|
453
|
+
start_time = time.time()
|
|
454
|
+
self._reset_run_state(correlation_id)
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
if not text:
|
|
458
|
+
logger.info(
|
|
459
|
+
"Empty or null text provided, returning null structure",
|
|
460
|
+
extra={'correlation_id': correlation_id} if correlation_id else None,
|
|
461
|
+
)
|
|
462
|
+
for field_name in self.SUPPORTED_FIELD_NAMES:
|
|
463
|
+
self._record_field_diagnostic(
|
|
464
|
+
field_name,
|
|
465
|
+
'skipped',
|
|
466
|
+
'empty_input',
|
|
467
|
+
'Извлеченный текст пустой, поле не анализировалось.',
|
|
468
|
+
)
|
|
469
|
+
return self._create_null_result(0.0, extraction_status='skipped')
|
|
470
|
+
|
|
471
|
+
if len(text) > self.MAX_TEXT_LENGTH:
|
|
472
|
+
logger.warning(
|
|
473
|
+
f"Text length {len(text)} exceeds MAX_TEXT_LENGTH {self.MAX_TEXT_LENGTH}, truncating"
|
|
474
|
+
)
|
|
475
|
+
text = text[:self.MAX_TEXT_LENGTH]
|
|
476
|
+
|
|
477
|
+
v2_result = run_extraction(
|
|
478
|
+
text, correlation_id=correlation_id, tables=tables
|
|
479
|
+
)
|
|
480
|
+
mapped = _map_v2_to_legacy(v2_result, self)
|
|
481
|
+
processing_time_ms = (time.time() - start_time) * 1000
|
|
482
|
+
|
|
483
|
+
fields_found = [
|
|
484
|
+
name for name, present in mapped["fields_found_flags"].items() if present
|
|
485
|
+
]
|
|
486
|
+
logger.info(
|
|
487
|
+
"Contract fields extracted via v2 pipeline: %d fields found",
|
|
488
|
+
len(fields_found),
|
|
489
|
+
extra={
|
|
490
|
+
"fields_found": fields_found,
|
|
491
|
+
"processing_time_ms": processing_time_ms,
|
|
492
|
+
"v2_elapsed_ms": v2_result.elapsed_ms,
|
|
493
|
+
"correlation_id": correlation_id,
|
|
494
|
+
"warning_codes": list(self._warning_codes),
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return ContractFieldsResult(
|
|
499
|
+
policy_period=mapped["policy_period"],
|
|
500
|
+
franchise=mapped["franchise"],
|
|
501
|
+
limit=mapped["limit"],
|
|
502
|
+
repair_mode=mapped["repair_mode"],
|
|
503
|
+
processing_time_ms=processing_time_ms,
|
|
504
|
+
extraction_status='partial' if self._warning_codes else 'done',
|
|
505
|
+
diagnostics=self._serialize_diagnostics(),
|
|
506
|
+
warning_codes=list(self._warning_codes),
|
|
507
|
+
additional_fields=mapped["additional_fields"],
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
processing_time_ms = (time.time() - start_time) * 1000
|
|
512
|
+
self._append_warning_code('contract_field_extraction_failed')
|
|
513
|
+
self._field_diagnostics['field_extraction'] = FieldDiagnostic(
|
|
514
|
+
stage='field_extraction',
|
|
515
|
+
status='failed',
|
|
516
|
+
reason_code='unexpected_exception',
|
|
517
|
+
message='Этап извлечения полей договора завершился с ошибкой.',
|
|
518
|
+
)
|
|
519
|
+
logger.error(
|
|
520
|
+
f"Error during field extraction: {exc}",
|
|
521
|
+
extra={
|
|
522
|
+
"processing_time_ms": processing_time_ms,
|
|
523
|
+
"correlation_id": correlation_id,
|
|
524
|
+
"reason_code": "unexpected_exception",
|
|
525
|
+
},
|
|
526
|
+
exc_info=True,
|
|
527
|
+
)
|
|
528
|
+
return self._create_null_result(processing_time_ms, extraction_status='failed')
|
|
529
|
+
|
|
530
|
+
def _create_null_result(
|
|
531
|
+
self,
|
|
532
|
+
processing_time_ms: float,
|
|
533
|
+
extraction_status: str = 'done',
|
|
534
|
+
) -> ContractFieldsResult:
|
|
535
|
+
"""Create a result with all fields set to null."""
|
|
536
|
+
return ContractFieldsResult(
|
|
537
|
+
policy_period=PolicyPeriodField(
|
|
538
|
+
start=None,
|
|
539
|
+
end=None,
|
|
540
|
+
confidence=0.0,
|
|
541
|
+
source_fragment=None
|
|
542
|
+
),
|
|
543
|
+
franchise=MonetaryField(
|
|
544
|
+
value=None,
|
|
545
|
+
currency=None,
|
|
546
|
+
confidence=0.0,
|
|
547
|
+
source_fragment=None
|
|
548
|
+
),
|
|
549
|
+
limit=MonetaryField(
|
|
550
|
+
value=None,
|
|
551
|
+
currency=None,
|
|
552
|
+
confidence=0.0,
|
|
553
|
+
source_fragment=None
|
|
554
|
+
),
|
|
555
|
+
repair_mode=TextField(
|
|
556
|
+
value=None,
|
|
557
|
+
confidence=0.0,
|
|
558
|
+
source_fragment=None
|
|
559
|
+
),
|
|
560
|
+
processing_time_ms=processing_time_ms,
|
|
561
|
+
extraction_status=extraction_status,
|
|
562
|
+
diagnostics=self._serialize_diagnostics(),
|
|
563
|
+
warning_codes=list(self._warning_codes),
|
|
564
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Exceptions raised by the OCR / extraction pipeline.
|
|
2
|
+
|
|
3
|
+
These cover failure modes of the recognizer itself (not the
|
|
4
|
+
polishelper application). Polishelper-specific exceptions
|
|
5
|
+
(PlaybookNotFoundError, LLMGuardViolationError) live in
|
|
6
|
+
``apps.analyses.exceptions``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OCRTimeoutError(Exception):
|
|
11
|
+
"""Raised when OCR processing exceeds the configured timeout.
|
|
12
|
+
|
|
13
|
+
The pipeline enforces a per-document timeout (default 300s) to keep
|
|
14
|
+
a hung tesseract subprocess from blocking the request indefinitely.
|
|
15
|
+
"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OCRProcessingError(Exception):
|
|
20
|
+
"""Raised when the OCR engine fails on otherwise valid input.
|
|
21
|
+
|
|
22
|
+
Covers corrupted files, invalid image data, or internal tesseract
|
|
23
|
+
crashes that aren't a timeout.
|
|
24
|
+
"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnsupportedFileTypeError(Exception):
|
|
29
|
+
"""Raised when a file with an unsupported extension is submitted.
|
|
30
|
+
|
|
31
|
+
The recognizer accepts PDF, PNG, JPG, JPEG.
|
|
32
|
+
"""
|
|
33
|
+
pass
|