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

Automated deployment from GitHub Actions: d0b87cbe4fdaf86c5c12e61d54b1acd8b234b76c

Browse files
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
- # Structured output schema
 
34
  class CEISAFields(BaseModel):
35
- importer_name: str | None = Field(description="Name of the importing company")
36
- importer_npwp: str | None = Field(description="NPWP tax ID of the importer")
37
- total_packages: int | None = Field(description="Total number of packages/koli")
38
- gross_weight: float | None = Field(description="Total gross weight in KGM")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 using Gemini 2.0 Flash Exp.
45
 
46
- Args:
47
- state: ExtractionGraphState with documents list containing doc_id, storage_path, pages
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
- # Lazy LLM setup: only instantiate the LLM when we encounter the first document
61
- # that actually needs LLM extraction (has pages). This keeps unit tests lightweight
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
- # Validate document state before processing
70
- if not doc.get("doc_id") or not doc.get("pages"):
 
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, pages)",
79
  "fallback_required": True,
80
- "ocr_method": "failed"
81
  })
82
  continue
83
 
84
- # Initialize LLM on first real document that needs extraction
85
- if structured_llm is None:
86
- try:
87
- if settings.DETERMINISTIC_E2E:
88
- if DeterministicLLM is None:
89
- raise RuntimeError("DETERMINISTIC_E2E enabled but DeterministicLLM not available")
90
- llm = DeterministicLLM()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  else:
92
- if ChatGoogleGenerativeAI is None:
93
- raise RuntimeError("Production LLM dependency 'langchain_google_genai' is not installed")
94
- primary_llm = ChatGoogleGenerativeAI(
95
- model=settings.GEMINI_MODEL_PRIMARY,
96
- temperature=0,
97
- api_key=settings.GEMINI_API_KEY
98
- )
99
- fallback_llm = ChatGoogleGenerativeAI(
100
- model=settings.GEMINI_MODEL_FALLBACK,
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  temperature=0,
102
- api_key=settings.GEMINI_API_KEY
103
  )
104
- llm = primary_llm.with_fallbacks([fallback_llm])
 
 
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
- except Exception as e:
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 so tests don't require langchain_core
148
  def __init__(self, content):
149
  self.content = content
150
 
