from datetime import datetime from decimal import Decimal from typing import Any from uuid import UUID, uuid4 from sqlalchemy.orm import Session from app.clock import now_utc from app.domain.serialize import jsonable from app.domain.timeutil import require_utc, to_iso_z from app.errors import AppError from app.persistence import models as orm from app.persistence.db import engine from app.persistence.repositories import CatalogRepository from app.scoring.engine import evaluate_deal from app.scoring.mathutil import round_score from app.settings import get_settings from sqlalchemy.orm import Session as SASession from sqlalchemy.orm import sessionmaker def evaluate_and_store( *, variant_id: UUID, payload: dict[str, Any], ) -> dict[str, Any]: now = now_utc() as_of = payload.get("as_of") if as_of is None: as_of = now else: as_of = require_utc(as_of, "as_of") if as_of > now: raise AppError(422, "AS_OF_IN_FUTURE", "as_of must not be in the future") snapshot_cutoff = now maker = sessionmaker(bind=engine, autoflush=False, autocommit=False) with engine.connect().execution_options(isolation_level="REPEATABLE READ") as connection: with SASession(bind=connection) as session: with session.begin(): repo = CatalogRepository(session) variant = repo.get_variant(variant_id) if variant is None: raise AppError(404, "NOT_FOUND", "variant not found", {"id": str(variant_id)}) offer = repo.get_offer(payload["offer_id"]) if offer is None: raise AppError(404, "NOT_FOUND", "offer not found", {"id": str(payload["offer_id"])}) if offer.variant_id != variant_id: raise AppError(422, "OFFER_VARIANT_MISMATCH", "offer does not belong to the variant") product = repo.get_product(variant.product_id) if product is None: raise AppError(404, "NOT_FOUND", "product not found") profile = repo.get_profile(payload["profile_id"], int(payload["profile_version"])) if profile is None: raise AppError(404, "NOT_FOUND", "profile version not found") model = repo.get_scoring_model(profile.scoring_version) if model is None: raise AppError(422, "UNKNOWN_SCORING_VERSION", "scoring version is missing") attributes = tuple(repo.list_attributes(variant_id)) observations, _ = repo.list_observations(offer.id, limit=None) result = evaluate_deal( profile=profile, scoring_model=model, product=product, variant=variant, offer=offer, attributes=attributes, observations=tuple(observations), definitions=repo.all_definitions(), as_of=as_of, snapshot_cutoff=snapshot_cutoff, engine_build=get_settings().engine_build, ) response = evaluation_to_response(result, evaluation_id=uuid4(), created_at=now) record = orm.EvaluationRecord( id=response["id"], variant_id=variant.id, offer_id=offer.id, profile_id=profile.id, profile_version=profile.version, scoring_version=model.version, as_of=as_of, snapshot_cutoff=snapshot_cutoff, scoring_hash=model.definition_hash, engine_build=result.engine_build, product_eligibility=result.product_eligibility.value, offer_eligibility=result.offer_eligibility.value, overall_eligibility=result.overall_eligibility.value, product_score=result.product_score, price_score=result.price_score, deal_score=result.deal_score, classification=result.classification, price_reference_scope=result.price_reference_scope.value, input_snapshot=result.input_snapshot, result_snapshot=response, created_at=now, ) repo.add_evaluation(record) for component in result.components: repo.add_component( orm.EvaluationComponentRecord( id=uuid4(), evaluation_id=record.id, kind=component.kind, component_key=component.component_key, raw_score=component.raw_score, weight=component.weight, contribution=component.contribution, details=jsonable(component.details), ) ) return response def load_evaluation(session: Session, evaluation_id: UUID) -> dict[str, Any]: record = CatalogRepository(session).get_evaluation(evaluation_id) if record is None: raise AppError(404, "NOT_FOUND", "evaluation not found", {"id": str(evaluation_id)}) return record.result_snapshot def evaluation_to_response(result, *, evaluation_id: UUID, created_at: datetime) -> dict[str, Any]: stats = result.price_statistics return { "id": str(evaluation_id), "profile_id": result.profile_id, "profile_version": result.profile_version, "scoring_version": result.scoring_version, "variant_id": str(result.variant_id), "offer_id": str(result.offer_id), "as_of": to_iso_z(result.as_of), "snapshot_cutoff": to_iso_z(result.snapshot_cutoff), "scoring_hash": result.scoring_hash, "engine_build": result.engine_build, "product_eligibility": result.product_eligibility.value, "offer_eligibility": result.offer_eligibility.value, "overall_eligibility": result.overall_eligibility.value, "eligible": result.eligible, "product_score": _num(result.product_score), "price_score": _num(result.price_score), "deal_score": _num(result.deal_score), "classification": result.classification, "price_basis": None if result.price_basis is None else result.price_basis.value, "product_coverage": _num(result.product_coverage), "failed_constraints": [_constraint(item) for item in result.failed_constraints], "unknown_constraints": [_constraint(item) for item in result.unknown_constraints], "constraint_results": [_constraint(item) for item in result.constraint_results], "product_gates": [_gate(item) for item in result.product_gates], "offer_gates": [_gate(item) for item in result.offer_gates], "components": [_component(item) for item in result.components], "price_statistics": { "current_total": _money(stats.current_total), "median_30d": _money(stats.median_30d), "median_30d_days": stats.median_30d_days, "median_90d": _money(stats.median_90d), "median_90d_days": stats.median_90d_days, "lowest_365d": _money(stats.lowest_365d), "lowest_365d_days": stats.lowest_365d_days, "lowest_365d_is_full_year": stats.lowest_365d_is_full_year, "used_observation_ids": [str(item) for item in stats.used_observation_ids], "current_observation_id": None if stats.current_observation_id is None else str(stats.current_observation_id), }, "price_reference_scope": result.price_reference_scope.value, "reasons": [{"code": item.code, "message": item.message} for item in result.reasons], "warnings": list(result.warnings), "input_snapshot": result.input_snapshot, "created_at": to_iso_z(created_at), } def _num(value: Decimal | None) -> float | None: return None if value is None else float(round_score(value)) def _money(value: Decimal | None) -> str | None: return None if value is None else format(value.quantize(Decimal("0.01")), "f") def _constraint(item) -> dict[str, Any]: return { "constraint_id": item.constraint_id, "attribute": item.attribute, "operator": item.operator, "expected": jsonable(item.expected), "actual": jsonable(item.actual), "status": item.status, "mandatory": item.mandatory, "attribute_row_id": None if item.attribute_row_id is None else str(item.attribute_row_id), "reason_code": item.reason_code, } def _gate(item) -> dict[str, Any]: return { "key": item.key, "status": item.status, "reason_code": item.reason_code, "details": jsonable(item.details), } def _component(item) -> dict[str, Any]: return { "kind": item.kind, "component_key": item.component_key, "raw_score": None if item.raw_score is None else float(round_score(item.raw_score)), "weight": None if item.weight is None else float(item.weight), "contribution": None if item.contribution is None else float(round_score(item.contribution)), "details": jsonable(item.details), }