Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
1cf88ff
1
Parent(s): dd9584b
Automated deployment from GitHub Actions: c077d743be852092402bf29515950ab5874e2735
Browse files- src/ai/graph.py +2 -0
- src/ai/nodes/extract.py +169 -2
- src/ai/nodes/risk.py +16 -11
- src/config.py +5 -3
- src/services/validation_rules_svc.py +31 -11
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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
| 122 |
-
|
| 123 |
-
|
| 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 |
-
|
| 140 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 330 |
]
|
| 331 |
present = [value for value in values if value not in (None, "")]
|
| 332 |
if len(present) < 2:
|
| 333 |
-
return
|
| 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
|
| 360 |
]
|
| 361 |
present = [value for value in values if value is not None]
|
| 362 |
if len(present) < 2:
|
| 363 |
-
return
|
| 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 =
|
| 399 |
-
pl =
|
| 400 |
-
inv =
|
| 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"
|
| 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 = [
|