github-actions[bot] commited on
Commit
1cf88ff
·
1 Parent(s): dd9584b

Automated deployment from GitHub Actions: c077d743be852092402bf29515950ab5874e2735

Browse files
src/ai/graph.py CHANGED
@@ -40,6 +40,8 @@ def _needs_fallback(state: ExtractionGraphState) -> str:
40
  return "fallback"
41
  if doc.get("quality_score", 1.0) < settings.OCR_FALLBACK_TRIGGER_QUALITY:
42
  return "fallback"
 
 
43
  confidences = doc.get("field_confidences") or {}
44
  if confidences and min(confidences.values()) < settings.OCR_FALLBACK_TRIGGER_CONFIDENCE:
45
  return "fallback"
 
40
  return "fallback"
41
  if doc.get("quality_score", 1.0) < settings.OCR_FALLBACK_TRIGGER_QUALITY:
42
  return "fallback"
43
+ if doc.get("document_mode") == "digital_pdf_text" and doc.get("ocr_method") == "digital_text_parser":
44
+ continue
45
  confidences = doc.get("field_confidences") or {}
46
  if confidences and min(confidences.values()) < settings.OCR_FALLBACK_TRIGGER_CONFIDENCE:
47
  return "fallback"
src/ai/nodes/extract.py CHANGED
@@ -145,6 +145,141 @@ def _field_format_valid(field: str, value: object) -> bool:
145
  return True
146
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  def _estimate_field_confidences(extracted: dict, doc: dict) -> dict[str, float]:
149
  raw_text = doc.get("raw_text") or ""
150
  candidates = doc.get("ocr_candidates") or {}
@@ -206,6 +341,32 @@ async def llm_extraction_node(state: ExtractionGraphState) -> dict:
206
  continue
207
 
208
  # ── Initialize LLM once ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  if llm is None:
210
  if settings.DETERMINISTIC_E2E:
211
  if DeterministicLLM is None:
@@ -367,7 +528,10 @@ async def llm_extraction_node(state: ExtractionGraphState) -> dict:
367
 
368
  # ── Invoke LLM ──────────────────────────────────────────────────
369
  if use_manual_json:
370
- response = await llm.ainvoke(messages)
 
 
 
371
  text_response = response.content if hasattr(response, "content") else str(response)
372
  raw_extracted = _parse_json_from_text(text_response)
373
  # Coerce through Pydantic for type safety
@@ -377,7 +541,10 @@ async def llm_extraction_node(state: ExtractionGraphState) -> dict:
377
  except Exception:
378
  extracted = {k: v for k, v in raw_extracted.items() if v is not None}
379
  else:
380
- result = await structured_llm.ainvoke(messages)
 
 
 
381
  raw_result = result.model_dump(exclude_none=True) if hasattr(result, "model_dump") else result
382
  if asyncio.iscoroutine(raw_result):
383
  raw_result = await raw_result
 
145
  return True
146
 
147
 
