BuyingAgent/backend/app/services/evaluation_service.py

211 lines
9.3 KiB
Python
Raw Normal View History

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