BuyingAgent/backend/app/services/evaluation_service.py
Lars b276758ee1
All checks were successful
Deploy Development / deploy (push) Successful in 1m19s
Test Suite / pytest-backend (push) Successful in 31s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-checks (push) Successful in 28s
Stelle den Frontend-MVP und die v0.3-API auf develop bereit, damit Dev API und PWA zusammen deployen.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 13:04:59 +02:00

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