Spaces:
Sleeping
Sleeping
Commit ·
ae08068
1
Parent(s): a266e5a
fix: sync cloud deployment snapshot with verified backend path
Browse files- src/ai/graph.py +7 -0
- src/ai/nodes/extract.py +46 -24
- src/config.py +15 -11
- src/dependencies.py +8 -1
- src/main.py +33 -1
- src/routers/batches.py +79 -2
- src/services/ocr_engine_svc.py +26 -6
- start.sh +1 -0
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 |
-
|
| 87 |
-
if
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
| 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
|
| 187 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
|
|
| 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":
|
| 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
|
| 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
|
| 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
|