166 lines
6.1 KiB
Python
166 lines
6.1 KiB
Python
"""Schema for the rate card — types only, no rates.
|
|
|
|
Rates live in :mod:`genesyscalc.ratecard` as an immutable verbatim anchor.
|
|
This module defines what a metered feature *is*, so the anchor can be typed
|
|
and the arithmetic can be tested without either knowing the other's
|
|
business.
|
|
|
|
The invariants in ``__post_init__`` are the point: a meter flagged
|
|
:attr:`Confidence.CONFIRMED` that carries no source URL and date cannot be
|
|
constructed. That is what stops an unsourced number wearing a green tick on
|
|
a client-facing stage.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
|
|
|
|
class Confidence(Enum):
|
|
"""How much weight a number carries. Rendered on stage as an icon."""
|
|
|
|
CONFIRMED = "confirmed" # published by the vendor, with a source URL + date
|
|
ESTIMATED = "estimated" # working assumption, stated
|
|
UNKNOWN = "unknown" # not sourced; bound it with sensitivity if material
|
|
|
|
@property
|
|
def icon(self) -> str:
|
|
return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value]
|
|
|
|
|
|
class LicenceModel(Enum):
|
|
"""Genesys prices per-user AI features differently by licence model."""
|
|
|
|
NAMED = "named"
|
|
CONCURRENT = "concurrent"
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return self.value.capitalize()
|
|
|
|
|
|
class MeterBasis(Enum):
|
|
"""How a feature converts activity into tokens."""
|
|
|
|
PER_USER_PER_MONTH = "per_user_per_month" # Copilot 40/60, STA 30/45
|
|
UNITS_PER_TOKEN = "units_per_token" # "51 sessions per token"
|
|
TOKENS_PER_UNIT = "tokens_per_unit" # "0.5 tokens per interaction"
|
|
VOICE_MINUTES = "voice_minutes" # 17 min/token, 15s per-call round-up
|
|
ZERO_RATED = "zero_rated" # "no charge for token usage"
|
|
|
|
|
|
class Tier(Enum):
|
|
"""Ordering for the published highest-tier rule.
|
|
|
|
"Genesys bases charges on the highest tier (price) resource that Genesys
|
|
Cloud uses during the interaction." Higher value = higher tier = wins.
|
|
An interaction is charged once, at the highest tier it touched — see
|
|
:class:`genesyscalc.usage.DeflectionMix`.
|
|
"""
|
|
|
|
BOT = 1
|
|
VIRTUAL_AGENT = 2
|
|
AGENTIC_VIRTUAL_AGENT = 3
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Meter:
|
|
"""One line of the vendor's published rate card.
|
|
|
|
``feature`` and ``published_rate`` hold the article's *exact* wording;
|
|
``rate`` holds the parsed number the arithmetic uses. Keeping both is
|
|
what makes a transcription slip catchable — ``test_rate_card`` pins the
|
|
strings, and a separate test proves the float agrees with the string.
|
|
"""
|
|
|
|
key: str
|
|
feature: str # the article's exact feature name
|
|
published_rate: str # the article's exact rate wording
|
|
basis: MeterBasis
|
|
rate: float = 0.0 # tokens per unit (0.0 for per-user/zero-rated)
|
|
per_user_named: float | None = None
|
|
per_user_concurrent: float | None = None
|
|
tier: Tier | None = None
|
|
confidence: Confidence = Confidence.CONFIRMED
|
|
source_url: str | None = None
|
|
source_date: str | None = None
|
|
unit_label: str = "units"
|
|
note: str = ""
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.rate < 0:
|
|
raise ValueError(f"{self.key}: rate must not be negative")
|
|
if self.confidence is Confidence.CONFIRMED and not (
|
|
self.source_url and self.source_date
|
|
):
|
|
raise ValueError(
|
|
f"{self.key}: a confirmed (🟢) meter must carry a source URL and date"
|
|
)
|
|
per_user = self.basis is MeterBasis.PER_USER_PER_MONTH
|
|
has_pair = (
|
|
self.per_user_named is not None and self.per_user_concurrent is not None
|
|
)
|
|
if per_user != has_pair:
|
|
raise ValueError(
|
|
f"{self.key}: per-user rates are required for "
|
|
f"{MeterBasis.PER_USER_PER_MONTH.value} and forbidden otherwise"
|
|
)
|
|
if self.basis is MeterBasis.ZERO_RATED and self.rate != 0.0:
|
|
raise ValueError(f"{self.key}: a zero-rated meter must have rate 0.0")
|
|
|
|
def tokens_per_user_month(self, licence: LicenceModel) -> float:
|
|
"""Subscription tokens for one user for one month."""
|
|
if self.basis is not MeterBasis.PER_USER_PER_MONTH:
|
|
raise ValueError(f"{self.key} is not a per-user meter")
|
|
rate = (
|
|
self.per_user_named
|
|
if licence is LicenceModel.NAMED
|
|
else self.per_user_concurrent
|
|
)
|
|
assert rate is not None # guaranteed by __post_init__
|
|
return rate
|
|
|
|
def tokens_for(self, units: float) -> float:
|
|
"""Consumption tokens for ``units`` of activity.
|
|
|
|
Voice-bot minutes are NOT priced here — they carry a per-call
|
|
round-up that needs call counts, so
|
|
:func:`genesyscalc.usage.voice_bot_tokens` owns that path.
|
|
"""
|
|
if self.basis in (MeterBasis.ZERO_RATED,):
|
|
return 0.0
|
|
if self.basis is MeterBasis.PER_USER_PER_MONTH:
|
|
raise ValueError(f"{self.key}: use tokens_per_user_month()")
|
|
return units * self.rate
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TokenPrice:
|
|
"""What one token costs, list versus negotiated.
|
|
|
|
``contracted_rate`` (a signed per-token rate) wins outright when set.
|
|
Otherwise the concession is a percentage off list — the lever a
|
|
negotiation actually moves. Both are client data and arrive from the
|
|
notebook's ``engagement-data`` cell, never from the anchor.
|
|
"""
|
|
|
|
currency: str = "USD"
|
|
list_rate: float = 1.00
|
|
contracted_rate: float | None = None
|
|
concession_pct: float = 0.0
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.list_rate <= 0:
|
|
raise ValueError("list_rate must be positive")
|
|
if not 0.0 <= self.concession_pct < 1.0:
|
|
raise ValueError("concession_pct must be in [0, 1)")
|
|
if self.contracted_rate is not None and self.contracted_rate < 0:
|
|
raise ValueError("contracted_rate must not be negative")
|
|
|
|
def effective_rate(self) -> float:
|
|
"""The rate actually billed per token."""
|
|
if self.contracted_rate is not None:
|
|
return self.contracted_rate
|
|
return self.list_rate * (1.0 - self.concession_pct)
|