muhammadghiffari commited on
Commit
ae08068
·
1 Parent(s): a266e5a

fix: sync cloud deployment snapshot with verified backend path

Browse files
src/ai/graph.py CHANGED
@@ -32,6 +32,13 @@ 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
  log.info("CLOUD_LLM_ONLY is active — bypassing heavy OCR ensemble fallback")
36
  return "validate"
37
 
 
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
 
src/ai/nodes/extract.py CHANGED
@@ -83,29 +83,44 @@ async def llm_extraction_node(state: ExtractionGraphState) -> dict:
83
 
84
  # Initialize LLM on first real document that needs extraction
85
  if structured_llm is None:
86
- if settings.DETERMINISTIC_E2E:
87
- if DeterministicLLM is None:
88
- raise RuntimeError("DETERMINISTIC_E2E enabled but DeterministicLLM not available")
89
- llm = DeterministicLLM()
90
- else:
91
- if ChatGoogleGenerativeAI is None:
92
- raise RuntimeError("Production LLM dependency 'langchain_google_genai' is not installed")
93
- primary_llm = ChatGoogleGenerativeAI(
94
- model=settings.GEMINI_MODEL_PRIMARY,
95
- temperature=0,
96
- api_key=settings.GEMINI_API_KEY
97
- )
98
- fallback_llm = ChatGoogleGenerativeAI(
99
- model=settings.GEMINI_MODEL_FALLBACK,
100
- temperature=0,
101
- api_key=settings.GEMINI_API_KEY
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  )
103
- llm = primary_llm.with_fallbacks([fallback_llm])
104
-
105
- structured_llm = llm.with_structured_output(CEISAFields)
106
- # Support both synchronous return and awaitable (coroutine/AsyncMock)
107
- if asyncio.iscoroutine(structured_llm) or inspect.isawaitable(structured_llm):
108
- structured_llm = await structured_llm
 
109
  # Validate document state before processing
110
  if not doc.get("doc_id") or not doc.get("pages"):
111
  log.error(
@@ -183,13 +198,20 @@ async def llm_extraction_node(state: ExtractionGraphState) -> dict:
183
  "ocr_method": "failed"
184
  })
185
  except Exception as e:
186
- # Unexpected errors log and re-raise to fail the batch
187
- log.critical(
 
188
  "Unexpected error in LLM extraction — batch will fail",
189
  doc_id=doc.get("doc_id"),
190
  batch_id=state["batch_id"],
191
  error_type=type(e).__name__
192
  )
 
 
 
 
 
 
193
  raise
194
 
