Spaces:
Sleeping
Sleeping
github-actions[bot] commited on
Commit ·
dd9584b
1
Parent(s): 011c72c
Automated deployment from GitHub Actions: d0b87cbe4fdaf86c5c12e61d54b1acd8b234b76c
Browse files- pyproject.toml +1 -0
- src/ai/graph.py +1 -8
- src/ai/nodes/extract.py +315 -113
- src/ai/nodes/fallback_ocr.py +4 -1
- src/ai/nodes/risk.py +17 -0
- src/ai/nodes/validate.py +11 -1
- src/ai/state.py +4 -0
- src/config.py +26 -18
- src/dependencies.py +1 -8
- src/main.py +1 -33
- src/routers/batches.py +30 -90
- src/scripts/backfill_batch_risk.py +106 -0
- src/scripts/recompute_field_confidences.py +109 -0
- src/scripts/revalidate_batch.py +129 -0
- src/services/ingest_svc.py +2 -2
- src/services/ocr_engine_svc.py +192 -72
- src/services/validation_rules_svc.py +168 -8
- src/tasks/ocr_tasks.py +50 -12
pyproject.toml
CHANGED
|
@@ -36,6 +36,7 @@ dependencies = [
|
|
| 36 |
# AI / LLM
|
| 37 |
"google-generativeai>=0.8.5",
|
| 38 |
"langchain-google-genai>=2.1.0",
|
|
|
|
| 39 |
"langgraph>=0.3.18",
|
| 40 |
"langgraph-checkpoint-redis>=0.0.6",
|
| 41 |
"langsmith>=0.3.11",
|
|
|
|
| 36 |
# AI / LLM
|
| 37 |
"google-generativeai>=0.8.5",
|
| 38 |
"langchain-google-genai>=2.1.0",
|
| 39 |
+
"langchain-openai>=0.2.14",
|
| 40 |
"langgraph>=0.3.18",
|
| 41 |
"langgraph-checkpoint-redis>=0.0.6",
|
| 42 |
"langsmith>=0.3.11",
|
src/ai/graph.py
CHANGED
|
@@ -32,13 +32,6 @@ log = structlog.get_logger()
|
|
| 32 |
def _needs_fallback(state: ExtractionGraphState) -> str:
|
| 33 |
"""Route to OCR ensemble fallback when quality, confidence, or data is weak."""
|
| 34 |
if settings.CLOUD_LLM_ONLY:
|
| 35 |
-
for doc in state.get("documents", []):
|
| 36 |
-
if doc.get("error") or not doc.get("extracted_data"):
|
| 37 |
-
return "fallback"
|
| 38 |
-
if len(doc.get("ocr_candidates") or {}) > 1:
|
| 39 |
-
return "fallback"
|
| 40 |
-
if doc.get("ocr_conflicts"):
|
| 41 |
-
return "fallback"
|
| 42 |
log.info("CLOUD_LLM_ONLY is active — bypassing heavy OCR ensemble fallback")
|
| 43 |
return "validate"
|
| 44 |
|
|
@@ -52,7 +45,7 @@ def _needs_fallback(state: ExtractionGraphState) -> str:
|
|
| 52 |
return "fallback"
|
| 53 |
if doc.get("ocr_conflicts"):
|
| 54 |
return "fallback"
|
| 55 |
-
if len(doc.get("ocr_candidates") or {}) > 1:
|
| 56 |
return "fallback"
|
| 57 |
return "validate"
|
| 58 |
|
|
|
|
| 32 |
def _needs_fallback(state: ExtractionGraphState) -> str:
|
| 33 |
"""Route to OCR ensemble fallback when quality, confidence, or data is weak."""
|
| 34 |
if settings.CLOUD_LLM_ONLY:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
log.info("CLOUD_LLM_ONLY is active — bypassing heavy OCR ensemble fallback")
|
| 36 |
return "validate"
|
| 37 |
|
|
|
|
| 45 |
return "fallback"
|
| 46 |
if doc.get("ocr_conflicts"):
|
| 47 |
return "fallback"
|
| 48 |
+
if len(doc.get("ocr_candidates") or {}) > 1 and doc.get("document_mode") != "digital_pdf_text":
|
| 49 |
return "fallback"
|
| 50 |
return "validate"
|
| 51 |
|
src/ai/nodes/extract.py
CHANGED
|
@@ -1,11 +1,16 @@
|
|
| 1 |
"""
|
| 2 |
TradeFlow AI — Primary LLM Extraction Node (Step 2.2)
|
| 3 |
|
| 4 |
-
Uses Gemini 2.0 Flash Exp for multimodal extraction
|
|
|
|
| 5 |
"""
|
| 6 |
|
|
|
|
|
|
|
| 7 |
import asyncio
|
| 8 |
import inspect
|
|
|
|
|
|
|
| 9 |
|
| 10 |
import structlog
|
| 11 |
from pydantic import BaseModel, Field
|
|
@@ -30,147 +35,360 @@ else:
|
|
| 30 |
|
| 31 |
log = structlog.get_logger()
|
| 32 |
|
| 33 |
-
|
|
|
|
| 34 |
class CEISAFields(BaseModel):
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
cif_value: float | None = Field(description="Total CIF value")
|
|
|
|
|
|
|
|
|
|
| 40 |
currency: str | None = Field(description="Currency code (e.g. USD, IDR)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
async def llm_extraction_node(state: ExtractionGraphState) -> dict:
|
| 43 |
"""
|
| 44 |
-
Step 2.2: Primary LLM Extraction
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
|
| 49 |
Returns:
|
| 50 |
-
dict with
|
| 51 |
-
- documents: Updated docs with extracted_data or error flag
|
| 52 |
-
- combined_data: Merged field values across docs
|
| 53 |
-
- steps: Execution trace
|
| 54 |
-
|
| 55 |
-
Raises:
|
| 56 |
-
Specific exceptions (GoogleAPIError, ValueError) — does NOT catch all exceptions
|
| 57 |
"""
|
| 58 |
log.info("Running llm_extraction_node", batch_id=state["batch_id"])
|
| 59 |
|
| 60 |
-
#
|
| 61 |
-
|
| 62 |
-
# when LLM dependencies are not installed.
|
| 63 |
structured_llm = None
|
|
|
|
| 64 |
|
| 65 |
updated_docs = []
|
| 66 |
combined_data = {}
|
| 67 |
|
| 68 |
for doc in state["documents"]:
|
| 69 |
-
#
|
| 70 |
-
|
|
|
|
| 71 |
log.error(
|
| 72 |
"Invalid document state — missing required fields",
|
| 73 |
doc_id=doc.get("doc_id"),
|
| 74 |
-
batch_id=state["batch_id"]
|
| 75 |
)
|
| 76 |
updated_docs.append({
|
| 77 |
**doc,
|
| 78 |
-
"error": "Document missing required fields (doc_id
|
| 79 |
"fallback_required": True,
|
| 80 |
-
"ocr_method": "failed"
|
| 81 |
})
|
| 82 |
continue
|
| 83 |
|
| 84 |
-
# Initialize LLM
|
| 85 |
-
if
|
| 86 |
-
|
| 87 |
-
if
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
else:
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
temperature=0,
|
| 102 |
-
|
| 103 |
)
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
structured_llm = llm.with_structured_output(CEISAFields)
|
| 107 |
-
# Support both synchronous return and awaitable (coroutine/AsyncMock)
|
| 108 |
if asyncio.iscoroutine(structured_llm) or inspect.isawaitable(structured_llm):
|
| 109 |
structured_llm = await structured_llm
|
| 110 |
-
|
| 111 |
-
log.exception(
|
| 112 |
-
"Gemini setup failed - preserving OCR candidates for reconciliation",
|
| 113 |
-
doc_id=doc.get("doc_id"),
|
| 114 |
-
batch_id=state["batch_id"],
|
| 115 |
-
error_type=type(e).__name__,
|
| 116 |
-
)
|
| 117 |
-
updated_docs.append({
|
| 118 |
-
**doc,
|
| 119 |
-
"error": str(e),
|
| 120 |
-
"fallback_required": True,
|
| 121 |
-
"ocr_method": "gemini_setup_failed",
|
| 122 |
-
})
|
| 123 |
-
continue
|
| 124 |
-
# Validate document state before processing
|
| 125 |
-
if not doc.get("doc_id") or not doc.get("pages"):
|
| 126 |
-
log.error(
|
| 127 |
-
"Invalid document state — missing required fields",
|
| 128 |
-
doc_id=doc.get("doc_id"),
|
| 129 |
-
batch_id=state["batch_id"]
|
| 130 |
-
)
|
| 131 |
-
updated_docs.append({
|
| 132 |
-
**doc,
|
| 133 |
-
"error": "Document missing required fields (doc_id, pages)",
|
| 134 |
-
"fallback_required": True,
|
| 135 |
-
"ocr_method": "failed"
|
| 136 |
-
})
|
| 137 |
-
continue
|
| 138 |
|
|
|
|
| 139 |
try:
|
| 140 |
-
# Avoid importing heavy langchain Core in deterministic/test mode
|
| 141 |
if settings.DETERMINISTIC_E2E:
|
| 142 |
messages = [{"type": "text", "text": "deterministic"}]
|
| 143 |
else:
|
| 144 |
try:
|
| 145 |
from langchain_core.messages import HumanMessage as _HumanMessage
|
| 146 |
except Exception:
|
| 147 |
-
class _HumanMessage: # lightweight fallback
|
| 148 |
def __init__(self, content):
|
| 149 |
self.content = content
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
-
#
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
candidates = dict(doc.get("ocr_candidates") or {})
|
|
|
|
| 171 |
candidates[settings.GEMINI_MODEL_PRIMARY] = {
|
| 172 |
"fields": extracted,
|
| 173 |
-
"confidence": 0.
|
|
|
|
| 174 |
}
|
| 175 |
|
| 176 |
updated_docs.append({
|
|
@@ -178,44 +396,28 @@ async def llm_extraction_node(state: ExtractionGraphState) -> dict:
|
|
| 178 |
"extracted_data": extracted,
|
| 179 |
"ocr_method": settings.GEMINI_MODEL_PRIMARY,
|
| 180 |
"ocr_candidates": candidates,
|
| 181 |
-
"field_confidences":
|
| 182 |
})
|
| 183 |
-
|
| 184 |
combined_data.update(extracted)
|
| 185 |
|
| 186 |
-
except (ValueError, KeyError) as e:
|
| 187 |
-
# Expected errors — likely malformed input
|
| 188 |
-
log.exception(
|
| 189 |
-
"Gemini extraction failed — will retry with fallback",
|
| 190 |
-
doc_id=doc.get("doc_id"),
|
| 191 |
-
batch_id=state["batch_id"],
|
| 192 |
-
error_type=type(e).__name__
|
| 193 |
-
)
|
| 194 |
-
updated_docs.append({
|
| 195 |
-
**doc,
|
| 196 |
-
"error": str(e),
|
| 197 |
-
"fallback_required": True,
|
| 198 |
-
"ocr_method": "failed"
|
| 199 |
-
})
|
| 200 |
except Exception as e:
|
| 201 |
-
#
|
| 202 |
-
# so the batch can be diagnosed instead of silently degrading.
|
| 203 |
log.exception(
|
| 204 |
-
"
|
| 205 |
doc_id=doc.get("doc_id"),
|
| 206 |
batch_id=state["batch_id"],
|
| 207 |
-
error_type=type(e).__name__
|
|
|
|
| 208 |
)
|
| 209 |
updated_docs.append({
|
| 210 |
**doc,
|
| 211 |
"error": str(e),
|
| 212 |
"fallback_required": True,
|
| 213 |
-
"ocr_method": "
|
| 214 |
})
|
| 215 |
-
raise
|
| 216 |
|
| 217 |
return {
|
| 218 |
"documents": updated_docs,
|
| 219 |
"combined_data": combined_data,
|
| 220 |
-
"steps": ["llm_extraction"]
|
| 221 |
}
|
|
|
|
| 1 |
"""
|
| 2 |
TradeFlow AI — Primary LLM Extraction Node (Step 2.2)
|
| 3 |
|
| 4 |
+
Uses Gemini 2.0 Flash Exp for multimodal extraction, or a local Ollama LLM
|
| 5 |
+
when USE_LOCAL_LLM=true.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
import asyncio
|
| 11 |
import inspect
|
| 12 |
+
import json
|
| 13 |
+
import re
|
| 14 |
|
| 15 |
import structlog
|
| 16 |
from pydantic import BaseModel, Field
|
|
|
|
| 35 |
|
| 36 |
log = structlog.get_logger()
|
| 37 |
|
| 38 |
+
|
| 39 |
+
# Structured output schema — comprehensive CEISA + B/L fields
|
| 40 |
class CEISAFields(BaseModel):
|
| 41 |
+
# Importer / Consignee
|
| 42 |
+
importer_name: str | None = Field(description="Name of importing company (consignee)")
|
| 43 |
+
importer_npwp: str | None = Field(description="NPWP tax ID, 15-16 digits, explicitly labeled NPWP")
|
| 44 |
+
importer_address: str | None = Field(description="Address of importer/consignee")
|
| 45 |
+
# Shipper / Exporter
|
| 46 |
+
exporter_name: str | None = Field(description="Name of exporting company (shipper)")
|
| 47 |
+
exporter_address: str | None = Field(description="Address of exporter/shipper")
|
| 48 |
+
# B/L and document references
|
| 49 |
+
bl_number: str | None = Field(description="Bill of Lading number")
|
| 50 |
+
bl_date: str | None = Field(description="Date of B/L issue")
|
| 51 |
+
# Vessel and voyage
|
| 52 |
+
vessel_name: str | None = Field(description="Name of the ocean vessel")
|
| 53 |
+
voyage_number: str | None = Field(description="Voyage number")
|
| 54 |
+
# Ports
|
| 55 |
+
port_of_loading: str | None = Field(description="Port of loading (departure)")
|
| 56 |
+
port_of_discharge: str | None = Field(description="Port of discharge (destination)")
|
| 57 |
+
# Cargo
|
| 58 |
+
total_packages: int | None = Field(description="Total number of packages/koli across ALL containers")
|
| 59 |
+
gross_weight: float | None = Field(description="Total gross weight in KGS/KGM")
|
| 60 |
+
# Container numbers (as a comma-separated string)
|
| 61 |
+
container_numbers: str | None = Field(description="Container numbers, comma-separated")
|
| 62 |
+
description_of_goods: str | None = Field(description="General description of goods")
|
| 63 |
+
hs_code: str | None = Field(description="HS/BTKI tariff code exactly as printed, do not pad or correct")
|
| 64 |
+
# Commercial values (usually from Invoice, may be absent in B/L)
|
| 65 |
cif_value: float | None = Field(description="Total CIF value")
|
| 66 |
+
fob_value: float | None = Field(description="Total FOB value")
|
| 67 |
+
freight_value: float | None = Field(description="Freight value")
|
| 68 |
+
insurance_value: float | None = Field(description="Insurance value")
|
| 69 |
currency: str | None = Field(description="Currency code (e.g. USD, IDR)")
|
| 70 |
+
importer_nib: str | None = Field(description="Importer NIB business ID exactly as printed")
|
| 71 |
+
# Incoterms
|
| 72 |
+
incoterms: str | None = Field(description="Incoterms (e.g. FOB, CIF, CFR)")
|
| 73 |
+
freight_terms: str | None = Field(description="Freight terms (PREPAID or COLLECT)")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _parse_json_from_text(text: str) -> dict:
|
| 77 |
+
"""
|
| 78 |
+
Robustly extract a JSON object from LLM plain-text output.
|
| 79 |
+
Handles markdown code fences and DeepSeek-style <think> tags.
|
| 80 |
+
"""
|
| 81 |
+
# Strip <think>...</think> tags (DeepSeek-R1 style)
|
| 82 |
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 83 |
+
|
| 84 |
+
# Try JSON inside markdown fences first
|
| 85 |
+
fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
| 86 |
+
if fence_match:
|
| 87 |
+
try:
|
| 88 |
+
return json.loads(fence_match.group(1))
|
| 89 |
+
except json.JSONDecodeError:
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
# Fall back to bare JSON object
|
| 93 |
+
json_match = re.search(r"\{.*\}", text, re.DOTALL)
|
| 94 |
+
if json_match:
|
| 95 |
+
try:
|
| 96 |
+
return json.loads(json_match.group(0))
|
| 97 |
+
except json.JSONDecodeError:
|
| 98 |
+
pass
|
| 99 |
+
|
| 100 |
+
return {}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _normalize_for_evidence(value: object) -> str:
|
| 104 |
+
return re.sub(r"[^a-z0-9]+", "", str(value or "").casefold())
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _field_value_has_text_evidence(field: str, value: object, raw_text: str) -> bool:
|
| 108 |
+
normalized_value = _normalize_for_evidence(value)
|
| 109 |
+
normalized_text = _normalize_for_evidence(raw_text)
|
| 110 |
+
if not normalized_value:
|
| 111 |
+
return False
|
| 112 |
+
if normalized_value in normalized_text:
|
| 113 |
+
return True
|
| 114 |
+
if field in {"gross_weight", "cif_value", "fob_value", "freight_value", "insurance_value"}:
|
| 115 |
+
numeric = re.sub(r"[^0-9]", "", str(value))
|
| 116 |
+
return bool(numeric and numeric in normalized_text)
|
| 117 |
+
if field == "total_packages":
|
| 118 |
+
numeric = re.sub(r"[^0-9]", "", str(value))
|
| 119 |
+
return bool(numeric and numeric in normalized_text)
|
| 120 |
+
return False
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def _field_format_valid(field: str, value: object) -> bool:
|
| 124 |
+
text = str(value or "").strip()
|
| 125 |
+
if not text:
|
| 126 |
+
return False
|
| 127 |
+
if field == "importer_npwp":
|
| 128 |
+
return len(re.sub(r"\D", "", text)) in {15, 16}
|
| 129 |
+
if field == "importer_nib":
|
| 130 |
+
return len(re.sub(r"\D", "", text)) == 13
|
| 131 |
+
if field == "hs_code":
|
| 132 |
+
return bool(re.fullmatch(r"\d{8}", text))
|
| 133 |
+
if field == "currency":
|
| 134 |
+
return bool(re.fullmatch(r"[A-Z]{3}", text))
|
| 135 |
+
if field in {"gross_weight", "cif_value", "fob_value", "freight_value", "insurance_value"}:
|
| 136 |
+
try:
|
| 137 |
+
return float(str(value).replace(",", "")) >= 0
|
| 138 |
+
except (TypeError, ValueError):
|
| 139 |
+
return False
|
| 140 |
+
if field == "total_packages":
|
| 141 |
+
try:
|
| 142 |
+
return int(float(str(value).replace(",", ""))) > 0
|
| 143 |
+
except (TypeError, ValueError):
|
| 144 |
+
return False
|
| 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 {}
|
| 151 |
+
pdf_candidate = candidates.get("pdf_text") or {}
|
| 152 |
+
base = 0.88 if doc.get("document_mode") == "digital_pdf_text" else 0.82
|
| 153 |
+
if pdf_candidate.get("confidence"):
|
| 154 |
+
base = max(base, min(0.96, float(pdf_candidate.get("confidence")) * 0.94))
|
| 155 |
+
|
| 156 |
+
confidences: dict[str, float] = {}
|
| 157 |
+
for field, value in extracted.items():
|
| 158 |
+
confidence = base
|
| 159 |
+
has_evidence = _field_value_has_text_evidence(field, value, raw_text)
|
| 160 |
+
format_valid = _field_format_valid(field, value)
|
| 161 |
+
if has_evidence:
|
| 162 |
+
confidence += 0.05
|
| 163 |
+
else:
|
| 164 |
+
confidence -= 0.12
|
| 165 |
+
if not format_valid:
|
| 166 |
+
confidence -= 0.25
|
| 167 |
+
confidences[field] = round(max(0.35, min(0.99, confidence)), 4)
|
| 168 |
+
return confidences
|
| 169 |
+
|
| 170 |
|
| 171 |
async def llm_extraction_node(state: ExtractionGraphState) -> dict:
|
| 172 |
"""
|
| 173 |
+
Step 2.2: Primary LLM Extraction.
|
| 174 |
|
| 175 |
+
- When USE_LOCAL_LLM=true: uses Ollama (text-only, manual JSON parsing).
|
| 176 |
+
- Otherwise: uses Gemini multimodal (with_structured_output).
|
| 177 |
|
| 178 |
Returns:
|
| 179 |
+
dict with documents, combined_data, steps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
"""
|
| 181 |
log.info("Running llm_extraction_node", batch_id=state["batch_id"])
|
| 182 |
|
| 183 |
+
# LLM instances — lazily initialized on first document
|
| 184 |
+
llm = None
|
|
|
|
| 185 |
structured_llm = None
|
| 186 |
+
use_manual_json = False # True for Ollama (no function-calling)
|
| 187 |
|
| 188 |
updated_docs = []
|
| 189 |
combined_data = {}
|
| 190 |
|
| 191 |
for doc in state["documents"]:
|
| 192 |
+
# ── Guard: document must have doc_id and pages ──────────────────────
|
| 193 |
+
has_extraction_input = bool(doc.get("pages")) or bool((doc.get("raw_text") or "").strip())
|
| 194 |
+
if not doc.get("doc_id") or not has_extraction_input:
|
| 195 |
log.error(
|
| 196 |
"Invalid document state — missing required fields",
|
| 197 |
doc_id=doc.get("doc_id"),
|
| 198 |
+
batch_id=state["batch_id"],
|
| 199 |
)
|
| 200 |
updated_docs.append({
|
| 201 |
**doc,
|
| 202 |
+
"error": "Document missing required fields (doc_id and pages/raw_text)",
|
| 203 |
"fallback_required": True,
|
| 204 |
+
"ocr_method": "failed",
|
| 205 |
})
|
| 206 |
continue
|
| 207 |
|
| 208 |
+
# ── Initialize LLM once ─────────────────────────────────────────────
|
| 209 |
+
if llm is None:
|
| 210 |
+
if settings.DETERMINISTIC_E2E:
|
| 211 |
+
if DeterministicLLM is None:
|
| 212 |
+
raise RuntimeError("DETERMINISTIC_E2E enabled but DeterministicLLM not available")
|
| 213 |
+
llm = DeterministicLLM()
|
| 214 |
+
structured_llm = llm.with_structured_output(CEISAFields)
|
| 215 |
+
use_manual_json = False
|
| 216 |
+
|
| 217 |
+
elif settings.USE_LOCAL_LLM:
|
| 218 |
+
try:
|
| 219 |
+
from langchain_openai import ChatOpenAI
|
| 220 |
+
except ImportError:
|
| 221 |
+
raise RuntimeError("Dependency 'langchain_openai' is required for local LLM support")
|
| 222 |
+
|
| 223 |
+
# Supports comma-separated models: "qwen2.5:7b,mistral:7b"
|
| 224 |
+
local_models = [m.strip() for m in settings.LOCAL_LLM_MODEL.split(",") if m.strip()]
|
| 225 |
+
if not local_models:
|
| 226 |
+
local_models = ["qwen2.5:7b"]
|
| 227 |
+
|
| 228 |
+
primary_llm = ChatOpenAI(
|
| 229 |
+
model=local_models[0],
|
| 230 |
+
base_url=settings.OLLAMA_BASE_URL,
|
| 231 |
+
api_key="ollama",
|
| 232 |
+
temperature=0,
|
| 233 |
+
max_retries=1,
|
| 234 |
+
)
|
| 235 |
+
log.info("Using primary local LLM", model=local_models[0])
|
| 236 |
+
|
| 237 |
+
if len(local_models) > 1:
|
| 238 |
+
fallback_llms = [
|
| 239 |
+
ChatOpenAI(
|
| 240 |
+
model=m,
|
| 241 |
+
base_url=settings.OLLAMA_BASE_URL,
|
| 242 |
+
api_key="ollama",
|
| 243 |
+
temperature=0,
|
| 244 |
+
max_retries=1,
|
| 245 |
+
)
|
| 246 |
+
for m in local_models[1:]
|
| 247 |
+
]
|
| 248 |
+
llm = primary_llm.with_fallbacks(fallback_llms)
|
| 249 |
+
log.info("Configured local fallback LLMs", models=local_models[1:])
|
| 250 |
else:
|
| 251 |
+
llm = primary_llm
|
| 252 |
+
|
| 253 |
+
# Ollama does NOT support function-calling — parse JSON manually
|
| 254 |
+
use_manual_json = True
|
| 255 |
+
|
| 256 |
+
else:
|
| 257 |
+
# ── Gemini (multimodal, with_structured_output) ──────────────
|
| 258 |
+
if ChatGoogleGenerativeAI is None:
|
| 259 |
+
raise RuntimeError("Production LLM dependency 'langchain_google_genai' is not installed")
|
| 260 |
+
|
| 261 |
+
primary_llm = ChatGoogleGenerativeAI(
|
| 262 |
+
model=settings.GEMINI_MODEL_PRIMARY,
|
| 263 |
+
temperature=0,
|
| 264 |
+
api_key=settings.GEMINI_API_KEY,
|
| 265 |
+
)
|
| 266 |
+
fallback_llms = []
|
| 267 |
+
try:
|
| 268 |
+
from langchain_openai import ChatOpenAI
|
| 269 |
+
olm_llm = ChatOpenAI(
|
| 270 |
+
model=settings.OLM_BASE_MODEL,
|
| 271 |
+
base_url=f"{settings.OLM_INFERENCE_URL}/v1",
|
| 272 |
+
api_key="empty",
|
| 273 |
temperature=0,
|
| 274 |
+
max_retries=1,
|
| 275 |
)
|
| 276 |
+
fallback_llms.append(olm_llm)
|
| 277 |
+
except Exception as e:
|
| 278 |
+
log.warning("Could not setup OLM fallback", error=str(e))
|
| 279 |
|
| 280 |
+
gemini_fallback = ChatGoogleGenerativeAI(
|
| 281 |
+
model=settings.GEMINI_MODEL_FALLBACK,
|
| 282 |
+
temperature=0,
|
| 283 |
+
api_key=settings.GEMINI_API_KEY,
|
| 284 |
+
)
|
| 285 |
+
fallback_llms.append(gemini_fallback)
|
| 286 |
+
llm = primary_llm.with_fallbacks(fallback_llms)
|
| 287 |
structured_llm = llm.with_structured_output(CEISAFields)
|
|
|
|
| 288 |
if asyncio.iscoroutine(structured_llm) or inspect.isawaitable(structured_llm):
|
| 289 |
structured_llm = await structured_llm
|
| 290 |
+
use_manual_json = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
+
# ── Build prompt messages ───────────────────────────────────────────
|
| 293 |
try:
|
|
|
|
| 294 |
if settings.DETERMINISTIC_E2E:
|
| 295 |
messages = [{"type": "text", "text": "deterministic"}]
|
| 296 |
else:
|
| 297 |
try:
|
| 298 |
from langchain_core.messages import HumanMessage as _HumanMessage
|
| 299 |
except Exception:
|
| 300 |
+
class _HumanMessage: # lightweight fallback
|
| 301 |
def __init__(self, content):
|
| 302 |
self.content = content
|
| 303 |
|
| 304 |
+
if use_manual_json:
|
| 305 |
+
# Text-only prompt for local Ollama models
|
| 306 |
+
raw_text = doc.get("raw_text", "")
|
| 307 |
+
content = (
|
| 308 |
+
"You are a strictly accurate customs document parser for CEISA 4.0 (Indonesian Customs). "
|
| 309 |
+
"Extract ALL the following fields from the document.\n"
|
| 310 |
+
"CRITICAL RULES:\n"
|
| 311 |
+
"1. If a value is NOT clearly present in the text, return null for that field. DO NOT GUESS.\n"
|
| 312 |
+
"2. Return ONLY a valid JSON object. No explanation, no markdown.\n"
|
| 313 |
+
"3. For gross_weight: remove commas used as thousand separators (e.g. '11,603.000' -> 11603.0).\n"
|
| 314 |
+
"4. For total_packages: sum ALL container package counts (e.g. '20 PACKAGES' + '17 PACKAGES' = 37).\n"
|
| 315 |
+
"5. For importer_npwp: ONLY extract if the text explicitly says 'NPWP' or 'Tax ID'. DO NOT use B/L numbers.\n\n"
|
| 316 |
+
"Fields to extract (return as JSON keys):\n"
|
| 317 |
+
"- importer_name: Consignee / buyer company name\n"
|
| 318 |
+
"- importer_npwp: NPWP tax ID (15-16 digits, null if not found)\n"
|
| 319 |
+
"- importer_address: Consignee/importer address\n"
|
| 320 |
+
"- exporter_name: Shipper / seller company name\n"
|
| 321 |
+
"- exporter_address: Shipper/exporter address\n"
|
| 322 |
+
"- bl_number: Bill of Lading number\n"
|
| 323 |
+
"- bl_date: B/L issue date (ISO 8601 if possible)\n"
|
| 324 |
+
"- vessel_name: Ocean vessel name\n"
|
| 325 |
+
"- voyage_number: Voyage number\n"
|
| 326 |
+
"- port_of_loading: Port of departure\n"
|
| 327 |
+
"- port_of_discharge: Port of destination\n"
|
| 328 |
+
"- total_packages: TOTAL packages across ALL containers (integer)\n"
|
| 329 |
+
"- gross_weight: Total gross weight in KGS as a plain float (no commas)\n"
|
| 330 |
+
"- container_numbers: All container numbers comma-separated\n"
|
| 331 |
+
"- description_of_goods: Brief description of cargo\n"
|
| 332 |
+
"- hs_code: HS/BTKI code exactly as printed; do NOT pad/correct invalid 6-digit codes\n"
|
| 333 |
+
"- cif_value: CIF value (float, null if not in document)\n"
|
| 334 |
+
"- fob_value: FOB value (float, null if not in document)\n"
|
| 335 |
+
"- freight_value: Freight value (float, null if not in document)\n"
|
| 336 |
+
"- insurance_value: Insurance value (float, null if not in document)\n"
|
| 337 |
+
"- currency: Currency code (USD/IDR/EUR etc, null if not found)\n"
|
| 338 |
+
"- importer_nib: NIB exactly as printed, null if not found\n"
|
| 339 |
+
"- incoterms: Incoterms code (FOB/CIF/CFR etc, null if not found)\n"
|
| 340 |
+
"- freight_terms: PREPAID or COLLECT (null if not found)\n\n"
|
| 341 |
+
f"Document Text:\n{raw_text[:12000]}"
|
| 342 |
)
|
| 343 |
+
messages = [_HumanMessage(content=[{"type": "text", "text": content}])]
|
| 344 |
+
else:
|
| 345 |
+
# Multimodal prompt for Gemini
|
| 346 |
+
raw_text = (doc.get("raw_text") or "")[:12000]
|
| 347 |
+
prompt_text = (
|
| 348 |
+
"Extract all CEISA fields (importer name, NPWP, packages, weight, CIF value) from this document."
|
| 349 |
+
)
|
| 350 |
+
if raw_text:
|
| 351 |
+
prompt_text += f"\n\nDirect PDF/OCR text:\n{raw_text}"
|
| 352 |
+
messages = [
|
| 353 |
+
_HumanMessage(
|
| 354 |
+
content=[
|
| 355 |
+
{
|
| 356 |
+
"type": "text",
|
| 357 |
+
"text": prompt_text,
|
| 358 |
+
},
|
| 359 |
+
(
|
| 360 |
+
{"type": "image_url", "image_url": {"url": doc["pages"][0]}}
|
| 361 |
+
if doc.get("pages")
|
| 362 |
+
else {"type": "text", "text": "No pages available"}
|
| 363 |
+
),
|
| 364 |
+
]
|
| 365 |
+
)
|
| 366 |
+
]
|
| 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
|
| 374 |
+
try:
|
| 375 |
+
validated = CEISAFields(**raw_extracted)
|
| 376 |
+
extracted = validated.model_dump(exclude_none=True)
|
| 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
|
| 384 |
+
extracted = raw_result
|
| 385 |
|
| 386 |
candidates = dict(doc.get("ocr_candidates") or {})
|
| 387 |
+
field_confidences = _estimate_field_confidences(extracted, doc)
|
| 388 |
candidates[settings.GEMINI_MODEL_PRIMARY] = {
|
| 389 |
"fields": extracted,
|
| 390 |
+
"confidence": round(sum(field_confidences.values()) / len(field_confidences), 4) if field_confidences else 0.0,
|
| 391 |
+
"field_confidences": field_confidences,
|
| 392 |
}
|
| 393 |
|
| 394 |
updated_docs.append({
|
|
|
|
| 396 |
"extracted_data": extracted,
|
| 397 |
"ocr_method": settings.GEMINI_MODEL_PRIMARY,
|
| 398 |
"ocr_candidates": candidates,
|
| 399 |
+
"field_confidences": field_confidences,
|
| 400 |
})
|
|
|
|
| 401 |
combined_data.update(extracted)
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
except Exception as e:
|
| 404 |
+
# Per-document failure — mark for fallback, do NOT crash the batch
|
|
|
|
| 405 |
log.exception(
|
| 406 |
+
"LLM extraction failed — marking doc for fallback",
|
| 407 |
doc_id=doc.get("doc_id"),
|
| 408 |
batch_id=state["batch_id"],
|
| 409 |
+
error_type=type(e).__name__,
|
| 410 |
+
error=str(e),
|
| 411 |
)
|
| 412 |
updated_docs.append({
|
| 413 |
**doc,
|
| 414 |
"error": str(e),
|
| 415 |
"fallback_required": True,
|
| 416 |
+
"ocr_method": "failed",
|
| 417 |
})
|
|
|
|
| 418 |
|
| 419 |
return {
|
| 420 |
"documents": updated_docs,
|
| 421 |
"combined_data": combined_data,
|
| 422 |
+
"steps": ["llm_extraction"],
|
| 423 |
}
|
src/ai/nodes/fallback_ocr.py
CHANGED
|
@@ -22,7 +22,10 @@ def _needs_reconciliation(doc: dict) -> bool:
|
|
| 22 |
or not doc.get("extracted_data")
|
| 23 |
or doc.get("quality_score", 1.0) < settings.OCR_FALLBACK_TRIGGER_QUALITY
|
| 24 |
or bool(doc.get("ocr_conflicts"))
|
| 25 |
-
or
|
|
|
|
|
|
|
|
|
|
| 26 |
or bool(
|
| 27 |
confidences
|
| 28 |
and min(confidences.values()) < settings.OCR_FALLBACK_TRIGGER_CONFIDENCE
|
|
|
|
| 22 |
or not doc.get("extracted_data")
|
| 23 |
or doc.get("quality_score", 1.0) < settings.OCR_FALLBACK_TRIGGER_QUALITY
|
| 24 |
or bool(doc.get("ocr_conflicts"))
|
| 25 |
+
or (
|
| 26 |
+
len(doc.get("ocr_candidates") or {}) > 1
|
| 27 |
+
and doc.get("document_mode") != "digital_pdf_text"
|
| 28 |
+
)
|
| 29 |
or bool(
|
| 30 |
confidences
|
| 31 |
and min(confidences.values()) < settings.OCR_FALLBACK_TRIGGER_CONFIDENCE
|
src/ai/nodes/risk.py
CHANGED
|
@@ -115,6 +115,15 @@ async def risk_assessment_node(state: ExtractionGraphState) -> dict:
|
|
| 115 |
)
|
| 116 |
crs_score = round(crs_raw * 100, 2)
|
| 117 |
crs_grade = _crs_to_grade(crs_score)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
features = {
|
| 120 |
"doc_quality_score": p_quality,
|
|
@@ -127,6 +136,10 @@ async def risk_assessment_node(state: ExtractionGraphState) -> dict:
|
|
| 127 |
"gross_weight_kg": float(combined_data.get("gross_weight") or 0.0),
|
| 128 |
}
|
| 129 |
rejection_prob = round(rejection_predictor.predict_proba(features), 4)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
risk_level = _probability_to_risk(rejection_prob)
|
| 131 |
|
| 132 |
# PRD §13 Invariant: CRS < 70 → must NOT auto-submit
|
|
@@ -147,6 +160,10 @@ async def risk_assessment_node(state: ExtractionGraphState) -> dict:
|
|
| 147 |
|
| 148 |
return {
|
| 149 |
"risk_level": risk_level,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
"needs_human_review": needs_human_review,
|
| 151 |
"steps": ["risk_assessment"],
|
| 152 |
# NOTE: crs_score and rejection_prob are persisted to DB in the
|
|
|
|
| 115 |
)
|
| 116 |
crs_score = round(crs_raw * 100, 2)
|
| 117 |
crs_grade = _crs_to_grade(crs_score)
|
| 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,
|
|
|
|
| 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
|
|
|
|
| 160 |
|
| 161 |
return {
|
| 162 |
"risk_level": risk_level,
|
| 163 |
+
"customs_readiness_score": crs_score,
|
| 164 |
+
"crs_grade": crs_grade,
|
| 165 |
+
"rejection_probability": rejection_prob,
|
| 166 |
+
"risk_features": features,
|
| 167 |
"needs_human_review": needs_human_review,
|
| 168 |
"steps": ["risk_assessment"],
|
| 169 |
# NOTE: crs_score and rejection_prob are persisted to DB in the
|
src/ai/nodes/validate.py
CHANGED
|
@@ -12,10 +12,20 @@ log = structlog.get_logger()
|
|
| 12 |
async def validation_node(state: ExtractionGraphState) -> dict:
|
| 13 |
"""
|
| 14 |
Step 2.4: Cross-Document Validation against JSON rules.
|
|
|
|
| 15 |
"""
|
| 16 |
log.info("Running validation_node", batch_id=state["batch_id"])
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
return {
|
| 21 |
"validation_results": results,
|
|
|
|
| 12 |
async def validation_node(state: ExtractionGraphState) -> dict:
|
| 13 |
"""
|
| 14 |
Step 2.4: Cross-Document Validation against JSON rules.
|
| 15 |
+
Gracefully handles rule evaluation errors to avoid crashing the pipeline.
|
| 16 |
"""
|
| 17 |
log.info("Running validation_node", batch_id=state["batch_id"])
|
| 18 |
|
| 19 |
+
try:
|
| 20 |
+
results, needs_review = validation_rules_service.evaluate(state)
|
| 21 |
+
except Exception as exc:
|
| 22 |
+
log.warning(
|
| 23 |
+
"Validation rules evaluation failed — marking for review",
|
| 24 |
+
batch_id=state["batch_id"],
|
| 25 |
+
error=str(exc),
|
| 26 |
+
)
|
| 27 |
+
results = []
|
| 28 |
+
needs_review = True
|
| 29 |
|
| 30 |
return {
|
| 31 |
"validation_results": results,
|
src/ai/state.py
CHANGED
|
@@ -28,6 +28,10 @@ class ExtractionGraphState(TypedDict):
|
|
| 28 |
validation_results: list[dict]
|
| 29 |
needs_human_review: bool
|
| 30 |
risk_level: str
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
ocr_conflicts: list[dict]
|
| 32 |
field_confidences: dict[str, float]
|
| 33 |
# Keep track of which node executed
|
|
|
|
| 28 |
validation_results: list[dict]
|
| 29 |
needs_human_review: bool
|
| 30 |
risk_level: str
|
| 31 |
+
customs_readiness_score: float | None
|
| 32 |
+
crs_grade: str | None
|
| 33 |
+
rejection_probability: float | None
|
| 34 |
+
risk_features: dict
|
| 35 |
ocr_conflicts: list[dict]
|
| 36 |
field_confidences: dict[str, float]
|
| 37 |
# Keep track of which node executed
|
src/config.py
CHANGED
|
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
|
| 27 |
ENVIRONMENT: Literal["development", "staging", "production"] = "development"
|
| 28 |
DEBUG: bool = False
|
| 29 |
SECRET_KEY: SecretStr = Field(..., min_length=32)
|
| 30 |
-
CORS_ORIGINS: list[str]
|
| 31 |
|
| 32 |
# ── Database ──────────────────────────────────────────────────────────────
|
| 33 |
DATABASE_URL: str # asyncpg connection string e.g. postgresql+asyncpg://...
|
|
@@ -59,6 +59,9 @@ class Settings(BaseSettings):
|
|
| 59 |
|
| 60 |
# ── AI Inference Services (SDD §2.3–2.6) ─────────────────────────────────
|
| 61 |
CLOUD_LLM_ONLY: bool = False # Bypass heavy local models and use Gemini API instead
|
|
|
|
|
|
|
|
|
|
| 62 |
SURYA_INFERENCE_URL: AnyHttpUrl = "http://surya-svc:8001" # Agent A
|
| 63 |
OLM_INFERENCE_URL: AnyHttpUrl = "http://olm-inference:8000" # Agent D
|
| 64 |
PADDLEOCR_SVC_URL: AnyHttpUrl = "http://paddleocr-svc:8002" # Agent B
|
|
@@ -68,10 +71,10 @@ class Settings(BaseSettings):
|
|
| 68 |
HF_TOKEN: SecretStr = "" # type: ignore[assignment]
|
| 69 |
|
| 70 |
# ── Azure Document Intelligence — Agent C ─────────────────────────────────
|
| 71 |
-
AZURE_DI_ENDPOINT: str | None = None
|
| 72 |
-
AZURE_DI_KEY: SecretStr = "" # type: ignore[assignment]
|
| 73 |
-
AZURE_DI_MODEL_ID: str = "prebuilt-
|
| 74 |
-
AZURE_DI_FREE_LIMIT: int = 5000 # Pages/month on F0 tier (Invariant #9)
|
| 75 |
|
| 76 |
# ── CEISA (Simulator in dev, real endpoint in prod) ───────────────────────
|
| 77 |
CEISA_BASE_URL: AnyHttpUrl = "http://simulator:8006"
|
|
@@ -102,9 +105,12 @@ class Settings(BaseSettings):
|
|
| 102 |
CHROMADB_PORT: int = 8000
|
| 103 |
|
| 104 |
# ── AI / LLM ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
| 105 |
GEMINI_API_KEY: SecretStr = Field(..., description="Google Gemini API key")
|
| 106 |
-
GEMINI_MODEL_PRIMARY: str = "gemini-
|
| 107 |
-
GEMINI_MODEL_FALLBACK: str = "gemini-
|
| 108 |
OPENAI_API_KEY: SecretStr = "" # type: ignore[assignment]
|
| 109 |
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
| 110 |
|
|
@@ -114,10 +120,9 @@ class Settings(BaseSettings):
|
|
| 114 |
LANGCHAIN_API_KEY: SecretStr = "" # type: ignore[assignment]
|
| 115 |
|
| 116 |
# ── Feature Flags ─────────────────────────────────────────────────────────
|
| 117 |
-
ENABLE_SURYA_AGENT: bool = True
|
| 118 |
-
ENABLE_AZURE_DI_AGENT: bool = True
|
| 119 |
-
|
| 120 |
-
ENABLE_VESSEL_VALIDATION: bool = True
|
| 121 |
ENABLE_BLOCKCHAIN: bool = True # type: ignore[assignment] — redeclared intentionally
|
| 122 |
ENABLE_INSW_CHECK: bool = True
|
| 123 |
ENABLE_NOTIFICATIONS_WHATSAPP: bool = False
|
|
@@ -133,8 +138,8 @@ class Settings(BaseSettings):
|
|
| 133 |
OCR_MAX_RENDERED_PAGES: int = 10
|
| 134 |
OCR_MAX_LLM_PAGES: int = 5
|
| 135 |
OCR_FAST_PATH_QUALITY_THRESHOLD: float = 0.95
|
| 136 |
-
|
| 137 |
-
|
| 138 |
OCR_RECONCILIATION_DISAGREEMENT_THRESHOLD: float = 0.20
|
| 139 |
LLM_CONFIDENCE_REVIEW_THRESHOLD: float = 0.70
|
| 140 |
CRS_MIN_SUBMIT_THRESHOLD: int = 55
|
|
@@ -161,9 +166,12 @@ class Settings(BaseSettings):
|
|
| 161 |
DRIFT_LOOKBACK_DAYS: int = 30
|
| 162 |
DRIFT_CORRECTION_THRESHOLD: int = 50
|
| 163 |
|
| 164 |
-
#
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
# ── Observability ─────────────────────────────────────────────────────────
|
| 169 |
SENTRY_DSN: str = ""
|
|
@@ -193,8 +201,8 @@ class CeleryConfig:
|
|
| 193 |
accept_content = ["json"]
|
| 194 |
timezone = "Asia/Jakarta"
|
| 195 |
enable_utc = True
|
| 196 |
-
task_soft_time_limit =
|
| 197 |
-
task_time_limit =
|
| 198 |
task_acks_late = True # Ack only after successful completion (NFR-016)
|
| 199 |
worker_prefetch_multiplier = 1 # One task at a time per worker
|
| 200 |
|
|
|
|
| 27 |
ENVIRONMENT: Literal["development", "staging", "production"] = "development"
|
| 28 |
DEBUG: bool = False
|
| 29 |
SECRET_KEY: SecretStr = Field(..., min_length=32)
|
| 30 |
+
CORS_ORIGINS: list[str] = ["*"]
|
| 31 |
|
| 32 |
# ── Database ──────────────────────────────────────────────────────────────
|
| 33 |
DATABASE_URL: str # asyncpg connection string e.g. postgresql+asyncpg://...
|
|
|
|
| 59 |
|
| 60 |
# ── AI Inference Services (SDD §2.3–2.6) ─────────────────────────────────
|
| 61 |
CLOUD_LLM_ONLY: bool = False # Bypass heavy local models and use Gemini API instead
|
| 62 |
+
ENABLE_DUAL_OCR: bool = False # Use both Surya and PaddleOCR for fallback/validation
|
| 63 |
+
OCR_FALLBACK_TRIGGER_QUALITY: float = 0.85 # Fallback threshold
|
| 64 |
+
OCR_FALLBACK_TRIGGER_CONFIDENCE: float = 0.80
|
| 65 |
SURYA_INFERENCE_URL: AnyHttpUrl = "http://surya-svc:8001" # Agent A
|
| 66 |
OLM_INFERENCE_URL: AnyHttpUrl = "http://olm-inference:8000" # Agent D
|
| 67 |
PADDLEOCR_SVC_URL: AnyHttpUrl = "http://paddleocr-svc:8002" # Agent B
|
|
|
|
| 71 |
HF_TOKEN: SecretStr = "" # type: ignore[assignment]
|
| 72 |
|
| 73 |
# ── Azure Document Intelligence — Agent C ─────────────────────────────────
|
| 74 |
+
AZURE_DI_ENDPOINT: str | None = None
|
| 75 |
+
AZURE_DI_KEY: SecretStr = "" # type: ignore[assignment]
|
| 76 |
+
AZURE_DI_MODEL_ID: str = "prebuilt-read"
|
| 77 |
+
AZURE_DI_FREE_LIMIT: int = 5000 # Pages/month on F0 tier (Invariant #9)
|
| 78 |
|
| 79 |
# ── CEISA (Simulator in dev, real endpoint in prod) ───────────────────────
|
| 80 |
CEISA_BASE_URL: AnyHttpUrl = "http://simulator:8006"
|
|
|
|
| 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"
|
| 114 |
OPENAI_API_KEY: SecretStr = "" # type: ignore[assignment]
|
| 115 |
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
| 116 |
|
|
|
|
| 120 |
LANGCHAIN_API_KEY: SecretStr = "" # type: ignore[assignment]
|
| 121 |
|
| 122 |
# ── Feature Flags ─────────────────────────────────────────────────────────
|
| 123 |
+
ENABLE_SURYA_AGENT: bool = True
|
| 124 |
+
ENABLE_AZURE_DI_AGENT: bool = True
|
| 125 |
+
ENABLE_VESSEL_VALIDATION: bool = True
|
|
|
|
| 126 |
ENABLE_BLOCKCHAIN: bool = True # type: ignore[assignment] — redeclared intentionally
|
| 127 |
ENABLE_INSW_CHECK: bool = True
|
| 128 |
ENABLE_NOTIFICATIONS_WHATSAPP: bool = False
|
|
|
|
| 138 |
OCR_MAX_RENDERED_PAGES: int = 10
|
| 139 |
OCR_MAX_LLM_PAGES: int = 5
|
| 140 |
OCR_FAST_PATH_QUALITY_THRESHOLD: float = 0.95
|
| 141 |
+
OCR_PDF_TEXT_MIN_CHARS: int = 250
|
| 142 |
+
OCR_PDF_TEXT_MIN_CHARS_PER_PAGE: int = 80
|
| 143 |
OCR_RECONCILIATION_DISAGREEMENT_THRESHOLD: float = 0.20
|
| 144 |
LLM_CONFIDENCE_REVIEW_THRESHOLD: float = 0.70
|
| 145 |
CRS_MIN_SUBMIT_THRESHOLD: int = 55
|
|
|
|
| 166 |
DRIFT_LOOKBACK_DAYS: int = 30
|
| 167 |
DRIFT_CORRECTION_THRESHOLD: int = 50
|
| 168 |
|
| 169 |
+
# Celery Configuration
|
| 170 |
+
CELERY_BROKER_URL: str = "redis://redis:6379/0"
|
| 171 |
+
CELERY_RESULT_BACKEND: str = "redis://redis:6379/0"
|
| 172 |
+
CELERY_TASK_SOFT_TIME_LIMIT: int = 1800 # 30 minutes for slow OCR models
|
| 173 |
+
CELERY_TASK_TIME_LIMIT: int = 1900
|
| 174 |
+
RUN_OCR_IN_API_BACKGROUND: bool = False
|
| 175 |
|
| 176 |
# ── Observability ─────────────────────────────────────────────────────────
|
| 177 |
SENTRY_DSN: str = ""
|
|
|
|
| 201 |
accept_content = ["json"]
|
| 202 |
timezone = "Asia/Jakarta"
|
| 203 |
enable_utc = True
|
| 204 |
+
task_soft_time_limit = 600
|
| 205 |
+
task_time_limit = 700
|
| 206 |
task_acks_late = True # Ack only after successful completion (NFR-016)
|
| 207 |
worker_prefetch_multiplier = 1 # One task at a time per worker
|
| 208 |
|
src/dependencies.py
CHANGED
|
@@ -54,17 +54,10 @@ async def close_supabase() -> None:
|
|
| 54 |
|
| 55 |
def get_supabase() -> AsyncClient:
|
| 56 |
if _supabase_client is None:
|
| 57 |
-
raise
|
| 58 |
-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 59 |
-
detail="Supabase client is not initialized. Check SUPABASE_URL and SUPABASE_SERVICE_KEY.",
|
| 60 |
-
)
|
| 61 |
return _supabase_client
|
| 62 |
|
| 63 |
|
| 64 |
-
def is_supabase_initialized() -> bool:
|
| 65 |
-
return _supabase_client is not None
|
| 66 |
-
|
| 67 |
-
|
| 68 |
# ── Keycloak JWKS cache ───────────────────────────────────────────────────────
|
| 69 |
_keycloak_jwks: dict | None = None
|
| 70 |
_keycloak_jwks_time: float = 0
|
|
|
|
| 54 |
|
| 55 |
def get_supabase() -> AsyncClient:
|
| 56 |
if _supabase_client is None:
|
| 57 |
+
raise RuntimeError("Supabase client not initialized. Call init_supabase() first.")
|
|
|
|
|
|
|
|
|
|
| 58 |
return _supabase_client
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
# ── Keycloak JWKS cache ───────────────────────────────────────────────────────
|
| 62 |
_keycloak_jwks: dict | None = None
|
| 63 |
_keycloak_jwks_time: float = 0
|
src/main.py
CHANGED
|
@@ -29,7 +29,7 @@ except Exception: # pragma: no cover - optional
|
|
| 29 |
Instrumentator = None
|
| 30 |
|
| 31 |
from .config import settings
|
| 32 |
-
from .dependencies import close_supabase, init_supabase
|
| 33 |
from .routers import admin, batches, blockchain, hs_recommend, vessel
|
| 34 |
from .utils.telemetry import setup_telemetry
|
| 35 |
|
|
@@ -120,38 +120,6 @@ def create_app() -> FastAPI:
|
|
| 120 |
app = create_app()
|
| 121 |
|
| 122 |
|
| 123 |
-
def _configured_secret(value: object) -> bool:
|
| 124 |
-
raw = value.get_secret_value() if hasattr(value, "get_secret_value") else value
|
| 125 |
-
text = str(raw or "").strip()
|
| 126 |
-
if not text:
|
| 127 |
-
return False
|
| 128 |
-
lowered = text.lower()
|
| 129 |
-
return not (
|
| 130 |
-
lowered.startswith("your-")
|
| 131 |
-
or lowered.startswith("test-")
|
| 132 |
-
or "placeholder" in lowered
|
| 133 |
-
)
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
@app.get("/health/config", tags=["health"])
|
| 137 |
-
async def health_config() -> dict[str, object]:
|
| 138 |
-
"""Safe deployment diagnostics. Returns booleans only for secrets."""
|
| 139 |
-
return {
|
| 140 |
-
"status": "ok",
|
| 141 |
-
"environment": settings.ENVIRONMENT,
|
| 142 |
-
"cloud_llm_only": settings.CLOUD_LLM_ONLY,
|
| 143 |
-
"deterministic_e2e": settings.DETERMINISTIC_E2E,
|
| 144 |
-
"storage_backend": settings.STORAGE_BACKEND,
|
| 145 |
-
"storage_bucket": settings.STORAGE_BUCKET_NAME,
|
| 146 |
-
"supabase_configured": bool(settings.SUPABASE_URL) and _configured_secret(settings.SUPABASE_SERVICE_KEY),
|
| 147 |
-
"supabase_client_initialized": is_supabase_initialized(),
|
| 148 |
-
"gemini_api_key_configured": _configured_secret(settings.GEMINI_API_KEY),
|
| 149 |
-
"azure_di_enabled": settings.ENABLE_AZURE_DI_AGENT,
|
| 150 |
-
"azure_di_configured": bool(settings.AZURE_DI_ENDPOINT) and _configured_secret(settings.AZURE_DI_KEY),
|
| 151 |
-
"celery_broker_configured": bool(settings.REDIS_URL),
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
|
| 155 |
@app.get("/health", tags=["health"])
|
| 156 |
async def health_check() -> dict[str, str]:
|
| 157 |
"""Health check endpoint — public, no auth required."""
|
|
|
|
| 29 |
Instrumentator = None
|
| 30 |
|
| 31 |
from .config import settings
|
| 32 |
+
from .dependencies import close_supabase, init_supabase
|
| 33 |
from .routers import admin, batches, blockchain, hs_recommend, vessel
|
| 34 |
from .utils.telemetry import setup_telemetry
|
| 35 |
|
|
|
|
| 120 |
app = create_app()
|
| 121 |
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
@app.get("/health", tags=["health"])
|
| 124 |
async def health_check() -> dict[str, str]:
|
| 125 |
"""Health check endpoint — public, no auth required."""
|
src/routers/batches.py
CHANGED
|
@@ -25,7 +25,7 @@ except Exception: # pragma: no cover - optional for tests
|
|
| 25 |
|
| 26 |
from ..dependencies import CurrentUser, get_current_user, get_supabase, require_operator
|
| 27 |
from ..services.ingest_svc import get_storage_service
|
| 28 |
-
from ..tasks.ocr_tasks import preprocess_document
|
| 29 |
|
| 30 |
log = structlog.get_logger()
|
| 31 |
router = APIRouter()
|
|
@@ -151,12 +151,16 @@ async def create_batch(
|
|
| 151 |
|
| 152 |
await supabase.table("batches").update({"status": "preprocessing"}).eq("id", batch_id).execute()
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
log.info("Batch created", batch_id=batch_id, user=user.id, docs=len(files), tier=user.tier)
|
| 162 |
return {"batch_id": batch_id, "status": "preprocessing", "documents": documents}
|
|
@@ -170,19 +174,18 @@ async def list_batches(
|
|
| 170 |
offset: int = 0,
|
| 171 |
) -> dict[str, Any]:
|
| 172 |
"""List batches for the current user's company."""
|
| 173 |
-
|
| 174 |
supabase.table("batches")
|
| 175 |
.select("id,status,customs_readiness_score,crs_grade,risk_level,created_at,expires_at")
|
| 176 |
-
.eq("company_id", user.company_id)
|
| 177 |
.order("created_at", desc=True)
|
| 178 |
.range(offset, offset + limit - 1)
|
| 179 |
-
.execute()
|
| 180 |
)
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
| 186 |
|
| 187 |
|
| 188 |
@router.get("/batches/{batch_id}")
|
|
@@ -192,7 +195,17 @@ async def get_batch(
|
|
| 192 |
supabase: Annotated[AsyncClient, Depends(get_supabase)],
|
| 193 |
) -> dict[str, Any]:
|
| 194 |
"""Get full batch details including extracted fields and validation results."""
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
batch = batch_res.data
|
| 197 |
if not batch:
|
| 198 |
raise HTTPException(status_code=404, detail="Batch not found")
|
|
@@ -202,15 +215,11 @@ async def get_batch(
|
|
| 202 |
docs_res = await supabase.table("documents").select("*").eq("batch_id", batch_id).execute()
|
| 203 |
fields_res = await supabase.table("extracted_fields").select("*").eq("batch_id", batch_id).execute()
|
| 204 |
validations_res = await supabase.table("validation_results").select("*").eq("batch_id", batch_id).execute()
|
| 205 |
-
fields = fields_res.data or []
|
| 206 |
-
reconciled_fields = _build_reconciled_fields(fields)
|
| 207 |
-
batch["importer"] = _importer_from_reconciled(reconciled_fields)
|
| 208 |
|
| 209 |
return {
|
| 210 |
"batch": batch,
|
| 211 |
"documents": docs_res.data,
|
| 212 |
-
"extracted_fields":
|
| 213 |
-
"reconciled_fields": reconciled_fields,
|
| 214 |
"validation_results": validations_res.data,
|
| 215 |
}
|
| 216 |
|
|
@@ -297,72 +306,3 @@ def _infer_doc_type(filename: str) -> str:
|
|
| 297 |
if any(k in name for k in ("pl", "packing", "packinglist")):
|
| 298 |
return "packing_list"
|
| 299 |
return "invoice"
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
def _decorate_batch_summary(row: dict[str, Any]) -> dict[str, Any]:
|
| 303 |
-
score = row.get("customs_readiness_score")
|
| 304 |
-
grade = row.get("crs_grade")
|
| 305 |
-
risk = row.get("risk_level")
|
| 306 |
-
return {
|
| 307 |
-
**row,
|
| 308 |
-
"ref": str(row.get("id", ""))[:8].upper(),
|
| 309 |
-
"type": "PIB",
|
| 310 |
-
"risk": risk or "LOW",
|
| 311 |
-
"crs": float(score or 0),
|
| 312 |
-
"grade": grade or "F",
|
| 313 |
-
"date": row.get("created_at"),
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
async def _load_importer_names(supabase: AsyncClient, batch_ids: list[str]) -> dict[str, str]:
|
| 318 |
-
if not batch_ids:
|
| 319 |
-
return {}
|
| 320 |
-
try:
|
| 321 |
-
fields_res = await (
|
| 322 |
-
supabase.table("extracted_fields")
|
| 323 |
-
.select("batch_id,ceisa_field,extracted_value,normalized_value")
|
| 324 |
-
.in_("batch_id", batch_ids)
|
| 325 |
-
.eq("ceisa_field", "importer_name")
|
| 326 |
-
.execute()
|
| 327 |
-
)
|
| 328 |
-
except Exception as exc:
|
| 329 |
-
log.warning("Could not load importer names for batch list", error=str(exc))
|
| 330 |
-
return {}
|
| 331 |
-
|
| 332 |
-
names: dict[str, str] = {}
|
| 333 |
-
for field in fields_res.data or []:
|
| 334 |
-
value = field.get("normalized_value") or field.get("extracted_value")
|
| 335 |
-
if value:
|
| 336 |
-
names[field["batch_id"]] = str(value)
|
| 337 |
-
return names
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
def _build_reconciled_fields(fields: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
| 341 |
-
reconciled: dict[str, dict[str, Any]] = {}
|
| 342 |
-
for field in fields:
|
| 343 |
-
name = field.get("ceisa_field")
|
| 344 |
-
if not name:
|
| 345 |
-
continue
|
| 346 |
-
confidence = float(field.get("confidence") or 0.0)
|
| 347 |
-
reconciled[name] = {
|
| 348 |
-
"value": field.get("normalized_value") or field.get("extracted_value") or field.get("raw_ocr_value"),
|
| 349 |
-
"confidence": confidence,
|
| 350 |
-
"level": field.get("confidence_level") or _confidence_level(confidence),
|
| 351 |
-
"source": field.get("extraction_method") or "direct_ocr",
|
| 352 |
-
"agent_disagreement": bool(field.get("agent_disagreement")),
|
| 353 |
-
"all_agent_values": field.get("agent_outputs") or {},
|
| 354 |
-
}
|
| 355 |
-
return reconciled
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
def _confidence_level(confidence: float) -> str:
|
| 359 |
-
if confidence >= 0.90:
|
| 360 |
-
return "HIGH"
|
| 361 |
-
if confidence >= 0.70:
|
| 362 |
-
return "MEDIUM"
|
| 363 |
-
return "LOW"
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
def _importer_from_reconciled(fields: dict[str, dict[str, Any]]) -> str:
|
| 367 |
-
value = fields.get("importer_name", {}).get("value")
|
| 368 |
-
return str(value) if value else "Pending extraction"
|
|
|
|
| 25 |
|
| 26 |
from ..dependencies import CurrentUser, get_current_user, get_supabase, require_operator
|
| 27 |
from ..services.ingest_svc import get_storage_service
|
| 28 |
+
from ..tasks.ocr_tasks import preprocess_document, run_preprocess_pipeline_sync
|
| 29 |
|
| 30 |
log = structlog.get_logger()
|
| 31 |
router = APIRouter()
|
|
|
|
| 151 |
|
| 152 |
await supabase.table("batches").update({"status": "preprocessing"}).eq("id", batch_id).execute()
|
| 153 |
|
| 154 |
+
if settings.RUN_OCR_IN_API_BACKGROUND:
|
| 155 |
+
log.info("Running OCR pipeline via FastAPI BackgroundTasks", batch_id=batch_id)
|
| 156 |
+
background_tasks.add_task(run_preprocess_pipeline_sync, batch_id, True)
|
| 157 |
+
else:
|
| 158 |
+
queue = "high" if user.is_enterprise else "default"
|
| 159 |
+
try:
|
| 160 |
+
preprocess_document.apply_async(args=[batch_id], queue=queue)
|
| 161 |
+
except Exception as e:
|
| 162 |
+
log.warning("Celery apply_async failed, falling back to BackgroundTasks", error=str(e))
|
| 163 |
+
background_tasks.add_task(run_preprocess_pipeline_sync, batch_id, True)
|
| 164 |
|
| 165 |
log.info("Batch created", batch_id=batch_id, user=user.id, docs=len(files), tier=user.tier)
|
| 166 |
return {"batch_id": batch_id, "status": "preprocessing", "documents": documents}
|
|
|
|
| 174 |
offset: int = 0,
|
| 175 |
) -> dict[str, Any]:
|
| 176 |
"""List batches for the current user's company."""
|
| 177 |
+
query = (
|
| 178 |
supabase.table("batches")
|
| 179 |
.select("id,status,customs_readiness_score,crs_grade,risk_level,created_at,expires_at")
|
|
|
|
| 180 |
.order("created_at", desc=True)
|
| 181 |
.range(offset, offset + limit - 1)
|
|
|
|
| 182 |
)
|
| 183 |
+
|
| 184 |
+
if user.company_id:
|
| 185 |
+
query = query.eq("company_id", user.company_id)
|
| 186 |
+
|
| 187 |
+
res = await query.execute()
|
| 188 |
+
return {"batches": res.data, "total": len(res.data)}
|
| 189 |
|
| 190 |
|
| 191 |
@router.get("/batches/{batch_id}")
|
|
|
|
| 195 |
supabase: Annotated[AsyncClient, Depends(get_supabase)],
|
| 196 |
) -> dict[str, Any]:
|
| 197 |
"""Get full batch details including extracted fields and validation results."""
|
| 198 |
+
try:
|
| 199 |
+
uuid.UUID(batch_id)
|
| 200 |
+
except ValueError as exc:
|
| 201 |
+
raise HTTPException(status_code=404, detail="Batch not found") from exc
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
batch_res = await supabase.table("batches").select("*").eq("id", batch_id).single().execute()
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
log.warning("Failed to load batch", batch_id=batch_id, error=str(exc))
|
| 207 |
+
raise HTTPException(status_code=404, detail="Batch not found") from exc
|
| 208 |
+
|
| 209 |
batch = batch_res.data
|
| 210 |
if not batch:
|
| 211 |
raise HTTPException(status_code=404, detail="Batch not found")
|
|
|
|
| 215 |
docs_res = await supabase.table("documents").select("*").eq("batch_id", batch_id).execute()
|
| 216 |
fields_res = await supabase.table("extracted_fields").select("*").eq("batch_id", batch_id).execute()
|
| 217 |
validations_res = await supabase.table("validation_results").select("*").eq("batch_id", batch_id).execute()
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
return {
|
| 220 |
"batch": batch,
|
| 221 |
"documents": docs_res.data,
|
| 222 |
+
"extracted_fields": fields_res.data,
|
|
|
|
| 223 |
"validation_results": validations_res.data,
|
| 224 |
}
|
| 225 |
|
|
|
|
| 306 |
if any(k in name for k in ("pl", "packing", "packinglist")):
|
| 307 |
return "packing_list"
|
| 308 |
return "invoice"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/scripts/backfill_batch_risk.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import sys
|
| 5 |
+
from typing import Any
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 9 |
+
|
| 10 |
+
from supabase import acreate_client
|
| 11 |
+
|
| 12 |
+
from src.ai.nodes.risk import risk_assessment_node
|
| 13 |
+
from src.config import settings
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _coerce_field(field: str, value: Any) -> Any:
|
| 17 |
+
if value is None:
|
| 18 |
+
return None
|
| 19 |
+
if field in {"gross_weight", "cif_value", "fob_value"}:
|
| 20 |
+
try:
|
| 21 |
+
return float(str(value).replace(",", ""))
|
| 22 |
+
except Exception:
|
| 23 |
+
return value
|
| 24 |
+
if field == "total_packages":
|
| 25 |
+
try:
|
| 26 |
+
return int(float(str(value).replace(",", "")))
|
| 27 |
+
except Exception:
|
| 28 |
+
return value
|
| 29 |
+
return value
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def backfill_batch_risk(batch_id: str) -> dict[str, Any]:
|
| 33 |
+
supabase = await acreate_client(
|
| 34 |
+
settings.SUPABASE_URL,
|
| 35 |
+
settings.SUPABASE_SERVICE_KEY.get_secret_value(),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
docs = (
|
| 39 |
+
await supabase.table("documents").select("*").eq("batch_id", batch_id).execute()
|
| 40 |
+
).data or []
|
| 41 |
+
fields = (
|
| 42 |
+
await supabase.table("extracted_fields").select("*").eq("batch_id", batch_id).execute()
|
| 43 |
+
).data or []
|
| 44 |
+
validations = (
|
| 45 |
+
await supabase.table("validation_results").select("*").eq("batch_id", batch_id).execute()
|
| 46 |
+
).data or []
|
| 47 |
+
|
| 48 |
+
combined: dict[str, Any] = {}
|
| 49 |
+
confidences: dict[str, float] = {}
|
| 50 |
+
for row in fields:
|
| 51 |
+
name = row.get("ceisa_field")
|
| 52 |
+
if not name:
|
| 53 |
+
continue
|
| 54 |
+
combined[name] = _coerce_field(
|
| 55 |
+
name,
|
| 56 |
+
row.get("normalized_value") or row.get("extracted_value"),
|
| 57 |
+
)
|
| 58 |
+
confidences[name] = float(row.get("confidence") or 0.0)
|
| 59 |
+
|
| 60 |
+
state = {
|
| 61 |
+
"batch_id": batch_id,
|
| 62 |
+
"company_id": "",
|
| 63 |
+
"documents": [
|
| 64 |
+
{
|
| 65 |
+
"doc_id": doc["id"],
|
| 66 |
+
"doc_type": doc.get("doc_type"),
|
| 67 |
+
"storage_path": doc.get("storage_path"),
|
| 68 |
+
"pages": [],
|
| 69 |
+
"extracted_data": {},
|
| 70 |
+
"quality_score": float(doc.get("quality_score") or 1.0),
|
| 71 |
+
"ocr_method": doc.get("ocr_engine_used"),
|
| 72 |
+
"error": doc.get("error_message"),
|
| 73 |
+
"ocr_candidates": {},
|
| 74 |
+
"ocr_conflicts": [],
|
| 75 |
+
"field_confidences": {},
|
| 76 |
+
}
|
| 77 |
+
for doc in docs
|
| 78 |
+
],
|
| 79 |
+
"combined_data": combined,
|
| 80 |
+
"validation_results": validations,
|
| 81 |
+
"needs_human_review": False,
|
| 82 |
+
"risk_level": "UNKNOWN",
|
| 83 |
+
"customs_readiness_score": None,
|
| 84 |
+
"crs_grade": None,
|
| 85 |
+
"rejection_probability": None,
|
| 86 |
+
"risk_features": {},
|
| 87 |
+
"ocr_conflicts": [],
|
| 88 |
+
"field_confidences": confidences,
|
| 89 |
+
"steps": [],
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
result = await risk_assessment_node(state) # type: ignore[arg-type]
|
| 93 |
+
payload = {
|
| 94 |
+
"risk_level": result.get("risk_level"),
|
| 95 |
+
"customs_readiness_score": result.get("customs_readiness_score"),
|
| 96 |
+
"crs_grade": result.get("crs_grade"),
|
| 97 |
+
"rejection_probability": result.get("rejection_probability"),
|
| 98 |
+
}
|
| 99 |
+
await supabase.table("batches").update(payload).eq("id", batch_id).execute()
|
| 100 |
+
return payload
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
if len(sys.argv) != 2:
|
| 105 |
+
raise SystemExit("Usage: python /app/src/scripts/backfill_batch_risk.py <batch_id>")
|
| 106 |
+
print(asyncio.run(backfill_batch_risk(sys.argv[1])))
|
src/scripts/recompute_field_confidences.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 9 |
+
|
| 10 |
+
from supabase import acreate_client
|
| 11 |
+
|
| 12 |
+
from src.ai.nodes.extract import _estimate_field_confidences
|
| 13 |
+
from src.config import settings
|
| 14 |
+
from src.services.ingest_svc import get_storage_service
|
| 15 |
+
from src.services.ocr_engine_svc import ocr_engine_service
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def recompute_field_confidences(batch_id: str) -> dict[str, Any]:
|
| 19 |
+
supabase = await acreate_client(
|
| 20 |
+
settings.SUPABASE_URL,
|
| 21 |
+
settings.SUPABASE_SERVICE_KEY.get_secret_value(),
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
docs = (
|
| 25 |
+
await supabase.table("documents").select("*").eq("batch_id", batch_id).execute()
|
| 26 |
+
).data or []
|
| 27 |
+
rows = (
|
| 28 |
+
await supabase.table("extracted_fields").select("*").eq("batch_id", batch_id).execute()
|
| 29 |
+
).data or []
|
| 30 |
+
|
| 31 |
+
fields_by_doc: dict[str, dict[str, Any]] = {}
|
| 32 |
+
row_keys_by_doc_field: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
| 33 |
+
for row in rows:
|
| 34 |
+
document_id = row.get("document_id")
|
| 35 |
+
field = row.get("ceisa_field")
|
| 36 |
+
if not document_id or not field:
|
| 37 |
+
continue
|
| 38 |
+
fields_by_doc.setdefault(document_id, {})[field] = row.get("normalized_value") or row.get("extracted_value")
|
| 39 |
+
row_keys_by_doc_field.setdefault((document_id, field), []).append(row)
|
| 40 |
+
|
| 41 |
+
storage = get_storage_service()
|
| 42 |
+
updated = 0
|
| 43 |
+
doc_summaries = []
|
| 44 |
+
|
| 45 |
+
for doc in docs:
|
| 46 |
+
doc_id = doc["id"]
|
| 47 |
+
extracted = fields_by_doc.get(doc_id) or {}
|
| 48 |
+
if not extracted:
|
| 49 |
+
continue
|
| 50 |
+
|
| 51 |
+
raw_text = ""
|
| 52 |
+
direct_candidate: dict[str, Any] = {}
|
| 53 |
+
try:
|
| 54 |
+
file_bytes = await storage.download_document(doc["storage_path"])
|
| 55 |
+
filename = doc.get("original_name") or doc.get("storage_path") or ""
|
| 56 |
+
if str(filename).lower().endswith(".pdf"):
|
| 57 |
+
direct_candidate = ocr_engine_service._extract_pdf_text(file_bytes)
|
| 58 |
+
raw_text = direct_candidate.get("text") or ""
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
print(f"warning: failed to reload {doc.get('original_name')}: {exc}")
|
| 61 |
+
|
| 62 |
+
doc_state = {
|
| 63 |
+
"document_mode": "digital_pdf_text" if raw_text else doc.get("processing_route"),
|
| 64 |
+
"raw_text": raw_text,
|
| 65 |
+
"ocr_candidates": {"pdf_text": direct_candidate} if direct_candidate else {},
|
| 66 |
+
}
|
| 67 |
+
confidences = _estimate_field_confidences(extracted, doc_state)
|
| 68 |
+
if not confidences:
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
for field, confidence in confidences.items():
|
| 72 |
+
for row in row_keys_by_doc_field.get((doc_id, field), []):
|
| 73 |
+
row_id = row.get("id")
|
| 74 |
+
if row_id:
|
| 75 |
+
await supabase.table("extracted_fields").update({"confidence": confidence}).eq("id", row_id).execute()
|
| 76 |
+
else:
|
| 77 |
+
await (
|
| 78 |
+
supabase.table("extracted_fields")
|
| 79 |
+
.update({"confidence": confidence})
|
| 80 |
+
.eq("batch_id", batch_id)
|
| 81 |
+
.eq("document_id", doc_id)
|
| 82 |
+
.eq("ceisa_field", field)
|
| 83 |
+
.execute()
|
| 84 |
+
)
|
| 85 |
+
updated += 1
|
| 86 |
+
|
| 87 |
+
avg_conf = round(sum(confidences.values()) / len(confidences), 4)
|
| 88 |
+
await (
|
| 89 |
+
supabase.table("documents")
|
| 90 |
+
.update({"overall_ocr_confidence": avg_conf})
|
| 91 |
+
.eq("id", doc_id)
|
| 92 |
+
.execute()
|
| 93 |
+
)
|
| 94 |
+
doc_summaries.append(
|
| 95 |
+
{
|
| 96 |
+
"doc_type": doc.get("doc_type"),
|
| 97 |
+
"original_name": doc.get("original_name"),
|
| 98 |
+
"avg_confidence": avg_conf,
|
| 99 |
+
"fields": len(confidences),
|
| 100 |
+
}
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
return {"batch_id": batch_id, "updated_rows": updated, "documents": doc_summaries}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
if len(sys.argv) != 2:
|
| 108 |
+
raise SystemExit("Usage: python /app/src/scripts/recompute_field_confidences.py <batch_id>")
|
| 109 |
+
print(asyncio.run(recompute_field_confidences(sys.argv[1])))
|
src/scripts/revalidate_batch.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 9 |
+
|
| 10 |
+
from supabase import acreate_client
|
| 11 |
+
|
| 12 |
+
from src.ai.nodes.risk import risk_assessment_node
|
| 13 |
+
from src.config import settings
|
| 14 |
+
from src.services.validation_rules_svc import validation_rules_service
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _coerce_field(field: str, value: Any) -> Any:
|
| 18 |
+
if value is None:
|
| 19 |
+
return None
|
| 20 |
+
if field in {"gross_weight", "cif_value", "fob_value", "freight_value", "insurance_value"}:
|
| 21 |
+
try:
|
| 22 |
+
return float(str(value).replace(",", ""))
|
| 23 |
+
except Exception:
|
| 24 |
+
return value
|
| 25 |
+
if field == "total_packages":
|
| 26 |
+
try:
|
| 27 |
+
return int(float(str(value).replace(",", "")))
|
| 28 |
+
except Exception:
|
| 29 |
+
return value
|
| 30 |
+
return value
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def revalidate_batch(batch_id: str) -> dict[str, Any]:
|
| 34 |
+
supabase = await acreate_client(
|
| 35 |
+
settings.SUPABASE_URL,
|
| 36 |
+
settings.SUPABASE_SERVICE_KEY.get_secret_value(),
|
| 37 |
+
)
|
| 38 |
+
docs = (
|
| 39 |
+
await supabase.table("documents").select("*").eq("batch_id", batch_id).execute()
|
| 40 |
+
).data or []
|
| 41 |
+
fields = (
|
| 42 |
+
await supabase.table("extracted_fields").select("*").eq("batch_id", batch_id).execute()
|
| 43 |
+
).data or []
|
| 44 |
+
|
| 45 |
+
by_doc: dict[str, dict[str, Any]] = {}
|
| 46 |
+
combined: dict[str, Any] = {}
|
| 47 |
+
confidences: dict[str, float] = {}
|
| 48 |
+
for row in fields:
|
| 49 |
+
document_id = row.get("document_id")
|
| 50 |
+
name = row.get("ceisa_field")
|
| 51 |
+
if not document_id or not name:
|
| 52 |
+
continue
|
| 53 |
+
value = _coerce_field(name, row.get("normalized_value") or row.get("extracted_value"))
|
| 54 |
+
by_doc.setdefault(document_id, {})[name] = value
|
| 55 |
+
combined[name] = value
|
| 56 |
+
confidences[name] = float(row.get("confidence") or 0.0)
|
| 57 |
+
|
| 58 |
+
state = {
|
| 59 |
+
"batch_id": batch_id,
|
| 60 |
+
"company_id": "",
|
| 61 |
+
"documents": [
|
| 62 |
+
{
|
| 63 |
+
"doc_id": doc["id"],
|
| 64 |
+
"doc_type": doc.get("doc_type"),
|
| 65 |
+
"storage_path": doc.get("storage_path"),
|
| 66 |
+
"pages": [],
|
| 67 |
+
"extracted_data": by_doc.get(doc["id"], {}),
|
| 68 |
+
"quality_score": float(doc.get("quality_score") or 1.0),
|
| 69 |
+
"ocr_method": doc.get("ocr_engine_used"),
|
| 70 |
+
"error": doc.get("error_message"),
|
| 71 |
+
"ocr_candidates": {},
|
| 72 |
+
"ocr_conflicts": [],
|
| 73 |
+
"field_confidences": {},
|
| 74 |
+
}
|
| 75 |
+
for doc in docs
|
| 76 |
+
],
|
| 77 |
+
"combined_data": combined,
|
| 78 |
+
"validation_results": [],
|
| 79 |
+
"needs_human_review": False,
|
| 80 |
+
"risk_level": "UNKNOWN",
|
| 81 |
+
"customs_readiness_score": None,
|
| 82 |
+
"crs_grade": None,
|
| 83 |
+
"rejection_probability": None,
|
| 84 |
+
"risk_features": {},
|
| 85 |
+
"ocr_conflicts": [],
|
| 86 |
+
"field_confidences": confidences,
|
| 87 |
+
"steps": [],
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
validations, needs_review = validation_rules_service.evaluate(state)
|
| 91 |
+
state["validation_results"] = validations
|
| 92 |
+
state["needs_human_review"] = needs_review
|
| 93 |
+
risk = await risk_assessment_node(state) # type: ignore[arg-type]
|
| 94 |
+
|
| 95 |
+
await supabase.table("validation_results").delete().eq("batch_id", batch_id).execute()
|
| 96 |
+
if validations:
|
| 97 |
+
await supabase.table("validation_results").insert([
|
| 98 |
+
{
|
| 99 |
+
"batch_id": batch_id,
|
| 100 |
+
"rule_id": row.get("rule_id", "UNKNOWN"),
|
| 101 |
+
"rule_name": row.get("rule_name", row.get("message", "Validation")),
|
| 102 |
+
"severity": row.get("severity", "WARNING"),
|
| 103 |
+
"error_message": row.get("message"),
|
| 104 |
+
"affected_fields": row.get("affected_fields", []),
|
| 105 |
+
}
|
| 106 |
+
for row in validations
|
| 107 |
+
]).execute()
|
| 108 |
+
|
| 109 |
+
payload = {
|
| 110 |
+
"status": "review_ready" if risk.get("needs_human_review") else "validated",
|
| 111 |
+
"risk_level": risk.get("risk_level"),
|
| 112 |
+
"customs_readiness_score": risk.get("customs_readiness_score"),
|
| 113 |
+
"crs_grade": risk.get("crs_grade"),
|
| 114 |
+
"rejection_probability": risk.get("rejection_probability"),
|
| 115 |
+
}
|
| 116 |
+
await supabase.table("batches").update(payload).eq("id", batch_id).execute()
|
| 117 |
+
return {
|
| 118 |
+
**payload,
|
| 119 |
+
"validation_counts": {
|
| 120 |
+
severity: sum(1 for row in validations if row.get("severity") == severity)
|
| 121 |
+
for severity in {"PASS", "WARNING", "CRITICAL_FAIL"}
|
| 122 |
+
},
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
if len(sys.argv) != 2:
|
| 128 |
+
raise SystemExit("Usage: python /app/src/scripts/revalidate_batch.py <batch_id>")
|
| 129 |
+
print(asyncio.run(revalidate_batch(sys.argv[1])))
|
src/services/ingest_svc.py
CHANGED
|
@@ -28,8 +28,8 @@ class StorageService:
|
|
| 28 |
"s3",
|
| 29 |
endpoint_url=f"http://{settings.MINIO_ENDPOINT}",
|
| 30 |
aws_access_key_id=settings.MINIO_ACCESS_KEY,
|
| 31 |
-
aws_secret_access_key=settings.MINIO_SECRET_KEY,
|
| 32 |
-
config=Config(signature_version="s3v4"),
|
| 33 |
region_name="us-east-1",
|
| 34 |
)
|
| 35 |
self._ensure_minio_bucket()
|
|
|
|
| 28 |
"s3",
|
| 29 |
endpoint_url=f"http://{settings.MINIO_ENDPOINT}",
|
| 30 |
aws_access_key_id=settings.MINIO_ACCESS_KEY,
|
| 31 |
+
aws_secret_access_key=settings.MINIO_SECRET_KEY.get_secret_value() if hasattr(settings.MINIO_SECRET_KEY, 'get_secret_value') else settings.MINIO_SECRET_KEY,
|
| 32 |
+
config=Config(signature_version="s3v4", s3={'addressing_style': 'path'}),
|
| 33 |
region_name="us-east-1",
|
| 34 |
)
|
| 35 |
self._ensure_minio_bucket()
|
src/services/ocr_engine_svc.py
CHANGED
|
@@ -56,8 +56,16 @@ CEISA_FIELD_PATTERNS = {
|
|
| 56 |
|
| 57 |
def _as_float(value: str) -> float | None:
|
| 58 |
try:
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return None
|
| 62 |
|
| 63 |
|
|
@@ -106,59 +114,62 @@ class OCREngineService:
|
|
| 106 |
started = time.perf_counter()
|
| 107 |
suffix = Path(filename or storage_path).suffix.lower()
|
| 108 |
mime_type = mimetypes.guess_type(filename or storage_path)[0] or "application/octet-stream"
|
|
|
|
| 109 |
|
| 110 |
-
page_images = await asyncio.to_thread(self._render_page_images, file_bytes, suffix)
|
| 111 |
raw_text = ""
|
| 112 |
candidates: dict[str, dict[str, Any]] = {}
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
if settings.CLOUD_LLM_ONLY:
|
| 115 |
-
if suffix == ".pdf" or mime_type == "application/pdf":
|
| 116 |
-
direct_candidate = await asyncio.to_thread(self._extract_pdf_text, file_bytes)
|
| 117 |
-
raw_text = direct_candidate.get("text", "")
|
| 118 |
-
if direct_candidate.get("fields") or direct_candidate.get("text"):
|
| 119 |
-
candidates["pdf_text"] = direct_candidate
|
| 120 |
-
|
| 121 |
-
if settings.ENABLE_AZURE_DI_AGENT:
|
| 122 |
-
azure_candidate = await self._run_azure(file_bytes, mime_type)
|
| 123 |
-
if azure_candidate.get("fields") or azure_candidate.get("text"):
|
| 124 |
-
candidates["azure-di"] = azure_candidate
|
| 125 |
-
raw_text = "\n".join(
|
| 126 |
-
part for part in [raw_text, azure_candidate.get("text", "")] if part
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
quality_score = self._estimate_quality(page_images) if page_images else 0.0
|
| 130 |
page_data_urls = [_data_url(b) for b in page_images[: settings.OCR_MAX_LLM_PAGES]]
|
| 131 |
return {
|
| 132 |
"pages": page_data_urls,
|
| 133 |
-
"raw_text":
|
| 134 |
-
"ocr_candidates":
|
| 135 |
-
"quality_score":
|
| 136 |
-
"ocr_engine_latencies_ms": {
|
| 137 |
-
name: candidate.get("latency_ms") for name, candidate in candidates.items()
|
| 138 |
-
},
|
| 139 |
}
|
| 140 |
|
| 141 |
-
pdf_task = None
|
| 142 |
-
if suffix == ".pdf" or mime_type == "application/pdf":
|
| 143 |
-
pdf_task = asyncio.to_thread(self._extract_pdf_text, file_bytes)
|
| 144 |
-
|
| 145 |
paddle_task = self._run_paddle(page_images)
|
| 146 |
-
|
|
|
|
| 147 |
|
| 148 |
gather_tasks = [paddle_task]
|
|
|
|
|
|
|
| 149 |
if azure_task is not None:
|
| 150 |
gather_tasks.append(azure_task)
|
| 151 |
-
if pdf_task is not None:
|
| 152 |
-
gather_tasks.insert(0, pdf_task)
|
| 153 |
-
|
| 154 |
results = await asyncio.gather(*gather_tasks)
|
| 155 |
idx = 0
|
| 156 |
-
if pdf_task is not None:
|
| 157 |
-
direct_candidate = results[idx]
|
| 158 |
-
idx += 1
|
| 159 |
-
raw_text = direct_candidate.get("text", "")
|
| 160 |
-
if direct_candidate.get("fields"):
|
| 161 |
-
candidates["pdf_text"] = direct_candidate
|
| 162 |
|
| 163 |
paddle_candidate = results[idx]
|
| 164 |
idx += 1
|
|
@@ -168,6 +179,15 @@ class OCREngineService:
|
|
| 168 |
part for part in [raw_text, paddle_candidate.get("text", "")] if part
|
| 169 |
)
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
if azure_task is not None:
|
| 172 |
azure_candidate = results[idx]
|
| 173 |
if azure_candidate.get("fields") or azure_candidate.get("text"):
|
|
@@ -196,12 +216,50 @@ class OCREngineService:
|
|
| 196 |
"raw_text": raw_text,
|
| 197 |
"ocr_candidates": candidates,
|
| 198 |
"quality_score": quality_score,
|
|
|
|
| 199 |
"ocr_engine_latencies_ms": {
|
| 200 |
name: candidate.get("latency_ms") for name, candidate in candidates.items()
|
| 201 |
},
|
| 202 |
}
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
def _render_page_images(self, file_bytes: bytes, suffix: str) -> list[bytes]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
if suffix == ".pdf":
|
| 206 |
try:
|
| 207 |
import fitz
|
|
@@ -227,14 +285,21 @@ class OCREngineService:
|
|
| 227 |
import pdfplumber
|
| 228 |
|
| 229 |
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
| 230 |
-
|
|
|
|
| 231 |
fields = extract_ceisa_fields_from_text(text)
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
| 233 |
return {
|
| 234 |
"fields": fields,
|
| 235 |
"text": text,
|
| 236 |
"confidence": confidence,
|
| 237 |
"overall_confidence": confidence,
|
|
|
|
|
|
|
|
|
|
| 238 |
"latency_ms": round((time.perf_counter() - started) * 1000, 2),
|
| 239 |
}
|
| 240 |
except Exception as exc:
|
|
@@ -249,36 +314,93 @@ class OCREngineService:
|
|
| 249 |
def _run_paddle_sync(self, page_images: list[bytes]) -> dict[str, Any]:
|
| 250 |
started = time.perf_counter()
|
| 251 |
try:
|
| 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 |
text = "\n".join(lines)
|
| 281 |
confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
|
|
|
| 282 |
return {
|
| 283 |
"fields": extract_ceisa_fields_from_text(text),
|
| 284 |
"text": text,
|
|
@@ -287,11 +409,12 @@ class OCREngineService:
|
|
| 287 |
"latency_ms": round((time.perf_counter() - started) * 1000, 2),
|
| 288 |
}
|
| 289 |
except Exception as exc:
|
| 290 |
-
log.warning("
|
| 291 |
return {"fields": {}, "text": "", "confidence": 0.0, "error": str(exc)}
|
| 292 |
|
|
|
|
| 293 |
async def _run_azure(self, file_bytes: bytes, mime_type: str) -> dict[str, Any]:
|
| 294 |
-
if not
|
| 295 |
return {"fields": {}, "text": "", "confidence": 0.0}
|
| 296 |
if not settings.AZURE_DI_ENDPOINT or not settings.AZURE_DI_KEY:
|
| 297 |
log.warning("Azure DI not configured; dual OCR is degraded to PaddleOCR only")
|
|
@@ -350,9 +473,6 @@ class OCREngineService:
|
|
| 350 |
log.warning("Azure DI failed", error=str(exc))
|
| 351 |
return {"fields": {}, "text": "", "confidence": 0.0, "error": str(exc)}
|
| 352 |
|
| 353 |
-
def _azure_enabled(self) -> bool:
|
| 354 |
-
return bool(settings.ENABLE_AZURE_DI_AGENT and settings.ENABLE_DUAL_OCR)
|
| 355 |
-
|
| 356 |
def _estimate_quality(self, page_images: list[bytes]) -> float:
|
| 357 |
if not page_images:
|
| 358 |
return 0.0
|
|
|
|
| 56 |
|
| 57 |
def _as_float(value: str) -> float | None:
|
| 58 |
try:
|
| 59 |
+
# Handle European format: "11,603.000" (comma = thousands separator)
|
| 60 |
+
# Detect if comma is used as thousands: e.g., "11,603.000" has comma before 3+ digits before decimal
|
| 61 |
+
cleaned = value.strip()
|
| 62 |
+
if re.search(r"\d,\d{3}(\.|$)", cleaned):
|
| 63 |
+
cleaned = cleaned.replace(",", "")
|
| 64 |
+
else:
|
| 65 |
+
# Could be decimal comma: "11.603,000" -> 11603.0
|
| 66 |
+
cleaned = cleaned.replace(".", "").replace(",", ".")
|
| 67 |
+
return float(cleaned)
|
| 68 |
+
except (ValueError, AttributeError):
|
| 69 |
return None
|
| 70 |
|
| 71 |
|
|
|
|
| 114 |
started = time.perf_counter()
|
| 115 |
suffix = Path(filename or storage_path).suffix.lower()
|
| 116 |
mime_type = mimetypes.guess_type(filename or storage_path)[0] or "application/octet-stream"
|
| 117 |
+
is_pdf = suffix == ".pdf" or mime_type == "application/pdf"
|
| 118 |
|
|
|
|
| 119 |
raw_text = ""
|
| 120 |
candidates: dict[str, dict[str, Any]] = {}
|
| 121 |
|
| 122 |
+
if is_pdf:
|
| 123 |
+
direct_candidate = await asyncio.to_thread(self._extract_pdf_text, file_bytes)
|
| 124 |
+
raw_text = direct_candidate.get("text", "")
|
| 125 |
+
if direct_candidate.get("fields") or raw_text.strip():
|
| 126 |
+
candidates["pdf_text"] = direct_candidate
|
| 127 |
+
|
| 128 |
+
if self._is_pdf_text_fast_path(direct_candidate):
|
| 129 |
+
latency_ms = round((time.perf_counter() - started) * 1000, 2)
|
| 130 |
+
log.info(
|
| 131 |
+
"Digital PDF fast path selected",
|
| 132 |
+
doc_id=doc_id,
|
| 133 |
+
storage_path=storage_path,
|
| 134 |
+
text_chars=direct_candidate.get("text_chars"),
|
| 135 |
+
text_chars_per_page=direct_candidate.get("text_chars_per_page"),
|
| 136 |
+
fields=list((direct_candidate.get("fields") or {}).keys()),
|
| 137 |
+
latency_ms=latency_ms,
|
| 138 |
+
)
|
| 139 |
+
return {
|
| 140 |
+
"pages": [],
|
| 141 |
+
"raw_text": raw_text,
|
| 142 |
+
"ocr_candidates": candidates,
|
| 143 |
+
"quality_score": float(direct_candidate.get("confidence") or 1.0),
|
| 144 |
+
"document_mode": "digital_pdf_text",
|
| 145 |
+
"ocr_engine_latencies_ms": {
|
| 146 |
+
name: candidate.get("latency_ms") for name, candidate in candidates.items()
|
| 147 |
+
},
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
page_images = await asyncio.to_thread(self._render_page_images, file_bytes, suffix)
|
| 151 |
+
|
| 152 |
if settings.CLOUD_LLM_ONLY:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
page_data_urls = [_data_url(b) for b in page_images[: settings.OCR_MAX_LLM_PAGES]]
|
| 154 |
return {
|
| 155 |
"pages": page_data_urls,
|
| 156 |
+
"raw_text": "",
|
| 157 |
+
"ocr_candidates": {},
|
| 158 |
+
"quality_score": 1.0,
|
| 159 |
+
"ocr_engine_latencies_ms": {}
|
|
|
|
|
|
|
| 160 |
}
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
paddle_task = self._run_paddle(page_images)
|
| 163 |
+
surya_task = self._run_surya(page_images) if settings.ENABLE_SURYA_AGENT else None
|
| 164 |
+
azure_task = self._run_azure(file_bytes, mime_type) if settings.ENABLE_DUAL_OCR else None
|
| 165 |
|
| 166 |
gather_tasks = [paddle_task]
|
| 167 |
+
if surya_task is not None:
|
| 168 |
+
gather_tasks.append(surya_task)
|
| 169 |
if azure_task is not None:
|
| 170 |
gather_tasks.append(azure_task)
|
|
|
|
|
|
|
|
|
|
| 171 |
results = await asyncio.gather(*gather_tasks)
|
| 172 |
idx = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
paddle_candidate = results[idx]
|
| 175 |
idx += 1
|
|
|
|
| 179 |
part for part in [raw_text, paddle_candidate.get("text", "")] if part
|
| 180 |
)
|
| 181 |
|
| 182 |
+
if surya_task is not None:
|
| 183 |
+
surya_candidate = results[idx]
|
| 184 |
+
idx += 1
|
| 185 |
+
if surya_candidate.get("fields") or surya_candidate.get("text"):
|
| 186 |
+
candidates["surya"] = surya_candidate
|
| 187 |
+
raw_text = "\n".join(
|
| 188 |
+
part for part in [raw_text, surya_candidate.get("text", "")] if part
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
if azure_task is not None:
|
| 192 |
azure_candidate = results[idx]
|
| 193 |
if azure_candidate.get("fields") or azure_candidate.get("text"):
|
|
|
|
| 216 |
"raw_text": raw_text,
|
| 217 |
"ocr_candidates": candidates,
|
| 218 |
"quality_score": quality_score,
|
| 219 |
+
"document_mode": "rendered_ocr",
|
| 220 |
"ocr_engine_latencies_ms": {
|
| 221 |
name: candidate.get("latency_ms") for name, candidate in candidates.items()
|
| 222 |
},
|
| 223 |
}
|
| 224 |
|
| 225 |
+
def _is_pdf_text_fast_path(self, candidate: dict[str, Any]) -> bool:
|
| 226 |
+
"""Use direct PDF text when the text layer is dense enough to avoid heavy OCR."""
|
| 227 |
+
text_chars = int(candidate.get("text_chars") or 0)
|
| 228 |
+
page_count = max(1, int(candidate.get("page_count") or 1))
|
| 229 |
+
text_chars_per_page = text_chars / page_count
|
| 230 |
+
confidence = float(candidate.get("confidence") or 0.0)
|
| 231 |
+
return (
|
| 232 |
+
confidence >= settings.OCR_FAST_PATH_QUALITY_THRESHOLD
|
| 233 |
+
and text_chars >= settings.OCR_PDF_TEXT_MIN_CHARS
|
| 234 |
+
and text_chars_per_page >= settings.OCR_PDF_TEXT_MIN_CHARS_PER_PAGE
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
def _render_page_images(self, file_bytes: bytes, suffix: str) -> list[bytes]:
|
| 238 |
+
"""Call MinerU microservice for preprocessing and rendering."""
|
| 239 |
+
try:
|
| 240 |
+
import httpx
|
| 241 |
+
b64_content = base64.b64encode(file_bytes).decode("ascii")
|
| 242 |
+
payload = {
|
| 243 |
+
"document_id": "temp",
|
| 244 |
+
"doc_type": "invoice",
|
| 245 |
+
"content_b64": b64_content,
|
| 246 |
+
"filename": f"temp{suffix}"
|
| 247 |
+
}
|
| 248 |
+
url = f"{str(settings.MINERU_SVC_URL).rstrip('/')}/preprocess"
|
| 249 |
+
with httpx.Client(timeout=30.0) as client:
|
| 250 |
+
response = client.post(url, json=payload)
|
| 251 |
+
response.raise_for_status()
|
| 252 |
+
data = response.json()
|
| 253 |
+
|
| 254 |
+
images = []
|
| 255 |
+
for page in data.get("pages", []):
|
| 256 |
+
images.append(base64.b64decode(page["image_b64"]))
|
| 257 |
+
if images:
|
| 258 |
+
return images
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
log.warning("MinerU preprocessing failed, falling back to local PyMuPDF", error=str(exc))
|
| 261 |
+
|
| 262 |
+
# Fallback to local rendering
|
| 263 |
if suffix == ".pdf":
|
| 264 |
try:
|
| 265 |
import fitz
|
|
|
|
| 285 |
import pdfplumber
|
| 286 |
|
| 287 |
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
| 288 |
+
pages = pdf.pages
|
| 289 |
+
text = "\n".join((page.extract_text() or "") for page in pages)
|
| 290 |
fields = extract_ceisa_fields_from_text(text)
|
| 291 |
+
text_chars = len(re.sub(r"\s+", "", text))
|
| 292 |
+
page_count = len(pages) or 1
|
| 293 |
+
text_chars_per_page = round(text_chars / page_count, 2)
|
| 294 |
+
confidence = 1.0 if text_chars_per_page >= settings.OCR_PDF_TEXT_MIN_CHARS_PER_PAGE else 0.0
|
| 295 |
return {
|
| 296 |
"fields": fields,
|
| 297 |
"text": text,
|
| 298 |
"confidence": confidence,
|
| 299 |
"overall_confidence": confidence,
|
| 300 |
+
"page_count": page_count,
|
| 301 |
+
"text_chars": text_chars,
|
| 302 |
+
"text_chars_per_page": text_chars_per_page,
|
| 303 |
"latency_ms": round((time.perf_counter() - started) * 1000, 2),
|
| 304 |
}
|
| 305 |
except Exception as exc:
|
|
|
|
| 314 |
def _run_paddle_sync(self, page_images: list[bytes]) -> dict[str, Any]:
|
| 315 |
started = time.perf_counter()
|
| 316 |
try:
|
| 317 |
+
import httpx
|
| 318 |
+
images_b64 = [base64.b64encode(img).decode("ascii") for img in page_images]
|
| 319 |
+
url = f"{str(settings.PADDLEOCR_SVC_URL).rstrip('/')}/extract"
|
| 320 |
+
|
| 321 |
+
lines = []
|
| 322 |
+
confidences = []
|
| 323 |
+
|
| 324 |
+
with httpx.Client(timeout=300.0) as client:
|
| 325 |
+
for img_b64 in images_b64:
|
| 326 |
+
payload = {
|
| 327 |
+
"image_b64": img_b64,
|
| 328 |
+
"doc_type": "bill_of_lading"
|
| 329 |
+
}
|
| 330 |
+
response = client.post(url, json=payload)
|
| 331 |
+
response.raise_for_status()
|
| 332 |
+
result = response.json()
|
| 333 |
+
|
| 334 |
+
for text_block in result.get("text_blocks_with_bbox", []):
|
| 335 |
+
text = text_block.get("text", "")
|
| 336 |
+
conf = text_block.get("confidence", 0.0)
|
| 337 |
+
if text.strip():
|
| 338 |
+
lines.append(text)
|
| 339 |
+
confidences.append(conf)
|
| 340 |
+
|
| 341 |
+
text = "\n".join(lines)
|
| 342 |
+
confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
| 343 |
+
|
| 344 |
+
return {
|
| 345 |
+
"fields": extract_ceisa_fields_from_text(text),
|
| 346 |
+
"text": text,
|
| 347 |
+
"confidence": confidence,
|
| 348 |
+
"overall_confidence": confidence,
|
| 349 |
+
"latency_ms": round((time.perf_counter() - started) * 1000, 2),
|
| 350 |
+
}
|
| 351 |
+
except Exception as exc:
|
| 352 |
+
log.warning("PaddleOCR HTTP failed", error=str(exc))
|
| 353 |
+
return {"fields": {}, "text": "", "confidence": 0.0, "error": str(exc)}
|
| 354 |
+
|
| 355 |
+
async def _run_surya(self, page_images: list[bytes]) -> dict[str, Any]:
|
| 356 |
+
if not page_images:
|
| 357 |
+
return {"fields": {}, "text": "", "confidence": 0.0}
|
| 358 |
+
return await asyncio.to_thread(self._run_surya_sync, page_images)
|
| 359 |
+
|
| 360 |
+
def _run_surya_sync(self, page_images: list[bytes]) -> dict[str, Any]:
|
| 361 |
+
started = time.perf_counter()
|
| 362 |
+
try:
|
| 363 |
+
import httpx
|
| 364 |
+
images_b64 = [base64.b64encode(img).decode("ascii") for img in page_images]
|
| 365 |
+
payload = {
|
| 366 |
+
"images_b64": images_b64,
|
| 367 |
+
"languages": ["en", "id"]
|
| 368 |
+
}
|
| 369 |
+
url = f"{str(settings.SURYA_INFERENCE_URL).rstrip('/')}/extract"
|
| 370 |
+
with httpx.Client(timeout=600.0) as client:
|
| 371 |
+
response = client.post(url, json=payload)
|
| 372 |
+
response.raise_for_status()
|
| 373 |
+
result = response.json()
|
| 374 |
+
|
| 375 |
+
# Surya v2 response: text_blocks is list[list[dict]] (per page, per block)
|
| 376 |
+
# Each block has: { text, html, confidence, bbox, polygon, label }
|
| 377 |
+
lines = []
|
| 378 |
+
confidences = []
|
| 379 |
+
text_blocks = result.get("text_blocks", [])
|
| 380 |
+
for page_blocks in text_blocks:
|
| 381 |
+
if isinstance(page_blocks, list):
|
| 382 |
+
for block in page_blocks:
|
| 383 |
+
text = block.get("text", "").strip()
|
| 384 |
+
conf = float(block.get("confidence", 1.0))
|
| 385 |
+
if text:
|
| 386 |
+
lines.append(text)
|
| 387 |
+
confidences.append(conf)
|
| 388 |
+
elif isinstance(page_blocks, dict):
|
| 389 |
+
# Fallback: old format where text_blocks is flat list of dicts
|
| 390 |
+
text = page_blocks.get("text", "").strip()
|
| 391 |
+
conf = float(page_blocks.get("confidence", 1.0))
|
| 392 |
+
if text:
|
| 393 |
+
lines.append(text)
|
| 394 |
+
confidences.append(conf)
|
| 395 |
+
|
| 396 |
+
# Also handle legacy flat "text" field
|
| 397 |
+
if not lines and result.get("text"):
|
| 398 |
+
lines = [result["text"]]
|
| 399 |
+
confidences = [result.get("confidence", result.get("overall_confidence", 0.0))]
|
| 400 |
|
| 401 |
text = "\n".join(lines)
|
| 402 |
confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
| 403 |
+
|
| 404 |
return {
|
| 405 |
"fields": extract_ceisa_fields_from_text(text),
|
| 406 |
"text": text,
|
|
|
|
| 409 |
"latency_ms": round((time.perf_counter() - started) * 1000, 2),
|
| 410 |
}
|
| 411 |
except Exception as exc:
|
| 412 |
+
log.warning("Surya OCR HTTP failed", error=str(exc))
|
| 413 |
return {"fields": {}, "text": "", "confidence": 0.0, "error": str(exc)}
|
| 414 |
|
| 415 |
+
|
| 416 |
async def _run_azure(self, file_bytes: bytes, mime_type: str) -> dict[str, Any]:
|
| 417 |
+
if not settings.ENABLE_DUAL_OCR:
|
| 418 |
return {"fields": {}, "text": "", "confidence": 0.0}
|
| 419 |
if not settings.AZURE_DI_ENDPOINT or not settings.AZURE_DI_KEY:
|
| 420 |
log.warning("Azure DI not configured; dual OCR is degraded to PaddleOCR only")
|
|
|
|
| 473 |
log.warning("Azure DI failed", error=str(exc))
|
| 474 |
return {"fields": {}, "text": "", "confidence": 0.0, "error": str(exc)}
|
| 475 |
|
|
|
|
|
|
|
|
|
|
| 476 |
def _estimate_quality(self, page_images: list[bytes]) -> float:
|
| 477 |
if not page_images:
|
| 478 |
return 0.0
|
src/services/validation_rules_svc.py
CHANGED
|
@@ -215,23 +215,173 @@ class ValidationRulesService:
|
|
| 215 |
evaluator: SafeRuleEvaluator,
|
| 216 |
context: dict[str, Any],
|
| 217 |
) -> dict[str, Any]:
|
|
|
|
|
|
|
| 218 |
try:
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
severity = "PASS" if passed else self._failure_severity(rule.get("severity"))
|
| 221 |
-
message =
|
| 222 |
if not passed:
|
| 223 |
message = self._format_message(rule.get("error_message") or message, context)
|
| 224 |
except Exception as exc:
|
| 225 |
severity = self._failure_severity(rule.get("severity"))
|
| 226 |
-
message = f"{
|
| 227 |
|
| 228 |
return {
|
| 229 |
-
"rule_id":
|
| 230 |
-
"rule_name":
|
| 231 |
"severity": severity,
|
| 232 |
"message": message,
|
| 233 |
-
"affected_fields": rule.get("affected_fields", []),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
def _build_context(self, state: dict[str, Any]) -> dict[str, Any]:
|
| 237 |
combined = state.get("combined_data") or {}
|
|
@@ -286,16 +436,26 @@ class ValidationRulesService:
|
|
| 286 |
return template
|
| 287 |
|
| 288 |
def _failure_severity(self, severity: str | None) -> str:
|
| 289 |
-
return "CRITICAL_FAIL" if severity
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
|
| 291 |
def _resolve_rules_path(self) -> Path:
|
| 292 |
configured = Path(settings.VALIDATION_RULES_PATH)
|
| 293 |
candidates = [
|
| 294 |
configured,
|
| 295 |
Path.cwd() / configured,
|
| 296 |
-
Path(__file__).resolve().parents[4] / configured,
|
| 297 |
Path("/app/validation_rules.json"),
|
| 298 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
for candidate in candidates:
|
| 300 |
if candidate.exists():
|
| 301 |
return candidate.resolve()
|
|
|
|
| 215 |
evaluator: SafeRuleEvaluator,
|
| 216 |
context: dict[str, Any],
|
| 217 |
) -> dict[str, Any]:
|
| 218 |
+
rule_id = rule.get("rule_id") or rule.get("id", "UNKNOWN")
|
| 219 |
+
rule_name = rule.get("name", rule_id)
|
| 220 |
try:
|
| 221 |
+
check_expr = rule.get("check")
|
| 222 |
+
if not check_expr:
|
| 223 |
+
legacy_result = self._evaluate_legacy_rule(rule, context)
|
| 224 |
+
if legacy_result is not None:
|
| 225 |
+
return legacy_result
|
| 226 |
+
# Rule has no evaluable expression — skip as PASS
|
| 227 |
+
return {
|
| 228 |
+
"rule_id": rule_id,
|
| 229 |
+
"rule_name": rule_name,
|
| 230 |
+
"severity": "PASS",
|
| 231 |
+
"message": rule_name,
|
| 232 |
+
"affected_fields": rule.get("affected_fields") or rule.get("fields", []),
|
| 233 |
+
}
|
| 234 |
+
passed = evaluator.evaluate(check_expr)
|
| 235 |
severity = "PASS" if passed else self._failure_severity(rule.get("severity"))
|
| 236 |
+
message = rule_name
|
| 237 |
if not passed:
|
| 238 |
message = self._format_message(rule.get("error_message") or message, context)
|
| 239 |
except Exception as exc:
|
| 240 |
severity = self._failure_severity(rule.get("severity"))
|
| 241 |
+
message = f"{rule_name} could not be evaluated: {exc}"
|
| 242 |
|
| 243 |
return {
|
| 244 |
+
"rule_id": rule_id,
|
| 245 |
+
"rule_name": rule_name,
|
| 246 |
"severity": severity,
|
| 247 |
"message": message,
|
| 248 |
+
"affected_fields": rule.get("affected_fields") or rule.get("fields", []),
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
def _evaluate_legacy_rule(
|
| 252 |
+
self,
|
| 253 |
+
rule: dict[str, Any],
|
| 254 |
+
context: dict[str, Any],
|
| 255 |
+
) -> dict[str, Any] | None:
|
| 256 |
+
rule_type = rule.get("type")
|
| 257 |
+
if not rule_type:
|
| 258 |
+
return None
|
| 259 |
+
|
| 260 |
+
rule_id = rule.get("rule_id") or rule.get("id", "UNKNOWN")
|
| 261 |
+
rule_name = rule.get("name", rule_id)
|
| 262 |
+
fields = rule.get("fields") or ([rule["field"]] if rule.get("field") else [])
|
| 263 |
+
|
| 264 |
+
try:
|
| 265 |
+
if rule_type in {"regex", "regex_and_lookup"}:
|
| 266 |
+
field = fields[0] if fields else rule.get("field")
|
| 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":
|
| 276 |
+
passed = True
|
| 277 |
+
elif rule_type == "lookup":
|
| 278 |
+
passed = self._evaluate_lookup_rule(rule, context)
|
| 279 |
+
else:
|
| 280 |
+
return None
|
| 281 |
+
except Exception as exc:
|
| 282 |
+
passed = False
|
| 283 |
+
log.warning("Legacy validation rule failed to evaluate", rule_id=rule_id, error=str(exc))
|
| 284 |
+
|
| 285 |
+
severity = "PASS" if passed else self._legacy_failure_severity(rule_id, rule.get("severity"))
|
| 286 |
+
return {
|
| 287 |
+
"rule_id": rule_id,
|
| 288 |
+
"rule_name": rule_name,
|
| 289 |
+
"severity": severity,
|
| 290 |
+
"message": rule_name if passed else rule.get("description") or rule_name,
|
| 291 |
+
"affected_fields": rule.get("affected_fields") or fields,
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
def _field_aliases(self, field: str | None) -> list[str]:
|
| 295 |
+
aliases = {
|
| 296 |
+
"nomorBl": ["bl_number", "nomorBl"],
|
| 297 |
+
"beratKotor": ["gross_weight", "beratKotor"],
|
| 298 |
+
"jumlahKemasan": ["total_packages", "jumlahKemasan"],
|
| 299 |
+
"namaKapal": ["vessel_name", "namaKapal"],
|
| 300 |
+
"voyageNumber": ["voyage_number", "voyageNumber"],
|
| 301 |
+
"kodePelabuhanMuat": ["port_of_loading", "kodePelabuhanMuat"],
|
| 302 |
+
"kodePelabuhanBongkar": ["port_of_discharge", "kodePelabuhanBongkar"],
|
| 303 |
+
"hs_code": ["hs_code", "posTarif"],
|
| 304 |
+
"nib": ["importer_nib", "nib", "nibEntitas"],
|
| 305 |
+
"npwp": ["importer_npwp", "npwp", "nomorIdentitas"],
|
| 306 |
+
"container_number": ["container_numbers", "container_number"],
|
| 307 |
}
|
| 308 |
+
if not field:
|
| 309 |
+
return []
|
| 310 |
+
return aliases.get(field, [field])
|
| 311 |
+
|
| 312 |
+
def _scope_value(self, scope: Any, field: str | None) -> Any:
|
| 313 |
+
for alias in self._field_aliases(field):
|
| 314 |
+
value = _unwrap(getattr(scope, alias, MISSING))
|
| 315 |
+
if value not in (None, "", MISSING):
|
| 316 |
+
return value
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
def _first_context_value(self, context: dict[str, Any], field: str | None) -> Any:
|
| 320 |
+
for scope_name in ("data", "inv", "pl", "bl", "item", "importir"):
|
| 321 |
+
value = self._scope_value(context[scope_name], field)
|
| 322 |
+
if value not in (None, "", MISSING):
|
| 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
|
| 339 |
+
if isinstance(value, (int, float)):
|
| 340 |
+
return str(round(float(value), 4))
|
| 341 |
+
return re.sub(r"\s+", " ", str(value)).strip().upper()
|
| 342 |
+
|
| 343 |
+
def _evaluate_cross_document_rule(self, rule: dict[str, Any], context: dict[str, Any]) -> bool:
|
| 344 |
+
rule_id = rule.get("rule_id") or rule.get("id")
|
| 345 |
+
tolerance_pct = float(rule.get("tolerance_pct") or 0)
|
| 346 |
+
if rule_id == "CV002" or not rule.get("fields"):
|
| 347 |
+
cif = self._as_float(self._scope_value(context["inv"], "cif_value"))
|
| 348 |
+
fob = self._as_float(self._scope_value(context["inv"], "fob_value"))
|
| 349 |
+
freight = self._as_float(self._scope_value(context["inv"], "freight_value"))
|
| 350 |
+
insurance = self._as_float(self._scope_value(context["inv"], "insurance_value"))
|
| 351 |
+
if None in (cif, fob, freight, insurance) or not cif:
|
| 352 |
+
return False
|
| 353 |
+
diff_pct = abs(cif - (fob + freight + insurance)) / cif * 100
|
| 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)
|
| 367 |
+
if any(abs(value - baseline) / abs(baseline) * 100 > tolerance_pct for value in present[1:]):
|
| 368 |
+
return False
|
| 369 |
+
return True
|
| 370 |
+
|
| 371 |
+
def _evaluate_lookup_rule(self, rule: dict[str, Any], context: dict[str, Any]) -> bool:
|
| 372 |
+
for field in rule.get("fields") or []:
|
| 373 |
+
value = str(self._first_context_value(context, field) or "")
|
| 374 |
+
if field in {"kodePelabuhanMuat", "kodePelabuhanBongkar"} and not re.search(r"\b[A-Z]{5}\b", value):
|
| 375 |
+
return False
|
| 376 |
+
return True
|
| 377 |
+
|
| 378 |
+
def _as_float(self, value: Any) -> float | None:
|
| 379 |
+
if value in (None, "", MISSING):
|
| 380 |
+
return None
|
| 381 |
+
try:
|
| 382 |
+
return float(str(value).replace(",", ""))
|
| 383 |
+
except (TypeError, ValueError):
|
| 384 |
+
return None
|
| 385 |
|
| 386 |
def _build_context(self, state: dict[str, Any]) -> dict[str, Any]:
|
| 387 |
combined = state.get("combined_data") or {}
|
|
|
|
| 436 |
return template
|
| 437 |
|
| 438 |
def _failure_severity(self, severity: str | None) -> str:
|
| 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 = [
|
| 449 |
configured,
|
| 450 |
Path.cwd() / configured,
|
|
|
|
| 451 |
Path("/app/validation_rules.json"),
|
| 452 |
]
|
| 453 |
+
|
| 454 |
+
try:
|
| 455 |
+
candidates.append(Path(__file__).resolve().parents[4] / configured)
|
| 456 |
+
except IndexError:
|
| 457 |
+
pass
|
| 458 |
+
|
| 459 |
for candidate in candidates:
|
| 460 |
if candidate.exists():
|
| 461 |
return candidate.resolve()
|
src/tasks/ocr_tasks.py
CHANGED
|
@@ -98,31 +98,49 @@ async def _persist_graph_result(batch_id: str, result: dict) -> None:
|
|
| 98 |
await supabase.table("batches").update({
|
| 99 |
"status": status,
|
| 100 |
"risk_level": result.get("risk_level"),
|
| 101 |
-
"customs_readiness_score": result.get("_crs_score"),
|
| 102 |
-
"crs_grade": result.get("_crs_grade"),
|
| 103 |
-
"rejection_probability": result.get("_rejection_prob"),
|
| 104 |
"langgraph_thread_id": batch_id,
|
| 105 |
}).eq("id", batch_id).execute()
|
| 106 |
finally:
|
| 107 |
pass
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def _average_confidence(confidences: dict) -> float:
|
| 111 |
values = [float(value) for value in confidences.values()]
|
| 112 |
return round(sum(values) / len(values), 4) if values else 0.0
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
"""Entry point: kicks off LangGraph extraction pipeline."""
|
| 118 |
-
import asyncio
|
| 119 |
-
|
| 120 |
from ..ai.graph import extraction_graph
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
try:
|
| 123 |
-
from .celery_app import get_worker_loop
|
| 124 |
-
loop = get_worker_loop()
|
| 125 |
config = {"configurable": {"thread_id": batch_id}}
|
|
|
|
| 126 |
context = loop.run_until_complete(_load_batch_context(batch_id))
|
| 127 |
initial_state = {
|
| 128 |
"batch_id": batch_id,
|
|
@@ -132,18 +150,38 @@ def preprocess_document(self, batch_id: str) -> None:
|
|
| 132 |
"validation_results": [],
|
| 133 |
"needs_human_review": False,
|
| 134 |
"risk_level": "UNKNOWN",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
"ocr_conflicts": [],
|
| 136 |
"field_confidences": {},
|
| 137 |
"steps": [],
|
| 138 |
}
|
| 139 |
-
# Run sync wrapper around async graph
|
| 140 |
result = loop.run_until_complete(
|
| 141 |
extraction_graph.ainvoke(initial_state, config=config)
|
| 142 |
)
|
| 143 |
loop.run_until_complete(_persist_graph_result(batch_id, result))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
log.info("Extraction pipeline complete", batch_id=batch_id)
|
| 145 |
except Exception as exc:
|
| 146 |
log.error("Pipeline failed", batch_id=batch_id, error=str(exc))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
raise self.retry(exc=exc)
|
| 148 |
|
| 149 |
|
|
|
|
| 98 |
await supabase.table("batches").update({
|
| 99 |
"status": status,
|
| 100 |
"risk_level": result.get("risk_level"),
|
| 101 |
+
"customs_readiness_score": result.get("customs_readiness_score", result.get("_crs_score")),
|
| 102 |
+
"crs_grade": result.get("crs_grade", result.get("_crs_grade")),
|
| 103 |
+
"rejection_probability": result.get("rejection_probability", result.get("_rejection_prob")),
|
| 104 |
"langgraph_thread_id": batch_id,
|
| 105 |
}).eq("id", batch_id).execute()
|
| 106 |
finally:
|
| 107 |
pass
|
| 108 |
|
| 109 |
|
| 110 |
+
async def _update_batch_status(batch_id: str, status: str, error_message: str | None = None) -> None:
|
| 111 |
+
"""Best-effort status update for the upload/detail UI."""
|
| 112 |
+
from supabase import acreate_client
|
| 113 |
+
|
| 114 |
+
from ..config import settings
|
| 115 |
+
|
| 116 |
+
supabase = await acreate_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_KEY.get_secret_value())
|
| 117 |
+
payload = {"status": status}
|
| 118 |
+
try:
|
| 119 |
+
await supabase.table("batches").update(payload).eq("id", batch_id).execute()
|
| 120 |
+
except Exception as exc:
|
| 121 |
+
log.warning(
|
| 122 |
+
"Failed to update batch status",
|
| 123 |
+
batch_id=batch_id,
|
| 124 |
+
status=status,
|
| 125 |
+
pipeline_error=error_message,
|
| 126 |
+
error=str(exc),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
def _average_confidence(confidences: dict) -> float:
|
| 131 |
values = [float(value) for value in confidences.values()]
|
| 132 |
return round(sum(values) / len(values), 4) if values else 0.0
|
| 133 |
|
| 134 |
|
| 135 |
+
def run_preprocess_pipeline_sync(batch_id: str, mark_error_on_failure: bool = False) -> None:
|
| 136 |
+
"""Run the extraction pipeline in-process when Celery is unavailable."""
|
|
|
|
|
|
|
|
|
|
| 137 |
from ..ai.graph import extraction_graph
|
| 138 |
+
from .celery_app import get_worker_loop
|
| 139 |
+
|
| 140 |
+
loop = get_worker_loop()
|
| 141 |
try:
|
|
|
|
|
|
|
| 142 |
config = {"configurable": {"thread_id": batch_id}}
|
| 143 |
+
loop.run_until_complete(_update_batch_status(batch_id, "ocr_running"))
|
| 144 |
context = loop.run_until_complete(_load_batch_context(batch_id))
|
| 145 |
initial_state = {
|
| 146 |
"batch_id": batch_id,
|
|
|
|
| 150 |
"validation_results": [],
|
| 151 |
"needs_human_review": False,
|
| 152 |
"risk_level": "UNKNOWN",
|
| 153 |
+
"customs_readiness_score": None,
|
| 154 |
+
"crs_grade": None,
|
| 155 |
+
"rejection_probability": None,
|
| 156 |
+
"risk_features": {},
|
| 157 |
"ocr_conflicts": [],
|
| 158 |
"field_confidences": {},
|
| 159 |
"steps": [],
|
| 160 |
}
|
|
|
|
| 161 |
result = loop.run_until_complete(
|
| 162 |
extraction_graph.ainvoke(initial_state, config=config)
|
| 163 |
)
|
| 164 |
loop.run_until_complete(_persist_graph_result(batch_id, result))
|
| 165 |
+
except Exception as exc:
|
| 166 |
+
if mark_error_on_failure:
|
| 167 |
+
loop.run_until_complete(_update_batch_status(batch_id, "error", str(exc)))
|
| 168 |
+
raise
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@celery_app.task(bind=True, queue="high", max_retries=3, default_retry_delay=10)
|
| 172 |
+
def preprocess_document(self, batch_id: str) -> None:
|
| 173 |
+
"""Entry point: kicks off LangGraph extraction pipeline."""
|
| 174 |
+
log.info("Starting extraction pipeline", batch_id=batch_id)
|
| 175 |
+
try:
|
| 176 |
+
run_preprocess_pipeline_sync(batch_id)
|
| 177 |
log.info("Extraction pipeline complete", batch_id=batch_id)
|
| 178 |
except Exception as exc:
|
| 179 |
log.error("Pipeline failed", batch_id=batch_id, error=str(exc))
|
| 180 |
+
if self.request.retries >= self.max_retries:
|
| 181 |
+
from .celery_app import get_worker_loop
|
| 182 |
+
|
| 183 |
+
loop = get_worker_loop()
|
| 184 |
+
loop.run_until_complete(_update_batch_status(batch_id, "error", str(exc)))
|
| 185 |
raise self.retry(exc=exc)
|
| 186 |
|
| 187 |
|