211 lines
9.3 KiB
Python
211 lines
9.3 KiB
Python
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),
|
|
}
|