195
  return {
 
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(
 
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 {
src/config.py CHANGED
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
27
  ENVIRONMENT: Literal["development", "staging", "production"] = "development"
28
  DEBUG: bool = False
29
  SECRET_KEY: SecretStr = Field(..., min_length=32)
30
- CORS_ORIGINS: list[str] = ["*"]
31
 
32
  # ── Database ──────────────────────────────────────────────────────────────
33
  DATABASE_URL: str # asyncpg connection string e.g. postgresql+asyncpg://...
@@ -68,9 +68,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_FREE_LIMIT: int = 5000 # Pages/month on F0 tier (Invariant #9)
 
74
 
75
  # ── CEISA (Simulator in dev, real endpoint in prod) ───────────────────────
76
  CEISA_BASE_URL: AnyHttpUrl = "http://simulator:8006"
@@ -113,9 +114,10 @@ class Settings(BaseSettings):
113
  LANGCHAIN_API_KEY: SecretStr = "" # type: ignore[assignment]
114
 
115
  # ── Feature Flags ─────────────────────────────────────────────────────────
116
- ENABLE_SURYA_AGENT: bool = True
117
- ENABLE_AZURE_DI_AGENT: bool = True
118
- ENABLE_VESSEL_VALIDATION: bool = True
 
119
  ENABLE_BLOCKCHAIN: bool = True # type: ignore[assignment] — redeclared intentionally
120
  ENABLE_INSW_CHECK: bool = True
121
  ENABLE_NOTIFICATIONS_WHATSAPP: bool = False
@@ -128,10 +130,12 @@ class Settings(BaseSettings):
128
  DETERMINISTIC_E2E: bool = False # Used in extract.py to swap LLM for deterministic mock
129
 
130
  # ── Thresholds ────────────────────────────────────────────────────────────
131
- OCR_MAX_RENDERED_PAGES: int = 10
132
- OCR_MAX_LLM_PAGES: int = 5
133
- OCR_FAST_PATH_QUALITY_THRESHOLD: float = 0.95
134
- OCR_RECONCILIATION_DISAGREEMENT_THRESHOLD: float = 0.20
 
 
135
  LLM_CONFIDENCE_REVIEW_THRESHOLD: float = 0.70
136
  CRS_MIN_SUBMIT_THRESHOLD: int = 55
137
  HS_CONFIDENCE_RAG_THRESHOLD: float = 0.75
 
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://...
 
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"
 
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
 
130
  DETERMINISTIC_E2E: bool = False # Used in extract.py to swap LLM for deterministic mock
131
 
132
  # ── Thresholds ────────────────────────────────────────────────────────────
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
141
  HS_CONFIDENCE_RAG_THRESHOLD: float = 0.75
src/dependencies.py CHANGED
@@ -54,10 +54,17 @@ async def close_supabase() -> None:
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
 
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
src/main.py CHANGED
@@ -29,7 +29,7 @@ except Exception: # pragma: no cover - optional
29
  Instrumentator = None
30
 
31
  from .config import settings
32
- from .dependencies import close_supabase, init_supabase
33
  from .routers import admin, batches, blockchain, hs_recommend, vessel
34
  from .utils.telemetry import setup_telemetry
35
 
@@ -120,6 +120,38 @@ def create_app() -> FastAPI:
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."""
 
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
  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."""
src/routers/batches.py CHANGED
@@ -178,7 +178,11 @@ async def list_batches(
178
  .range(offset, offset + limit - 1)
179
  .execute()
180
  )
181
- return {"batches": res.data, "total": len(res.data)}
 
 
 
 
182
 
183
 
184
  @router.get("/batches/{batch_id}")
@@ -198,11 +202,15 @@ async def get_batch(
198
  docs_res = await supabase.table("documents").select("*").eq("batch_id", batch_id).execute()
199
  fields_res = await supabase.table("extracted_fields").select("*").eq("batch_id", batch_id).execute()
200
  validations_res = await supabase.table("validation_results").select("*").eq("batch_id", batch_id).execute()
 
 
 
201
 
202
  return {
203
  "batch": batch,
204
  "documents": docs_res.data,
205
- "extracted_fields": fields_res.data,
 
206
  "validation_results": validations_res.data,
207
  }
208
 
@@ -289,3 +297,72 @@ def _infer_doc_type(filename: str) -> str:
289
  if any(k in name for k in ("pl", "packing", "packinglist")):
290
  return "packing_list"
291
  return "invoice"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}")
 
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
  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"
src/services/ocr_engine_svc.py CHANGED
@@ -112,13 +112,30 @@ class OCREngineService:
112
  candidates: dict[str, dict[str, Any]] = {}
113
 
114
  if settings.CLOUD_LLM_ONLY:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  page_data_urls = [_data_url(b) for b in page_images[: settings.OCR_MAX_LLM_PAGES]]
116
  return {
117
  "pages": page_data_urls,
118
- "raw_text": "",
119
- "ocr_candidates": {},
120
- "quality_score": 1.0,
121
- "ocr_engine_latencies_ms": {}
 
 
122
  }
123
 
124
  pdf_task = None
@@ -126,7 +143,7 @@ class OCREngineService:
126
  pdf_task = asyncio.to_thread(self._extract_pdf_text, file_bytes)
127
 
128
  paddle_task = self._run_paddle(page_images)
129
- azure_task = self._run_azure(file_bytes, mime_type) if settings.ENABLE_DUAL_OCR else None
130
 
131
  gather_tasks = [paddle_task]
132
  if azure_task is not None:
@@ -274,7 +291,7 @@ class OCREngineService:
274
  return {"fields": {}, "text": "", "confidence": 0.0, "error": str(exc)}
275
 
276
  async def _run_azure(self, file_bytes: bytes, mime_type: str) -> dict[str, Any]:
277
- if not settings.ENABLE_DUAL_OCR:
278
  return {"fields": {}, "text": "", "confidence": 0.0}
279
  if not settings.AZURE_DI_ENDPOINT or not settings.AZURE_DI_KEY:
280
  log.warning("Azure DI not configured; dual OCR is degraded to PaddleOCR only")
@@ -333,6 +350,9 @@ class OCREngineService:
333
  log.warning("Azure DI failed", error=str(exc))
334
  return {"fields": {}, "text": "", "confidence": 0.0, "error": str(exc)}
335
 
 
 
 
336
  def _estimate_quality(self, page_images: list[bytes]) -> float:
337
  if not page_images:
338
  return 0.0
 
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
 
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:
 
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
  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
start.sh CHANGED
@@ -6,6 +6,7 @@ redis-server --daemonize yes
6
  sleep 2
7
 
8
  # Start Celery worker in the background
 
9
  celery -A src.tasks.ocr_tasks worker --loglevel=info &
10
 
11
  # Start FastAPI
 
6
  sleep 2
7
 
8
  # Start Celery worker in the background
9
+ export C_FORCE_ROOT=true
10
  celery -A src.tasks.ocr_tasks worker --loglevel=info &
11
 
12
  # Start FastAPI