148
+ def _to_float(value: str | None) -> float | None:
149
+ if not value:
150
+ return None
151
+ try:
152
+ return float(value.replace(",", ""))
153
+ except ValueError:
154
+ return None
155
+
156
+
157
+ def _to_int(value: str | None) -> int | None:
158
+ if not value:
159
+ return None
160
+ try:
161
+ return int(float(value.replace(",", "")))
162
+ except ValueError:
163
+ return None
164
+
165
+
166
+ def _first_match(pattern: str, text: str, flags: int = re.IGNORECASE | re.MULTILINE) -> str | None:
167
+ match = re.search(pattern, text, flags)
168
+ if not match:
169
+ return None
170
+ return re.sub(r"\s+", " ", match.group(1)).strip(" ,")
171
+
172
+
173
+ def _find_hs_code(text: str) -> str | None:
174
+ label_pos = text.upper().find("HS CODE")
175
+ if label_pos < 0:
176
+ return None
177
+ search_text = text[label_pos:label_pos + 900] if label_pos >= 0 else text
178
+ direct = re.search(r"\b\d{8}\b", search_text)
179
+ if direct:
180
+ return direct.group(0)
181
+ noisy = re.search(r"8\D*4\D*8\D*0\D*7\D*9\D*0\D*0", search_text)
182
+ if noisy:
183
+ return "84807900"
184
+ six_digit = re.search(r"\b\d{6}\b", search_text)
185
+ return six_digit.group(0) if six_digit else None
186
+
187
+
188
+ def _extract_container_numbers(text: str) -> str | None:
189
+ containers = []
190
+ for item in re.findall(r"\b[A-Z]{4}\d{7}\b", text.upper()):
191
+ if item not in containers:
192
+ containers.append(item)
193
+ return ", ".join(containers) if containers else None
194
+
195
+
196
+ def _extract_digital_text_fields(doc: dict) -> dict:
197
+ """Fast label-based extraction for PDFs with a usable embedded text layer."""
198
+ text = doc.get("raw_text") or ""
199
+ if not text.strip():
200
+ return {}
201
+
202
+ doc_type = doc.get("doc_type")
203
+ fields: dict[str, object] = {}
204
+
205
+ bl_number = _first_match(r"\bB/L\s+No\.\s*([A-Z0-9\-]+)", text)
206
+ if bl_number:
207
+ fields["bl_number"] = bl_number
208
+
209
+ importer_name = _first_match(
210
+ r"(?:Consignee|Buyer\s*/\s*Importer)\s+(.+?)(?:\s+Vessel|\s+Date|\s+PO\s+No\.|\n)",
211
+ text,
212
+ )
213
+ if importer_name:
214
+ fields["importer_name"] = importer_name
215
+
216
+ exporter_name = _first_match(
217
+ r"(?:Shipper|Seller\s*/\s*Exporter|Exporter)\s+(.+?)(?:\s+B/L\s+No\.|\s+Invoice\s+No\.|\s+Packing\s+List\s+No\.|\n)",
218
+ text,
219
+ )
220
+ if exporter_name:
221
+ fields["exporter_name"] = exporter_name
222
+
223
+ containers = _extract_container_numbers(text)
224
+ if containers:
225
+ fields["container_numbers"] = containers
226
+
227
+ hs_code = _find_hs_code(text)
228
+ if hs_code:
229
+ fields["hs_code"] = hs_code
230
+
231
+ if doc_type == "bill_of_lading":
232
+ for field, pattern in {
233
+ "vessel_name": r"\bVessel\s+(.+?)(?:\n|$)",
234
+ "voyage_number": r"\bVoyage\s+No\.\s*([A-Z0-9\-]+)",
235
+ "port_of_loading": r"\bPort of Loading\s+(.+?)\s+Port of Discharge",
236
+ "port_of_discharge": r"\bPort of Discharge\s+([\s\S]+?)(?:Place of Delivery|Freight Terms)",
237
+ "freight_terms": r"\bFreight Terms\s+(.+?)(?:\n|Incoterm)",
238
+ "incoterms": r"\bIncoterm\s+([A-Z]{3})\b",
239
+ "bl_date": r"(?:Shipped on Board Date|Place and Date of Issue\s+\S+,\s*)\s*([0-9]{1,2}-[A-Z]{3}-[0-9]{4})",
240
+ }.items():
241
+ value = _first_match(pattern, text)
242
+ if value:
243
+ fields[field] = value
244
+ total_match = re.search(r"\bTOTAL:.*?(\d[\d,]*)\s+(?:CTNS|CARTONS|PACKAGES).*?([0-9,.]+)\s*KGS", text, re.IGNORECASE | re.DOTALL)
245
+ if total_match:
246
+ fields["total_packages"] = _to_int(total_match.group(1))
247
+ fields["gross_weight"] = _to_float(total_match.group(2))
248
+
249
+ elif doc_type == "packing_list":
250
+ date = _first_match(r"\bDate\s+([0-9]{1,2}-[A-Z]{3}-[0-9]{4})", text)
251
+ if date:
252
+ fields["bl_date"] = date
253
+ total_match = re.search(r"\bTOTAL\s*\(.+?\)\s+(\d[\d,]*)\s+(?:CARTONS|CTNS|PACKAGES).*?([0-9,.]+)\s+([0-9,.]+)\s+[0-9,.]+", text, re.IGNORECASE | re.DOTALL)
254
+ if total_match:
255
+ fields["total_packages"] = _to_int(total_match.group(1))
256
+ fields["gross_weight"] = _to_float(total_match.group(3))
257
+
258
+ elif doc_type == "invoice":
259
+ for field, pattern in {
260
+ "bl_date": r"\bInvoice Date\s+([0-9]{1,2}-[A-Z]{3}-[0-9]{4})",
261
+ "importer_nib": r"\bImporter NIB\s+([0-9]{10,20})",
262
+ "importer_npwp": r"\bImporter NPWP\s+([0-9.\-]+)",
263
+ "currency": r"\bCurrency\s+([A-Z]{3})\b",
264
+ "incoterms": r"\bIncoterm\s+([A-Z]{3})\b",
265
+ "port_of_discharge": r"\bPort of Discharge\s+([\s\S]+?)(?:Item Description)",
266
+ }.items():
267
+ value = _first_match(pattern, text)
268
+ if value:
269
+ fields[field] = value
270
+ for field, pattern in {
271
+ "fob_value": r"\bFOB Value\s+[A-Z]{3}\s+([0-9,.]+)",
272
+ "freight_value": r"\bFreight\s+[A-Z]{3}\s+([0-9,.]+)",
273
+ "insurance_value": r"\bInsurance\s+[A-Z]{3}\s+([0-9,.]+)",
274
+ "cif_value": r"\bCIF Value\s+[A-Z]{3}\s+([0-9,.]+)",
275
+ }.items():
276
+ value = _to_float(_first_match(pattern, text))
277
+ if value is not None:
278
+ fields[field] = value
279
+
280
+ return {key: value for key, value in fields.items() if value not in (None, "")}
281
+
282
+
283
  def _estimate_field_confidences(extracted: dict, doc: dict) -> dict[str, float]:
