TradeFlowAI / src /ai /nodes /extract.py
github-actions[bot]
Automated deployment from GitHub Actions: c077d743be852092402bf29515950ab5874e2735
1cf88ff
Raw
History Blame Contribute Delete
26.2 kB
"""
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"],
}