Spaces:
Sleeping
Sleeping
File size: 6,267 Bytes
db4ba8d 1cf88ff db4ba8d 1cf88ff db4ba8d 1cf88ff db4ba8d dd9584b 1cf88ff db4ba8d 1cf88ff db4ba8d 1cf88ff db4ba8d dd9584b db4ba8d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 | """
TradeFlow AI — Risk Assessment Node (Step 2.5)
Runs the XGBoost rejection predictor and computes the
Customs Readiness Score (CRS) for a batch.
PRD §13 — CRS = weighted average across 5 pillars:
(1) Document Quality 20%
(2) Data Completeness 25%
(3) Cross-document Consistency 30%
(4) Historical Performance 15%
(5) HS Code Confidence 10%
"""
from __future__ import annotations
import structlog
from ...services.predictor_svc import rejection_predictor
from ..state import ExtractionGraphState
log = structlog.get_logger()
# Pillar weights per PRD §13
PILLAR_WEIGHTS = {
"doc_quality": 0.20,
"completeness": 0.25,
"consistency": 0.30,
"historical": 0.15,
"hs_confidence": 0.10,
}
REQUIRED_CEISA_FIELDS = [
"importer_name", "importer_npwp", "total_packages",
"gross_weight", "cif_value", "currency",
]
def _compute_completeness(combined_data: dict) -> float:
filled = sum(1 for f in REQUIRED_CEISA_FIELDS if combined_data.get(f))
return filled / len(REQUIRED_CEISA_FIELDS)
def _compute_consistency(validation_results: list[dict]) -> float:
if not validation_results:
return 1.0
passed = sum(1 for r in validation_results if r.get("severity") == "PASS")
return passed / len(validation_results)
def _compute_doc_quality(documents: list[dict]) -> float:
scores = [d.get("quality_score", 0.8) for d in documents]
return sum(scores) / len(scores) if scores else 0.0
def _compute_hs_confidence(combined_data: dict, field_confidences: dict) -> float:
if field_confidences.get("hs_code") is not None:
return max(0.0, min(1.0, float(field_confidences["hs_code"])))
return 0.85 if combined_data.get("hs_code") else 0.0
def _crs_to_grade(score: float) -> str:
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
if score >= 60:
return "D"
return "F"
def _score_to_risk(score: float) -> str:
if score >= 80:
return "LOW"
if score >= 65:
return "MEDIUM"
if score >= 50:
return "HIGH"
return "CRITICAL"
def _probability_to_risk(probability: float) -> str:
if probability < 0.15:
return "LOW"
if probability < 0.35:
return "MEDIUM"
if probability < 0.60:
return "HIGH"
return "CRITICAL"
async def risk_assessment_node(state: ExtractionGraphState) -> dict:
"""
Compute CRS (0-100) and rejection probability (0-1).
XGBoost inference uses the shared predictor service, with heuristic
fallback when no trained model is available yet.
"""
log.info("Running risk_assessment_node", batch_id=state["batch_id"])
combined_data = state.get("combined_data", {})
validation_results = state.get("validation_results", [])
documents = state.get("documents", [])
field_confidences = state.get("field_confidences", {})
# ── Pillar scores ──────────────────────────────────────────────
p_quality = _compute_doc_quality(documents)
p_completeness = _compute_completeness(combined_data)
p_consistency = _compute_consistency(validation_results)
p_historical = 0.80 # Stub — fetched from company submission history
p_hs_conf = _compute_hs_confidence(combined_data, field_confidences)
# ── Weighted CRS ───────────────────────────────────────────────
crs_raw = (
p_quality * PILLAR_WEIGHTS["doc_quality"]
+ p_completeness * PILLAR_WEIGHTS["completeness"]
+ p_consistency * PILLAR_WEIGHTS["consistency"]
+ p_historical * PILLAR_WEIGHTS["historical"]
+ p_hs_conf * PILLAR_WEIGHTS["hs_confidence"]
)
crs_score = round(crs_raw * 100, 2)
crs_grade = _crs_to_grade(crs_score)
critical_failures = sum(1 for r in validation_results if r.get("severity") == "CRITICAL_FAIL")
warnings = sum(1 for r in validation_results if r.get("severity") == "WARNING")
validation_penalty = (critical_failures * 10.0) + (warnings * 4.0)
crs_score = round(max(0.0, crs_score - validation_penalty), 2)
crs_grade = _crs_to_grade(crs_score)
features = {
"doc_quality_score": p_quality,
"completeness_score": p_completeness,
"consistency_score": p_consistency,
"historical_rate": p_historical,
"hs_confidence": p_hs_conf,
"cif_value_usd": float(combined_data.get("cif_value") or 0.0),
"package_count": float(combined_data.get("total_packages") or 0.0),
"gross_weight_kg": float(combined_data.get("gross_weight") or 0.0),
"critical_validation_failures": critical_failures,
"warning_validation_failures": warnings,
"validation_penalty": validation_penalty,
}
rejection_prob = round(rejection_predictor.predict_proba(features), 4)
validation_risk = (critical_failures * 0.18) + (warnings * 0.06)
rejection_prob = round(max(rejection_prob, min(0.95, validation_risk)), 4)
risk_level = _probability_to_risk(rejection_prob)
# PRD §13 Invariant: CRS < 70 → must NOT auto-submit
needs_human_review = (
state.get("needs_human_review", False)
or crs_score < 70.0
or rejection_prob >= 0.35
)
log.info(
"CRS computed",
batch_id=state["batch_id"],
crs=crs_score,
grade=crs_grade,
risk=risk_level,
rejection_prob=rejection_prob,
)
return {
"risk_level": risk_level,
"customs_readiness_score": crs_score,
"crs_grade": crs_grade,
"rejection_probability": rejection_prob,
"risk_features": features,
"needs_human_review": needs_human_review,
"steps": ["risk_assessment"],
# NOTE: crs_score and rejection_prob are persisted to DB in the
# caller task (ocr_tasks.assess_risk), not stored in graph state
# to keep the state lean per PRD §0.2 Invariant #5.
"_crs_score": crs_score,
"_crs_grade": crs_grade,
"_rejection_prob": rejection_prob,
"_risk_features": features,
}
|