Split a booked AI-plan fact across workplans or repos using measured tokens, measured+estimated tokens, session counts, or an even split. Unmeasured remainder stays a residual. The record is not resource-control AllocationEvidence and does not create booked spend.
78 lines
2.8 KiB
Python
78 lines
2.8 KiB
Python
"""Fin-hub reporting allocation of a booked AI-plan fact onto work."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
from fin_hub.money import money
|
|
|
|
REPORTING_METHODS = (
|
|
"measured_token_share",
|
|
"measured_estimated_token_share",
|
|
"session_count_share",
|
|
"even_split",
|
|
)
|
|
|
|
|
|
class ReportingAllocationShare(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
target_key: str = Field(min_length=1, max_length=256)
|
|
share: Decimal = Field(ge=0, le=1)
|
|
driver_value: int = Field(ge=0)
|
|
|
|
@field_validator("share", mode="before")
|
|
@classmethod
|
|
def validate_share(cls, value):
|
|
return money(value, non_negative=True)
|
|
|
|
|
|
class ReportingAllocation(BaseModel):
|
|
"""Local reporting allocation. Not resource-control AllocationEvidence."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
schema_version: Literal["0.1"] = "0.1"
|
|
record_type: Literal["reporting_allocation"] = "reporting_allocation"
|
|
record_id: str = Field(min_length=1, max_length=256)
|
|
revision_of: str | None = None
|
|
financial_fact_id: str = Field(min_length=1)
|
|
method: Literal[
|
|
"measured_token_share",
|
|
"measured_estimated_token_share",
|
|
"session_count_share",
|
|
"even_split",
|
|
]
|
|
estimator_version: str = Field(min_length=1, max_length=64)
|
|
session_token_record_id: str | None = None
|
|
period_start: date
|
|
period_end: date
|
|
currency: str
|
|
booked_amount: Decimal
|
|
shares: list[ReportingAllocationShare]
|
|
residual_share: Decimal = Field(ge=0, le=1)
|
|
residual_reason: Literal["none", "unmeasured", "unattributed", "no_targets"]
|
|
source_evidence: list[str] = Field(min_length=1)
|
|
|
|
@field_validator("booked_amount", "residual_share", mode="before")
|
|
@classmethod
|
|
def validate_money_fields(cls, value):
|
|
return money(value, non_negative=True)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_shares(self) -> "ReportingAllocation":
|
|
keys = [share.target_key for share in self.shares]
|
|
if len(set(keys)) != len(keys):
|
|
raise ValueError("reporting allocation target keys must be unique")
|
|
total = sum((share.share for share in self.shares), Decimal("0.00"))
|
|
if total + self.residual_share != Decimal("1.00"):
|
|
raise ValueError("allocation shares plus residual_share must equal 1")
|
|
if self.residual_share == Decimal("0.00") and self.residual_reason != "none":
|
|
raise ValueError("zero residual must use residual_reason=none")
|
|
if self.residual_share > Decimal("0.00") and self.residual_reason == "none":
|
|
raise ValueError("nonzero residual requires a residual_reason")
|
|
return self
|