151
- # Real LLM call with multimodal content
152
- # Pass pages as base64-encoded images for extraction
153
- messages = [
154
- _HumanMessage(
155
- content=[
156
- {"type": "text", "text": "Extract all CEISA fields (importer name, NPWP, packages, weight, CIF value) from this document."},
157
- {"type": "image_url", "image_url": {"url": doc["pages"][0]}} if doc["pages"] else {"type": "text", "text": "No pages available"}
158
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  )
160
- ]
161
-
162
- result = await structured_llm.ainvoke(messages)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
- # Convert Pydantic model to dict
165
- raw_extracted = result.model_dump(exclude_none=True) if hasattr(result, 'model_dump') else result
166
- if asyncio.iscoroutine(raw_extracted):
167
- raw_extracted = await raw_extracted
168
- extracted = raw_extracted
 
 
 
 
 
 
 
 
 
 
 
 
169
 
170
  candidates = dict(doc.get("ocr_candidates") or {})
 
171
  candidates[settings.GEMINI_MODEL_PRIMARY] = {
172
  "fields": extracted,
173
- "confidence": 0.82,
 
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": dict.fromkeys(extracted, 0.82),
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
- # Unexpected errors are real production failures and should surface
202
- # so the batch can be diagnosed instead of silently degrading.
203
  log.exception(
204
- "Unexpected error in LLM extraction — batch will fail",
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": "gemini_failed"
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 len(doc.get("ocr_candidates") or {}) > 1
 
 
 
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
- results, needs_review = validation_rules_service.evaluate(state)
 
 
 
 
 
 
 
 
 
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] | 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-document"
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-2.5-pro"
107
- GEMINI_MODEL_FALLBACK: str = "gemini-2.5-flash"
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
- ENABLE_DUAL_OCR: bool = True # Legacy alias used by lightweight OCR service
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
- OCR_FALLBACK_TRIGGER_QUALITY: float = 0.75
137
- OCR_FALLBACK_TRIGGER_CONFIDENCE: float = 0.78
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
- # ── Celery ────────────────────────────────────────────────────────────────
165
- CELERY_TASK_SOFT_TIME_LIMIT: int = 300
166
- CELERY_TASK_TIME_LIMIT: int = 600
 
 
 
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 = 300
197
- task_time_limit = 600
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 HTTPException(
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, is_supabase_initialized
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
- queue = "high" if user.is_enterprise else "default"
155
- try:
156
- preprocess_document.apply_async(args=[batch_id], queue=queue)
157
- except Exception as e:
158
- log.warning("Celery apply_async failed, falling back to BackgroundTasks", error=str(e))
159
- background_tasks.add_task(preprocess_document, batch_id)
 
 
 
 
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
- res = await (
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
- batches = [_decorate_batch_summary(row) for row in (res.data or [])]
182
- importer_by_batch = await _load_importer_names(supabase, [row["id"] for row in batches])
183
- for row in batches:
184
- row["importer"] = importer_by_batch.get(row["id"], "Pending extraction")
185
- return {"batches": batches, "total": len(batches)}
 
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
- batch_res = await supabase.table("batches").select("*").eq("id", batch_id).single().execute()
 
 
 
 
 
 
 
 
 
 
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": 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
- return float(value.replace(",", ""))
60
- except ValueError:
 
 
 
 
 
 
 
 
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": raw_text,
134
- "ocr_candidates": candidates,
135
- "quality_score": 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
- azure_task = self._run_azure(file_bytes, mime_type) if self._azure_enabled() else None
 
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
- text = "\n".join((page.extract_text() or "") for page in pdf.pages)
 
231
  fields = extract_ceisa_fields_from_text(text)
232
- confidence = 0.98 if text.strip() else 0.0
 
 
 
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
- if self._paddle is None:
253
- from paddleocr import PaddleOCR
254
-
255
- try:
256
- self._paddle = PaddleOCR(
257
- use_angle_cls=True,
258
- lang=settings.PADDLEOCR_LANG,
259
- show_log=False,
260
- )
261
- except TypeError:
262
- self._paddle = PaddleOCR(use_angle_cls=True, lang=settings.PADDLEOCR_LANG)
263
-
264
- lines: list[str] = []
265
- confidences: list[float] = []
266
- with tempfile.TemporaryDirectory(prefix="tradeflow-paddle-") as tmpdir:
267
- for index, image_bytes in enumerate(page_images):
268
- image_path = Path(tmpdir) / f"page-{index}.png"
269
- image_path.write_bytes(image_bytes)
270
- result = self._paddle.ocr(str(image_path), cls=True)
271
- for page in result or []:
272
- for item in page or []:
273
- if len(item) >= 2 and isinstance(item[1], (list, tuple)):
274
- text = str(item[1][0])
275
- confidence = float(item[1][1])
276
- if text.strip():
277
- lines.append(text)
278
- confidences.append(confidence)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("PaddleOCR failed", error=str(exc))
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 self._azure_enabled():
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
- passed = evaluator.evaluate(rule["check"])
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  severity = "PASS" if passed else self._failure_severity(rule.get("severity"))
221
- message = rule.get("name", rule.get("rule_id", "Validation rule"))
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"{rule.get('name', rule.get('rule_id'))} could not be evaluated: {exc}"
227
 
228
  return {
229
- "rule_id": rule.get("rule_id", "UNKNOWN"),
230
- "rule_name": rule.get("name", rule.get("rule_id", "Validation rule")),
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 == "CRITICAL" else "WARNING"
 
 
 
 
 
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
- @celery_app.task(bind=True, queue="high", max_retries=3, default_retry_delay=10)
116
- def preprocess_document(self, batch_id: str) -> None:
117
- """Entry point: kicks off LangGraph extraction pipeline."""
118
- import asyncio
119
-
120
  from ..ai.graph import extraction_graph
121
- log.info("Starting extraction pipeline", batch_id=batch_id)
 
 
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