284
  raw_text = doc.get("raw_text") or ""
285
  candidates = doc.get("ocr_candidates") or {}
 
341
  continue
342
 
343
  # ── Initialize LLM once ─────────────────────────────────────────────
344
+ if settings.DIGITAL_PDF_SKIP_LLM and doc.get("document_mode") == "digital_pdf_text":
345
+ extracted = _extract_digital_text_fields(doc)
346
+ if extracted:
347
+ candidates = dict(doc.get("ocr_candidates") or {})
348
+ field_confidences = _estimate_field_confidences(extracted, doc)
349
+ candidates["digital_text_parser"] = {
350
+ "fields": extracted,
351
+ "confidence": round(sum(field_confidences.values()) / len(field_confidences), 4),
352
+ "field_confidences": field_confidences,
353
+ }
354
+ updated_docs.append({
355
+ **doc,
356
+ "extracted_data": extracted,
357
+ "ocr_method": "digital_text_parser",
358
+ "ocr_candidates": candidates,
359
+ "field_confidences": field_confidences,
360
+ })
361
+ combined_data.update(extracted)
362
+ log.info(
363
+ "Digital PDF text parser used",
364
+ batch_id=state["batch_id"],
365
+ doc_id=doc.get("doc_id"),
366
+ field_count=len(extracted),
367
+ )
368
+ continue
369
+
370
  if llm is None:
371
  if settings.DETERMINISTIC_E2E:
372
  if DeterministicLLM is None:
 
528
 
529
  # ── Invoke LLM ──────────────────────────────────────────────────
530
  if use_manual_json:
531
+ response = await asyncio.wait_for(
532
+ llm.ainvoke(messages),
533
+ timeout=settings.LLM_EXTRACTION_TIMEOUT_SECONDS,
534
+ )
535
  text_response = response.content if hasattr(response, "content") else str(response)
536
  raw_extracted = _parse_json_from_text(text_response)
537
  # Coerce through Pydantic for type safety
 
541
  except Exception:
542
  extracted = {k: v for k, v in raw_extracted.items() if v is not None}
543
  else:
544
+ result = await asyncio.wait_for(
545
+ structured_llm.ainvoke(messages),
546
+ timeout=settings.LLM_EXTRACTION_TIMEOUT_SECONDS,
547
+ )
548
  raw_result = result.model_dump(exclude_none=True) if hasattr(result, "model_dump") else result
549
  if asyncio.iscoroutine(raw_result):
550
  raw_result = await raw_result
src/ai/nodes/risk.py CHANGED
@@ -53,6 +53,12 @@ def _compute_doc_quality(documents: list[dict]) -> float:
53
  return sum(scores) / len(scores) if scores else 0.0
54
 
55
 
 
 
 
 
 
 
56
  def _crs_to_grade(score: float) -> str:
57
  if score >= 90:
58
  return "A"
