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,
    }