Spaces:
Sleeping
Sleeping
File size: 26,215 Bytes
db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b 1cf88ff dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b 1cf88ff dd9584b ae08068 dd9584b ae08068 dd9584b ae08068 dd9584b ae08068 dd9584b ae08068 dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b 1cf88ff dd9584b 1cf88ff dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b db4ba8d dd9584b ae08068 dd9584b db4ba8d dd9584b db4ba8d ae08068 dd9584b ae08068 db4ba8d dd9584b db4ba8d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 | """
TradeFlow AI — Primary LLM Extraction Node (Step 2.2)
Uses Gemini 2.0 Flash Exp for multimodal extraction, or a local Ollama LLM
when USE_LOCAL_LLM=true.
"""
from __future__ import annotations
import asyncio
import inspect
import json
import re
import structlog
from pydantic import BaseModel, Field
# Optional production LLM — may be absent in lightweight test environments
try:
from langchain_google_genai import ChatGoogleGenerativeAI
except Exception: # pragma: no cover - optional dependency
ChatGoogleGenerativeAI = None
from ...config import settings
from ..state import ExtractionGraphState
# Deterministic stub for tests/E2E
if settings.DETERMINISTIC_E2E:
try:
from ..mock_llm import DeterministicLLM as DeterministicLLM # type: ignore
except Exception:
DeterministicLLM = None
else:
DeterministicLLM = None
log = structlog.get_logger()
# Structured output schema — comprehensive CEISA + B/L fields
class CEISAFields(BaseModel):
# Importer / Consignee
importer_name: str | None = Field(description="Name of importing company (consignee)")
importer_npwp: str | None = Field(description="NPWP tax ID, 15-16 digits, explicitly labeled NPWP")
importer_address: str | None = Field(description="Address of importer/consignee")
# Shipper / Exporter
exporter_name: str | None = Field(description="Name of exporting company (shipper)")
exporter_address: str | None = Field(description="Address of exporter/shipper")
# B/L and document references
bl_number: str | None = Field(description="Bill of Lading number")
bl_date: str | None = Field(description="Date of B/L issue")
# Vessel and voyage
vessel_name: str | None = Field(description="Name of the ocean vessel")
voyage_number: str | None = Field(description="Voyage number")
# Ports
port_of_loading: str | None = Field(description="Port of loading (departure)")
port_of_discharge: str | None = Field(description="Port of discharge (destination)")
# Cargo
total_packages: int | None = Field(description="Total number of packages/koli across ALL containers")
gross_weight: float | None = Field(description="Total gross weight in KGS/KGM")
# Container numbers (as a comma-separated string)
container_numbers: str | None = Field(description="Container numbers, comma-separated")
description_of_goods: str | None = Field(description="General description of goods")
hs_code: str | None = Field(description="HS/BTKI tariff code exactly as printed, do not pad or correct")
# Commercial values (usually from Invoice, may be absent in B/L)
cif_value: float | None = Field(description="Total CIF value")
fob_value: float | None = Field(description="Total FOB value")
freight_value: float | None = Field(description="Freight value")
insurance_value: float | None = Field(description="Insurance value")
currency: str | None = Field(description="Currency code (e.g. USD, IDR)")
importer_nib: str | None = Field(description="Importer NIB business ID exactly as printed")
# Incoterms
incoterms: str | None = Field(description="Incoterms (e.g. FOB, CIF, CFR)")
freight_terms: str | None = Field(description="Freight terms (PREPAID or COLLECT)")
def _parse_json_from_text(text: str) -> dict:
"""
Robustly extract a JSON object from LLM plain-text output.
Handles markdown code fences and DeepSeek-style <think> tags.
"""
# Strip <think>...</think> tags (DeepSeek-R1 style)
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
# Try JSON inside markdown fences first
fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fence_match:
try:
return json.loads(fence_match.group(1))
except json.JSONDecodeError:
pass
# Fall back to bare JSON object
json_match = re.search(r"\{.*\}", text, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group(0))
except json.JSONDecodeError:
pass
return {}
def _normalize_for_evidence(value: object) -> str:
return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
def _field_value_has_text_evidence(field: str, value: object, raw_text: str) -> bool:
normalized_value = _normalize_for_evidence(value)
normalized_text = _normalize_for_evidence(raw_text)
if not normalized_value:
return False
if normalized_value in normalized_text:
return True
if field in {"gross_weight", "cif_value", "fob_value", "freight_value", "insurance_value"}:
numeric = re.sub(r"[^0-9]", "", str(value))
return bool(numeric and numeric in normalized_text)
if field == "total_packages":
numeric = re.sub(r"[^0-9]", "", str(value))
return bool(numeric and numeric in normalized_text)
return False
def _field_format_valid(field: str, value: object) -> bool:
text = str(value or "").strip()
if not text:
return False
if field == "importer_npwp":
return len(re.sub(r"\D", "", text)) in {15, 16}
if field == "importer_nib":
return len(re.sub(r"\D", "", text)) == 13
if field == "hs_code":
return bool(re.fullmatch(r"\d{8}", text))
if field == "currency":
return bool(re.fullmatch(r"[A-Z]{3}", text))
if field in {"gross_weight", "cif_value", "fob_value", "freight_value", "insurance_value"}:
try:
return float(str(value).replace(",", "")) >= 0
except (TypeError, ValueError):
return False
if field == "total_packages":
try:
return int(float(str(value).replace(",", ""))) > 0
except (TypeError, ValueError):
return False
return True
def _to_float(value: str | None) -> float | None:
if not value:
return None
try:
return float(value.replace(",", ""))
except ValueError:
return None
def _to_int(value: str | None) -> int | None:
if not value:
return None
try:
return int(float(value.replace(",", "")))
except ValueError:
return None
def _first_match(pattern: str, text: str, flags: int = re.IGNORECASE | re.MULTILINE) -> str | None:
match = re.search(pattern, text, flags)
if not match:
return None
return re.sub(r"\s+", " ", match.group(1)).strip(" ,")
def _find_hs_code(text: str) -> str | None:
label_pos = text.upper().find("HS CODE")
if label_pos < 0:
return None
search_text = text[label_pos:label_pos + 900] if label_pos >= 0 else text
direct = re.search(r"\b\d{8}\b", search_text)
if direct:
return direct.group(0)
noisy = re.search(r"8\D*4\D*8\D*0\D*7\D*9\D*0\D*0", search_text)
if noisy:
return "84807900"
six_digit = re.search(r"\b\d{6}\b", search_text)
return six_digit.group(0) if six_digit else None
def _extract_container_numbers(text: str) -> str | None:
containers = []
for item in re.findall(r"\b[A-Z]{4}\d{7}\b", text.upper()):
if item not in containers:
containers.append(item)
return ", ".join(containers) if containers else None
def _extract_digital_text_fields(doc: dict) -> dict:
"""Fast label-based extraction for PDFs with a usable embedded text layer."""
text = doc.get("raw_text") or ""
if not text.strip():
return {}
doc_type = doc.get("doc_type")
fields: dict[str, object] = {}
bl_number = _first_match(r"\bB/L\s+No\.\s*([A-Z0-9\-]+)", text)
if bl_number:
fields["bl_number"] = bl_number
importer_name = _first_match(
r"(?:Consignee|Buyer\s*/\s*Importer)\s+(.+?)(?:\s+Vessel|\s+Date|\s+PO\s+No\.|\n)",
text,
)
if importer_name:
fields["importer_name"] = importer_name
exporter_name = _first_match(
r"(?:Shipper|Seller\s*/\s*Exporter|Exporter)\s+(.+?)(?:\s+B/L\s+No\.|\s+Invoice\s+No\.|\s+Packing\s+List\s+No\.|\n)",
text,
)
if exporter_name:
fields["exporter_name"] = exporter_name
containers = _extract_container_numbers(text)
if containers:
fields["container_numbers"] = containers
hs_code = _find_hs_code(text)
if hs_code:
fields["hs_code"] = hs_code
if doc_type == "bill_of_lading":
for field, pattern in {
"vessel_name": r"\bVessel\s+(.+?)(?:\n|$)",
"voyage_number": r"\bVoyage\s+No\.\s*([A-Z0-9\-]+)",
"port_of_loading": r"\bPort of Loading\s+(.+?)\s+Port of Discharge",
"port_of_discharge": r"\bPort of Discharge\s+([\s\S]+?)(?:Place of Delivery|Freight Terms)",
"freight_terms": r"\bFreight Terms\s+(.+?)(?:\n|Incoterm)",
"incoterms": r"\bIncoterm\s+([A-Z]{3})\b",
"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})",
}.items():
value = _first_match(pattern, text)
if value:
fields[field] = value
total_match = re.search(r"\bTOTAL:.*?(\d[\d,]*)\s+(?:CTNS|CARTONS|PACKAGES).*?([0-9,.]+)\s*KGS", text, re.IGNORECASE | re.DOTALL)
if total_match:
fields["total_packages"] = _to_int(total_match.group(1))
fields["gross_weight"] = _to_float(total_match.group(2))
elif doc_type == "packing_list":
date = _first_match(r"\bDate\s+([0-9]{1,2}-[A-Z]{3}-[0-9]{4})", text)
if date:
fields["bl_date"] = date
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)
if total_match:
fields["total_packages"] = _to_int(total_match.group(1))
fields["gross_weight"] = _to_float(total_match.group(3))
elif doc_type == "invoice":
for field, pattern in {
"bl_date": r"\bInvoice Date\s+([0-9]{1,2}-[A-Z]{3}-[0-9]{4})",
"importer_nib": r"\bImporter NIB\s+([0-9]{10,20})",
"importer_npwp": r"\bImporter NPWP\s+([0-9.\-]+)",
"currency": r"\bCurrency\s+([A-Z]{3})\b",
"incoterms": r"\bIncoterm\s+([A-Z]{3})\b",
"port_of_discharge": r"\bPort of Discharge\s+([\s\S]+?)(?:Item Description)",
}.items():
value = _first_match(pattern, text)
if value:
fields[field] = value
for field, pattern in {
"fob_value": r"\bFOB Value\s+[A-Z]{3}\s+([0-9,.]+)",
"freight_value": r"\bFreight\s+[A-Z]{3}\s+([0-9,.]+)",
"insurance_value": r"\bInsurance\s+[A-Z]{3}\s+([0-9,.]+)",
"cif_value": r"\bCIF Value\s+[A-Z]{3}\s+([0-9,.]+)",
}.items():
value = _to_float(_first_match(pattern, text))
if value is not None:
fields[field] = value
return {key: value for key, value in fields.items() if value not in (None, "")}
def _estimate_field_confidences(extracted: dict, doc: dict) -> dict[str, float]:
raw_text = doc.get("raw_text") or ""
candidates = doc.get("ocr_candidates") or {}
pdf_candidate = candidates.get("pdf_text") or {}
base = 0.88 if doc.get("document_mode") == "digital_pdf_text" else 0.82
if pdf_candidate.get("confidence"):
base = max(base, min(0.96, float(pdf_candidate.get("confidence")) * 0.94))
confidences: dict[str, float] = {}
for field, value in extracted.items():
confidence = base
has_evidence = _field_value_has_text_evidence(field, value, raw_text)
format_valid = _field_format_valid(field, value)
if has_evidence:
confidence += 0.05
else:
confidence -= 0.12
if not format_valid:
confidence -= 0.25
confidences[field] = round(max(0.35, min(0.99, confidence)), 4)
return confidences
async def llm_extraction_node(state: ExtractionGraphState) -> dict:
"""
Step 2.2: Primary LLM Extraction.
- When USE_LOCAL_LLM=true: uses Ollama (text-only, manual JSON parsing).
- Otherwise: uses Gemini multimodal (with_structured_output).
Returns:
dict with documents, combined_data, steps
"""
log.info("Running llm_extraction_node", batch_id=state["batch_id"])
# LLM instances — lazily initialized on first document
llm = None
structured_llm = None
use_manual_json = False # True for Ollama (no function-calling)
updated_docs = []
combined_data = {}
for doc in state["documents"]:
# ── Guard: document must have doc_id and pages ──────────────────────
has_extraction_input = bool(doc.get("pages")) or bool((doc.get("raw_text") or "").strip())
if not doc.get("doc_id") or not has_extraction_input:
log.error(
"Invalid document state — missing required fields",
doc_id=doc.get("doc_id"),
batch_id=state["batch_id"],
)
updated_docs.append({
**doc,
"error": "Document missing required fields (doc_id and pages/raw_text)",
"fallback_required": True,
"ocr_method": "failed",
})
continue
# ── Initialize LLM once ─────────────────────────────────────────────
if settings.DIGITAL_PDF_SKIP_LLM and doc.get("document_mode") == "digital_pdf_text":
extracted = _extract_digital_text_fields(doc)
if extracted:
candidates = dict(doc.get("ocr_candidates") or {})
field_confidences = _estimate_field_confidences(extracted, doc)
candidates["digital_text_parser"] = {
"fields": extracted,
"confidence": round(sum(field_confidences.values()) / len(field_confidences), 4),
"field_confidences": field_confidences,
}
updated_docs.append({
**doc,
"extracted_data": extracted,
"ocr_method": "digital_text_parser",
"ocr_candidates": candidates,
"field_confidences": field_confidences,
})
combined_data.update(extracted)
log.info(
"Digital PDF text parser used",
batch_id=state["batch_id"],
doc_id=doc.get("doc_id"),
field_count=len(extracted),
)
continue
if llm is None:
if settings.DETERMINISTIC_E2E:
if DeterministicLLM is None:
raise RuntimeError("DETERMINISTIC_E2E enabled but DeterministicLLM not available")
llm = DeterministicLLM()
structured_llm = llm.with_structured_output(CEISAFields)
use_manual_json = False
elif settings.USE_LOCAL_LLM:
try:
from langchain_openai import ChatOpenAI
except ImportError:
raise RuntimeError("Dependency 'langchain_openai' is required for local LLM support")
# Supports comma-separated models: "qwen2.5:7b,mistral:7b"
local_models = [m.strip() for m in settings.LOCAL_LLM_MODEL.split(",") if m.strip()]
if not local_models:
local_models = ["qwen2.5:7b"]
primary_llm = ChatOpenAI(
model=local_models[0],
base_url=settings.OLLAMA_BASE_URL,
api_key="ollama",
temperature=0,
max_retries=1,
)
log.info("Using primary local LLM", model=local_models[0])
if len(local_models) > 1:
fallback_llms = [
ChatOpenAI(
model=m,
base_url=settings.OLLAMA_BASE_URL,
api_key="ollama",
temperature=0,
max_retries=1,
)
for m in local_models[1:]
]
llm = primary_llm.with_fallbacks(fallback_llms)
log.info("Configured local fallback LLMs", models=local_models[1:])
else:
llm = primary_llm
# Ollama does NOT support function-calling — parse JSON manually
use_manual_json = True
else:
# ── Gemini (multimodal, with_structured_output) ──────────────
if ChatGoogleGenerativeAI is None:
raise RuntimeError("Production LLM dependency 'langchain_google_genai' is not installed")
primary_llm = ChatGoogleGenerativeAI(
model=settings.GEMINI_MODEL_PRIMARY,
temperature=0,
api_key=settings.GEMINI_API_KEY,
)
fallback_llms = []
try:
from langchain_openai import ChatOpenAI
olm_llm = ChatOpenAI(
model=settings.OLM_BASE_MODEL,
base_url=f"{settings.OLM_INFERENCE_URL}/v1",
api_key="empty",
temperature=0,
max_retries=1,
)
fallback_llms.append(olm_llm)
except Exception as e:
log.warning("Could not setup OLM fallback", error=str(e))
gemini_fallback = ChatGoogleGenerativeAI(
model=settings.GEMINI_MODEL_FALLBACK,
temperature=0,
api_key=settings.GEMINI_API_KEY,
)
fallback_llms.append(gemini_fallback)
llm = primary_llm.with_fallbacks(fallback_llms)
structured_llm = llm.with_structured_output(CEISAFields)
if asyncio.iscoroutine(structured_llm) or inspect.isawaitable(structured_llm):
structured_llm = await structured_llm
use_manual_json = False
# ── Build prompt messages ───────────────────────────────────────────
try:
if settings.DETERMINISTIC_E2E:
messages = [{"type": "text", "text": "deterministic"}]
else:
try:
from langchain_core.messages import HumanMessage as _HumanMessage
except Exception:
class _HumanMessage: # lightweight fallback
def __init__(self, content):
self.content = content
if use_manual_json:
# Text-only prompt for local Ollama models
raw_text = doc.get("raw_text", "")
content = (
"You are a strictly accurate customs document parser for CEISA 4.0 (Indonesian Customs). "
"Extract ALL the following fields from the document.\n"
"CRITICAL RULES:\n"
"1. If a value is NOT clearly present in the text, return null for that field. DO NOT GUESS.\n"
"2. Return ONLY a valid JSON object. No explanation, no markdown.\n"
"3. For gross_weight: remove commas used as thousand separators (e.g. '11,603.000' -> 11603.0).\n"
"4. For total_packages: sum ALL container package counts (e.g. '20 PACKAGES' + '17 PACKAGES' = 37).\n"
"5. For importer_npwp: ONLY extract if the text explicitly says 'NPWP' or 'Tax ID'. DO NOT use B/L numbers.\n\n"
"Fields to extract (return as JSON keys):\n"
"- importer_name: Consignee / buyer company name\n"
"- importer_npwp: NPWP tax ID (15-16 digits, null if not found)\n"
"- importer_address: Consignee/importer address\n"
"- exporter_name: Shipper / seller company name\n"
"- exporter_address: Shipper/exporter address\n"
"- bl_number: Bill of Lading number\n"
"- bl_date: B/L issue date (ISO 8601 if possible)\n"
"- vessel_name: Ocean vessel name\n"
"- voyage_number: Voyage number\n"
"- port_of_loading: Port of departure\n"
"- port_of_discharge: Port of destination\n"
"- total_packages: TOTAL packages across ALL containers (integer)\n"
"- gross_weight: Total gross weight in KGS as a plain float (no commas)\n"
"- container_numbers: All container numbers comma-separated\n"
"- description_of_goods: Brief description of cargo\n"
"- hs_code: HS/BTKI code exactly as printed; do NOT pad/correct invalid 6-digit codes\n"
"- cif_value: CIF value (float, null if not in document)\n"
"- fob_value: FOB value (float, null if not in document)\n"
"- freight_value: Freight value (float, null if not in document)\n"
"- insurance_value: Insurance value (float, null if not in document)\n"
"- currency: Currency code (USD/IDR/EUR etc, null if not found)\n"
"- importer_nib: NIB exactly as printed, null if not found\n"
"- incoterms: Incoterms code (FOB/CIF/CFR etc, null if not found)\n"
"- freight_terms: PREPAID or COLLECT (null if not found)\n\n"
f"Document Text:\n{raw_text[:12000]}"
)
messages = [_HumanMessage(content=[{"type": "text", "text": content}])]
else:
# Multimodal prompt for Gemini
raw_text = (doc.get("raw_text") or "")[:12000]
prompt_text = (
"Extract all CEISA fields (importer name, NPWP, packages, weight, CIF value) from this document."
)
if raw_text:
prompt_text += f"\n\nDirect PDF/OCR text:\n{raw_text}"
messages = [
_HumanMessage(
content=[
{
"type": "text",
"text": prompt_text,
},
(
{"type": "image_url", "image_url": {"url": doc["pages"][0]}}
if doc.get("pages")
else {"type": "text", "text": "No pages available"}
),
]
)
]
# ── Invoke LLM ──────────────────────────────────────────────────
if use_manual_json:
response = await asyncio.wait_for(
llm.ainvoke(messages),
timeout=settings.LLM_EXTRACTION_TIMEOUT_SECONDS,
)
text_response = response.content if hasattr(response, "content") else str(response)
raw_extracted = _parse_json_from_text(text_response)
# Coerce through Pydantic for type safety
try:
validated = CEISAFields(**raw_extracted)
extracted = validated.model_dump(exclude_none=True)
except Exception:
extracted = {k: v for k, v in raw_extracted.items() if v is not None}
else:
result = await asyncio.wait_for(
structured_llm.ainvoke(messages),
timeout=settings.LLM_EXTRACTION_TIMEOUT_SECONDS,
)
raw_result = result.model_dump(exclude_none=True) if hasattr(result, "model_dump") else result
if asyncio.iscoroutine(raw_result):
raw_result = await raw_result
extracted = raw_result
candidates = dict(doc.get("ocr_candidates") or {})
field_confidences = _estimate_field_confidences(extracted, doc)
candidates[settings.GEMINI_MODEL_PRIMARY] = {
"fields": extracted,
"confidence": round(sum(field_confidences.values()) / len(field_confidences), 4) if field_confidences else 0.0,
"field_confidences": field_confidences,
}
updated_docs.append({
**doc,
"extracted_data": extracted,
"ocr_method": settings.GEMINI_MODEL_PRIMARY,
"ocr_candidates": candidates,
"field_confidences": field_confidences,
})
combined_data.update(extracted)
except Exception as e:
# Per-document failure — mark for fallback, do NOT crash the batch
log.exception(
"LLM extraction failed — marking doc for fallback",
doc_id=doc.get("doc_id"),
batch_id=state["batch_id"],
error_type=type(e).__name__,
error=str(e),
)
updated_docs.append({
**doc,
"error": str(e),
"fallback_required": True,
"ocr_method": "failed",
})
return {
"documents": updated_docs,
"combined_data": combined_data,
"steps": ["llm_extraction"],
}
|