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.
Files changed (33) hide show
  1. polis_recognizer/__init__.py +98 -0
  2. polis_recognizer/contract_field_extractor.py +564 -0
  3. polis_recognizer/exceptions.py +33 -0
  4. polis_recognizer/extraction/__init__.py +22 -0
  5. polis_recognizer/extraction/candidates.py +89 -0
  6. polis_recognizer/extraction/layout.py +163 -0
  7. polis_recognizer/extraction/negation.py +78 -0
  8. polis_recognizer/extraction/normalizer.py +133 -0
  9. polis_recognizer/extraction/numeric.py +103 -0
  10. polis_recognizer/extraction/parsers/__init__.py +42 -0
  11. polis_recognizer/extraction/parsers/base.py +56 -0
  12. polis_recognizer/extraction/parsers/franchise.py +311 -0
  13. polis_recognizer/extraction/parsers/limit.py +256 -0
  14. polis_recognizer/extraction/parsers/policy_number.py +223 -0
  15. polis_recognizer/extraction/parsers/policy_period.py +256 -0
  16. polis_recognizer/extraction/parsers/premium.py +215 -0
  17. polis_recognizer/extraction/parsers/repair_mode.py +214 -0
  18. polis_recognizer/extraction/parsers/sum_type.py +168 -0
  19. polis_recognizer/extraction/pipeline.py +130 -0
  20. polis_recognizer/extraction/ranker.py +57 -0
  21. polis_recognizer/extraction/tables.py +122 -0
  22. polis_recognizer/extractor.py +242 -0
  23. polis_recognizer/hybrid_ingestion.py +72 -0
  24. polis_recognizer/image_preprocessing.py +223 -0
  25. polis_recognizer/ocr_config.py +246 -0
  26. polis_recognizer/ocr_service.py +838 -0
  27. polis_recognizer/pdf_extraction_router.py +320 -0
  28. polis_recognizer/pdfplumber_ingestion.py +98 -0
  29. polis_recognizer/policy_ingestion.py +145 -0
  30. polis_recognizer-0.1.0.dist-info/METADATA +192 -0
  31. polis_recognizer-0.1.0.dist-info/RECORD +33 -0
  32. polis_recognizer-0.1.0.dist-info/WHEEL +4 -0
  33. 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