Spaces:
Runtime error
Runtime error
| import time | |
| from decimal import Decimal | |
| from sqlalchemy import select | |
| from sqlalchemy.orm import Session | |
| from app.core.enums import BureauRisk, JourneyAction, RuleType, ScorecardDecision | |
| from app.models.audit import ScorecardExecutionAudit | |
| from app.models.rule import RuleMaster | |
| from app.models.scorecard import ScorecardResult | |
| from app.schemas.scorecard import ScorecardRequest, ScorecardResponse | |
| from app.services.bureau import BureauRiskClassifier | |
| from app.services.context import build_evaluation_context | |
| from app.services.hash import stable_payload_hash | |
| from app.services.rule_engine import RuleEngine | |
| from app.services.rule_repository import RuleRepository | |
| class ScorecardService: | |
| def __init__(self) -> None: | |
| self.rules = RuleRepository() | |
| self.engine = RuleEngine() | |
| self.bureau = BureauRiskClassifier() | |
| def _existing_result( | |
| self, db: Session, request: ScorecardRequest, request_hash: str | |
| ) -> ScorecardResult | None: | |
| stmt = ( | |
| select(ScorecardResult) | |
| .where( | |
| ScorecardResult.lead_id == request.application.leadId, | |
| ScorecardResult.tenant_id == request.tenantId, | |
| ScorecardResult.product_type == request.productType, | |
| ScorecardResult.request_hash == request_hash, | |
| ) | |
| .order_by(ScorecardResult.created_at.desc()) | |
| ) | |
| return db.scalars(stmt).first() | |
| def generate(self, db: Session, request: ScorecardRequest, persist: bool = True) -> ScorecardResponse: | |
| start = time.perf_counter() | |
| payload_hash = stable_payload_hash(request.model_dump()) | |
| if persist and not request.forceRecalculate: | |
| existing = self._existing_result(db, request, payload_hash) | |
| if existing: | |
| return self._to_response(existing) | |
| audit = ScorecardExecutionAudit( | |
| lead_id=request.application.leadId, | |
| tenant_id=request.tenantId, | |
| product_type=request.productType, | |
| request_hash=payload_hash, | |
| matched_rules=[], | |
| skipped_rules=[], | |
| status="STARTED", | |
| ) | |
| if persist: | |
| db.add(audit) | |
| db.flush() | |
| try: | |
| response = self._execute(db, request) | |
| if persist: | |
| result = ScorecardResult( | |
| tenant_id=request.tenantId, | |
| product_type=request.productType, | |
| lead_id=response.leadId, | |
| final_score=response.finalScore, | |
| decision=response.decision, | |
| underwriting_required=response.underwritingRequired, | |
| reject_flag=response.rejectFlag, | |
| journey_action=response.journeyAction, | |
| bureau_risk=response.bureauRisk, | |
| score_breakup={k: str(v) for k, v in response.scoreBreakup.items()}, | |
| triggered_rules=response.triggeredRules, | |
| reason_codes=response.reasonCodes, | |
| request_hash=payload_hash, | |
| ) | |
| db.add(result) | |
| audit.matched_rules = response.triggeredRules | |
| audit.status = "SUCCESS" | |
| audit.latency_ms = int((time.perf_counter() - start) * 1000) | |
| db.commit() | |
| db.refresh(result) | |
| return self._to_response(result) | |
| return response | |
| except Exception as exc: | |
| if persist: | |
| audit.status = "FAILED" | |
| audit.error_message = str(exc) | |
| audit.latency_ms = int((time.perf_counter() - start) * 1000) | |
| db.commit() | |
| raise | |
| def _execute(self, db: Session, request: ScorecardRequest) -> ScorecardResponse: | |
| context = build_evaluation_context(request) | |
| rules = self.rules.get_active_rules( | |
| db, request.tenantId, request.productType, request.application.dealerId | |
| ) | |
| hard_reject = self.engine.evaluate( | |
| [rule for rule in rules if rule.rule_type == RuleType.HARD_REJECT], context | |
| ) | |
| score, breakup, score_eval = self.engine.score(rules, context) | |
| bureau_risk = self.bureau.classify(context) | |
| non_stp = self.engine.evaluate( | |
| [rule for rule in rules if rule.rule_type == RuleType.NON_STP], context | |
| ) | |
| stp_eligibility_rules = [ | |
| rule for rule in rules if rule.rule_type == RuleType.STP_ELIGIBILITY | |
| ] | |
| stp_eval = self.engine.evaluate(stp_eligibility_rules, context) | |
| decision = self._decide( | |
| score=score, | |
| hard_reject_rules=hard_reject.matched, | |
| non_stp_rules=non_stp.matched, | |
| stp_rules=stp_eligibility_rules, | |
| stp_matched=stp_eval.matched, | |
| bureau_risk=bureau_risk, | |
| ) | |
| underwriting_required = decision in { | |
| ScorecardDecision.NON_STP, | |
| ScorecardDecision.CONDITIONAL_STP, | |
| } | |
| reject_flag = decision == ScorecardDecision.REJECT | |
| journey_action = self._journey_action(decision) | |
| triggered_rules = ( | |
| hard_reject.codes + score_eval.codes + non_stp.codes + stp_eval.codes | |
| ) | |
| reason_codes = list( | |
| dict.fromkeys( | |
| hard_reject.reason_codes | |
| + score_eval.reason_codes | |
| + non_stp.reason_codes | |
| + stp_eval.reason_codes | |
| ) | |
| ) | |
| return ScorecardResponse( | |
| leadId=request.application.leadId, | |
| finalScore=score, | |
| decision=decision, | |
| underwritingRequired=underwriting_required, | |
| rejectFlag=reject_flag, | |
| journeyAction=journey_action, | |
| bureauRisk=bureau_risk, | |
| reasonCodes=reason_codes, | |
| triggeredRules=triggered_rules, | |
| scoreBreakup=breakup, | |
| ) | |
| def _decide( | |
| self, | |
| score: Decimal, | |
| hard_reject_rules: list[RuleMaster], | |
| non_stp_rules: list[RuleMaster], | |
| stp_rules: list[RuleMaster], | |
| stp_matched: list[RuleMaster], | |
| bureau_risk: BureauRisk, | |
| ) -> ScorecardDecision: | |
| if hard_reject_rules: | |
| return ScorecardDecision.REJECT | |
| all_stp_rules_match = bool(stp_rules) and len(stp_rules) == len(stp_matched) | |
| if ( | |
| all_stp_rules_match | |
| and score >= Decimal("80") | |
| and not non_stp_rules | |
| and bureau_risk != BureauRisk.HIGH | |
| ): | |
| return ScorecardDecision.STP | |
| if non_stp_rules or bureau_risk == BureauRisk.HIGH: | |
| return ScorecardDecision.NON_STP | |
| if Decimal("65") <= score <= Decimal("79"): | |
| return ScorecardDecision.CONDITIONAL_STP | |
| return ScorecardDecision.NON_STP | |
| def _journey_action(self, decision: ScorecardDecision) -> JourneyAction: | |
| if decision == ScorecardDecision.STP: | |
| return JourneyAction.SKIP_UNDERWRITING | |
| if decision == ScorecardDecision.REJECT: | |
| return JourneyAction.REJECT_APPLICATION | |
| return JourneyAction.SEND_TO_UNDERWRITING | |
| def _to_response(self, result: ScorecardResult) -> ScorecardResponse: | |
| return ScorecardResponse( | |
| leadId=result.lead_id, | |
| finalScore=result.final_score, | |
| decision=result.decision, | |
| underwritingRequired=result.underwriting_required, | |
| rejectFlag=result.reject_flag, | |
| journeyAction=result.journey_action, | |
| bureauRisk=result.bureau_risk, | |
| reasonCodes=list(result.reason_codes or []), | |
| triggeredRules=list(result.triggered_rules or []), | |
| scoreBreakup={k: Decimal(str(v)) for k, v in (result.score_breakup or {}).items()}, | |
| ) | |