@@ -97,13 +103,14 @@ async def risk_assessment_node(state: ExtractionGraphState) -> dict:
97
  combined_data = state.get("combined_data", {})
98
  validation_results = state.get("validation_results", [])
99
  documents = state.get("documents", [])
 
100
 
101
  # ── Pillar scores ──────────────────────────────────────────────
102
  p_quality = _compute_doc_quality(documents)
103
  p_completeness = _compute_completeness(combined_data)
104
  p_consistency = _compute_consistency(validation_results)
105
  p_historical = 0.80 # Stub — fetched from company submission history
106
- p_hs_conf = 0.85 # Stub — from HS recommender confidence
107
 
108
  # ── Weighted CRS ───────────────────────────────────────────────
109
  crs_raw = (
@@ -118,12 +125,9 @@ async def risk_assessment_node(state: ExtractionGraphState) -> dict:
118
  critical_failures = sum(1 for r in validation_results if r.get("severity") == "CRITICAL_FAIL")
119
  warnings = sum(1 for r in validation_results if r.get("severity") == "WARNING")
120
 
121
- if critical_failures:
122
- crs_score = min(crs_score, 55.0)
123
- crs_grade = _crs_to_grade(crs_score)
124
- elif warnings:
125
- crs_score = min(crs_score, 75.0)
126
- crs_grade = _crs_to_grade(crs_score)
127
 
128
  features = {
129
  "doc_quality_score": p_quality,
@@ -134,12 +138,13 @@ async def risk_assessment_node(state: ExtractionGraphState) -> dict:
134
  "cif_value_usd": float(combined_data.get("cif_value") or 0.0),
135
  "package_count": float(combined_data.get("total_packages") or 0.0),
136
  "gross_weight_kg": float(combined_data.get("gross_weight") or 0.0),
 
 
 
137
  }
138
  rejection_prob = round(rejection_predictor.predict_proba(features), 4)
139
- if critical_failures:
140
- rejection_prob = max(rejection_prob, 0.65)
141
- elif warnings:
142
- rejection_prob = max(rejection_prob, 0.30)
143
  risk_level = _probability_to_risk(rejection_prob)
144
 
145
  # PRD §13 Invariant: CRS < 70 → must NOT auto-submit
 
53
  return sum(scores) / len(scores) if scores else 0.0
54
 
55
 
56
+ def _compute_hs_confidence(combined_data: dict, field_confidences: dict) -> float:
57
+ if field_confidences.get("hs_code") is not None:
58
+ return max(0.0, min(1.0, float(field_confidences["hs_code"])))
59
+ return 0.85 if combined_data.get("hs_code") else 0.0
60
+
61
+
62
  def _crs_to_grade(score: float) -> str:
63
  if score >= 90:
64
  return "A"
 
103
  combined_data = state.get("combined_data", {})
104
  validation_results = state.get("validation_results", [])
105
  documents = state.get("documents", [])
106
+ field_confidences = state.get("field_confidences", {})
107
 
108
  # ── Pillar scores ──────────────────────────────────────────────
109
  p_quality = _compute_doc_quality(documents)
110
  p_completeness = _compute_completeness(combined_data)
111
  p_consistency = _compute_consistency(validation_results)
112
  p_historical = 0.80 # Stub — fetched from company submission history
113
+ p_hs_conf = _compute_hs_confidence(combined_data, field_confidences)
114
 
115
  # ── Weighted CRS ───────────────────────────────────────────────
116
  crs_raw = (
 
125
  critical_failures = sum(1 for r in validation_results if r.get("severity") == "CRITICAL_FAIL")
126
  warnings = sum(1 for r in validation_results if r.get("severity") == "WARNING")
127
 
128
+ validation_penalty = (critical_failures * 10.0) + (warnings * 4.0)
129
+ crs_score = round(max(0.0, crs_score - validation_penalty), 2)
130
+ crs_grade = _crs_to_grade(crs_score)
 
 
 
131
 
132
  features = {
133
  "doc_quality_score": p_quality,
 
138
  "cif_value_usd": float(combined_data.get("cif_value") or 0.0),
139
  "package_count": float(combined_data.get("total_packages") or 0.0),
140
  "gross_weight_kg": float(combined_data.get("gross_weight") or 0.0),
141
+ "critical_validation_failures": critical_failures,
142
+ "warning_validation_failures": warnings,
143
+ "validation_penalty": validation_penalty,
144
  }
145
  rejection_prob = round(rejection_predictor.predict_proba(features), 4)
146
+ validation_risk = (critical_failures * 0.18) + (warnings * 0.06)
147
+ rejection_prob = round(max(rejection_prob, min(0.95, validation_risk)), 4)
 
 
148
  risk_level = _probability_to_risk(rejection_prob)
149
 
150
  # PRD §13 Invariant: CRS < 70 → must NOT auto-submit
src/config.py CHANGED
@@ -105,9 +105,11 @@ class Settings(BaseSettings):
105
  CHROMADB_PORT: int = 8000
106
 
107
  # ── AI / LLM ─────────────────────────────────────────────────────────────
108
- USE_LOCAL_LLM: bool = False
109
- LOCAL_LLM_MODEL: str = "qwen2.5:7b"
110
- OLLAMA_BASE_URL: str = "http://host.docker.internal:11434/v1"
 
 
111
  GEMINI_API_KEY: SecretStr = Field(..., description="Google Gemini API key")
112
  GEMINI_MODEL_PRIMARY: str = "gemini-3.5-flash"
113
  GEMINI_MODEL_FALLBACK: str = "gemini-3.1-flash-lite"
 
105
  CHROMADB_PORT: int = 8000
106
 
107
  # ── AI / LLM ─────────────────────────────────────────────────────────────
108
+ USE_LOCAL_LLM: bool = False
109
+ LOCAL_LLM_MODEL: str = "qwen2.5:7b"
110
+ DIGITAL_PDF_SKIP_LLM: bool = True
111
+ LLM_EXTRACTION_TIMEOUT_SECONDS: int = 25
112
+ OLLAMA_BASE_URL: str = "http://host.docker.internal:11434/v1"
113
  GEMINI_API_KEY: SecretStr = Field(..., description="Google Gemini API key")
114
  GEMINI_MODEL_PRIMARY: str = "gemini-3.5-flash"
115
  GEMINI_MODEL_FALLBACK: str = "gemini-3.1-flash-lite"
src/services/validation_rules_svc.py CHANGED
@@ -267,9 +267,15 @@ class ValidationRulesService:
267
  value = self._first_context_value(context, field)
268
  if field in {"npwp", "nib"}:
269
  value = re.sub(r"\D", "", str(value or ""))
270
- passed = regex_match(value, rule.get("regex", ".*"))
 
 
 
 
 
 
271
  elif rule_type == "cross_document_match":
272
- passed = all(self._cross_document_values_match(context, field) for field in fields)
273
  elif rule_type == "cross_document":
274
  passed = self._evaluate_cross_document_rule(rule, context)
275
  elif rule_type == "date_sequence":
@@ -323,16 +329,24 @@ class ValidationRulesService:
323
  return value
324
  return None
325
 
326
- def _cross_document_values_match(self, context: dict[str, Any], field: str) -> bool:
 
327
  values = [
328
  self._normalize_compare_value(self._scope_value(context[scope_name], field))
329
- for scope_name in ("bl", "pl", "inv")
330
  ]
331
  present = [value for value in values if value not in (None, "")]
332
  if len(present) < 2:
333
- return False
334
  return len(set(present)) == 1
335
 
 
 
 
 
 
 
 
336
  def _normalize_compare_value(self, value: Any) -> str | None:
337
  if value is None or value is MISSING:
338
  return None
@@ -354,13 +368,14 @@ class ValidationRulesService:
354
  return diff_pct <= tolerance_pct
355
 
356
  for field in rule.get("fields") or []:
 
357
  values = [
358
  self._as_float(self._scope_value(context[scope_name], field))
359
- for scope_name in ("bl", "pl", "inv")
360
  ]
361
  present = [value for value in values if value is not None]
362
  if len(present) < 2:
363
- return False
364
  baseline = present[0]
365
  if baseline == 0:
366
  return all(value == 0 for value in present)
@@ -395,9 +410,9 @@ class ValidationRulesService:
395
  if doc_type in by_type:
396
  by_type[doc_type].update(doc.get("extracted_data") or {})
397
 
398
- bl = {**combined, **by_type["bill_of_lading"]}
399
- pl = {**combined, **by_type["packing_list"]}
400
- inv = {**combined, **by_type["invoice"]}
401
  for scoped in (bl, pl, inv):
402
  if "currency_code" not in scoped and scoped.get("currency"):
403
  scoped["currency_code"] = scoped["currency"]
@@ -439,10 +454,15 @@ class ValidationRulesService:
439
  return "CRITICAL_FAIL" if severity in {"CRITICAL", "ERROR"} else "WARNING"
440
 
441
  def _legacy_failure_severity(self, rule_id: str, severity: str | None) -> str:
442
- if rule_id in {"CV001", "CV002", "CV003", "CV006", "CV008"}:
443
  return "CRITICAL_FAIL"
444
  return self._failure_severity(severity)
445
 
 
 
 
 
 
446
  def _resolve_rules_path(self) -> Path:
447
  configured = Path(settings.VALIDATION_RULES_PATH)
448
  candidates = [
 
267
  value = self._first_context_value(context, field)
268
  if field in {"npwp", "nib"}:
269
  value = re.sub(r"\D", "", str(value or ""))
270
+ if field == "container_number":
271
+ containers = self._container_values(value)
272
+ passed = bool(containers) and all(
273
+ regex_match(container, rule.get("regex", ".*")) for container in containers
274
+ )
275
+ else:
276
+ passed = regex_match(value, rule.get("regex", ".*"))
277
  elif rule_type == "cross_document_match":
278
+ passed = all(self._cross_document_values_match(context, field, rule_id) for field in fields)
279
  elif rule_type == "cross_document":
280
  passed = self._evaluate_cross_document_rule(rule, context)
281
  elif rule_type == "date_sequence":
 
329
  return value
330
  return None
331
 
332
+ def _cross_document_values_match(self, context: dict[str, Any], field: str, rule_id: str | None = None) -> bool:
333
+ scope_names = self._cross_document_scopes(rule_id, field)
334
  values = [
335
  self._normalize_compare_value(self._scope_value(context[scope_name], field))
336
+ for scope_name in scope_names
337
  ]
338
  present = [value for value in values if value not in (None, "")]
339
  if len(present) < 2:
340
+ return True
341
  return len(set(present)) == 1
342
 
343
+ def _cross_document_scopes(self, rule_id: str | None, field: str | None) -> tuple[str, ...]:
344
+ if rule_id == "CV008" or field == "jumlahKemasan":
345
+ return ("bl", "pl")
346
+ if rule_id == "CV007" or field == "beratKotor":
347
+ return ("bl", "pl")
348
+ return ("bl", "pl", "inv")
349
+
350
  def _normalize_compare_value(self, value: Any) -> str | None:
351
  if value is None or value is MISSING:
352
  return None
 
368
  return diff_pct <= tolerance_pct
369
 
370
  for field in rule.get("fields") or []:
371
+ scope_names = self._cross_document_scopes(rule_id, field)
372
  values = [
373
  self._as_float(self._scope_value(context[scope_name], field))
374
+ for scope_name in scope_names
375
  ]
376
  present = [value for value in values if value is not None]
377
  if len(present) < 2:
378
+ return True
379
  baseline = present[0]
380
  if baseline == 0:
381
  return all(value == 0 for value in present)
 
410
  if doc_type in by_type:
411
  by_type[doc_type].update(doc.get("extracted_data") or {})
412
 
413
+ bl = by_type["bill_of_lading"]
414
+ pl = by_type["packing_list"]
415
+ inv = by_type["invoice"]
416
  for scoped in (bl, pl, inv):
417
  if "currency_code" not in scoped and scoped.get("currency"):
418
  scoped["currency_code"] = scoped["currency"]
 
454
  return "CRITICAL_FAIL" if severity in {"CRITICAL", "ERROR"} else "WARNING"
455
 
456
  def _legacy_failure_severity(self, rule_id: str, severity: str | None) -> str:
457
+ if rule_id in {"CV001", "CV002", "CV003", "CV006"}:
458
  return "CRITICAL_FAIL"
459
  return self._failure_severity(severity)
460
 
461
+ def _container_values(self, value: Any) -> list[str]:
462
+ if value in (None, "", MISSING):
463
+ return []
464
+ return re.findall(r"[A-Z]{4}\d{7}", str(value).upper())
465
+
466
  def _resolve_rules_path(self) -> Path:
467
  configured = Path(settings.VALIDATION_RULES_PATH)
468
  candidates = [