Download app/services/operation_usage.py from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 19.3 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/app/services/operation_usage.py
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/app/services/operation_usage.py
-
curl -L -o operation_usage.py https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/app/services/operation_usage.py
19.3 kB
| """Authoritative request-unit limits for costly DocDoe operations. | |
| Policy is intentionally simple: a successful operation consumes one or more | |
| student-readable units; rejection and provider failure do not. Reservations | |
| count toward the limit and concurrency cap while work is running. A logical | |
| retry reuses the same user/operation/idempotency row, so it cannot double-count. | |
| Prompts and response bodies are never stored here. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import logging | |
| from dataclasses import dataclass | |
| from datetime import datetime, timedelta, timezone | |
| from typing import Any, Literal | |
| from fastapi import HTTPException, status | |
| from sqlalchemy import func, select, update | |
| from sqlalchemy.exc import IntegrityError | |
| from sqlalchemy.orm import Session | |
| from app.core.config import get_settings | |
| from app.models.usage_operation import UsageOperation | |
| from app.models.user import User | |
| from app.services.entitlements import EntitlementFeature, require_entitlement_feature, resolve_entitlements | |
| from app.services.plan_catalog import get_monthly_usage_limits, get_plan_config | |
| from app.utils.request_id import request_id_ctx_var | |
| logger = logging.getLogger(__name__) | |
| UsageOperationName = Literal[ | |
| "study_chat_ai", | |
| "tuition_tutor", | |
| "correction_ai", | |
| "source_processing", | |
| "learn_anything_generation", | |
| "pyq_style_generation", | |
| "video_generation", | |
| "upload_storage", | |
| "exam_intelligence_generation", | |
| ] | |
| _DEFAULT_POLICY: dict[str, dict[str, int | str]] = { | |
| "study_chat_ai": {"limit": 50, "period": "day", "max_active": 1}, | |
| "tuition_tutor": {"limit": 20, "period": "day", "max_active": 1}, | |
| "correction_ai": {"limit": 20, "period": "day", "max_active": 1}, | |
| "source_processing": {"limit": 10, "period": "month", "max_active": 1}, | |
| "learn_anything_generation": {"limit": 10, "period": "day", "max_active": 1}, | |
| "pyq_style_generation": {"limit": 20, "period": "day", "max_active": 1}, | |
| "video_generation": {"limit": 2, "period": "month", "max_active": 1}, | |
| "upload_storage": {"limit": 100 * 1024 * 1024, "period": "month", "max_active": 2}, | |
| # Audit H3: the two generative GET endpoints in /intelligence/ previously | |
| # had no per-user quota at all. They share the exam-intelligence feature | |
| # gate with pyq_style_generation but get their own daily budget. | |
| "exam_intelligence_generation": {"limit": 20, "period": "day", "max_active": 1}, | |
| } | |
| _OPERATION_FEATURES: dict[str, EntitlementFeature] = { | |
| "study_chat_ai": "study_chat_ai", | |
| "tuition_tutor": "tuition_ai_tutor", | |
| "correction_ai": "deterministic_correction", | |
| "source_processing": "source_upload", | |
| "learn_anything_generation": "learn_anything_generation", | |
| "pyq_style_generation": "pyq_style_generation", | |
| "video_generation": "heavy_generation", | |
| "upload_storage": "source_upload", | |
| "exam_intelligence_generation": "pyq_style_generation", | |
| } | |
| # Estimated provider USD cost per AI-token operation, used ONLY for the | |
| # per-user daily budget in reserve_usage(). Values are derived from the | |
| # provider cost table and typical token volumes, not billed amounts. Operations | |
| # that do not consume LLM tokens (source_processing, video_generation, | |
| # upload_storage) are intentionally absent so they are never double-counted. | |
| _OPERATION_ESTIMATED_COST_USD: dict[str, float] = { | |
| "study_chat_ai": 0.002, | |
| "tuition_tutor": 0.004, | |
| "correction_ai": 0.003, | |
| "learn_anything_generation": 0.008, | |
| "pyq_style_generation": 0.006, | |
| "exam_intelligence_generation": 0.006, | |
| } | |
| class UsagePolicy: | |
| limit: int | |
| period: Literal["day", "month"] | |
| max_active: int | |
| class UsageReservation: | |
| record: UsageOperation | |
| duplicate_completed: bool = False | |
| def should_charge(self) -> bool: | |
| return not self.duplicate_completed | |
| def _policies(policy_key: str = "free_trial") -> dict[str, UsagePolicy]: | |
| raw: dict[str, Any] = {} | |
| configured = get_settings().usage_policy_json.strip() | |
| if configured: | |
| try: | |
| parsed = json.loads(configured) | |
| if isinstance(parsed, dict): | |
| raw = parsed | |
| except (TypeError, ValueError): | |
| logger.warning("usage_policy_invalid", extra={"event": "usage_policy_invalid"}) | |
| free_config = get_plan_config("free_trial") | |
| plan_config = get_plan_config(policy_key) or free_config | |
| generation_multiplier = ( | |
| max(1, plan_config.monthly_generation_limit // max(1, free_config.monthly_generation_limit)) | |
| if plan_config and free_config | |
| else 1 | |
| ) | |
| legacy_limits = get_monthly_usage_limits(policy_key) | |
| policy_defaults = {name: dict(values) for name, values in _DEFAULT_POLICY.items()} | |
| for operation in ( | |
| "study_chat_ai", | |
| "tuition_tutor", | |
| "correction_ai", | |
| "learn_anything_generation", | |
| "pyq_style_generation", | |
| "exam_intelligence_generation", | |
| ): | |
| policy_defaults[operation]["limit"] = int(policy_defaults[operation]["limit"]) * generation_multiplier | |
| policy_defaults["source_processing"]["limit"] = int( | |
| legacy_limits.get("documents_uploaded_count", policy_defaults["source_processing"]["limit"]) | |
| ) | |
| policy_defaults["video_generation"]["limit"] = int( | |
| plan_config.monthly_video_limit if plan_config else policy_defaults["video_generation"]["limit"] | |
| ) | |
| policy_defaults["upload_storage"]["limit"] = int( | |
| legacy_limits.get("storage_bytes_used", policy_defaults["upload_storage"]["limit"]) | |
| ) | |
| policy_overrides = raw.get("policies", {}) if isinstance(raw.get("policies"), dict) else {} | |
| selected_overrides = ( | |
| policy_overrides.get(policy_key, {}) | |
| if isinstance(policy_overrides.get(policy_key), dict) | |
| else {} | |
| ) | |
| result: dict[str, UsagePolicy] = {} | |
| for operation, defaults in policy_defaults.items(): | |
| global_override = raw.get(operation) if isinstance(raw.get(operation), dict) else {} | |
| specific_override = ( | |
| selected_overrides.get(operation) | |
| if isinstance(selected_overrides.get(operation), dict) | |
| else {} | |
| ) | |
| override = {**global_override, **specific_override} | |
| period = override.get("period", defaults["period"]) | |
| result[operation] = UsagePolicy( | |
| limit=max(0, int(override.get("limit", defaults["limit"]))), | |
| period="month" if period == "month" else "day", | |
| max_active=max(1, int(override.get("max_active", defaults["max_active"]))), | |
| ) | |
| return result | |
| def _period_key(policy: UsagePolicy, now: datetime) -> str: | |
| return now.strftime("%Y-%m" if policy.period == "month" else "%Y-%m-%d") | |
| def stable_idempotency_key(*parts: object) -> str: | |
| """Hash metadata into a bounded key without persisting private prompt text.""" | |
| raw = "\x1f".join(str(part) for part in parts) | |
| return hashlib.sha256(raw.encode("utf-8", errors="ignore")).hexdigest() | |
| def _student_error(code: str, message: str, *, http_status: int) -> HTTPException: | |
| return HTTPException( | |
| status_code=http_status, | |
| detail={ | |
| "code": code, | |
| "message": message, | |
| "retryable": code == "OPERATION_IN_PROGRESS", | |
| "request_id": request_id_ctx_var.get("-"), | |
| }, | |
| ) | |
| def _duplicate_completed_error() -> HTTPException: | |
| """Reject a replay of an already-completed idempotency key. | |
| Used by callers whose completed row stores no result: replaying the key | |
| would otherwise re-run the paid operation for free. | |
| """ | |
| return _student_error( | |
| "DUPLICATE_REQUEST", | |
| "This request has already been completed. Start a new request to continue.", | |
| http_status=status.HTTP_409_CONFLICT, | |
| ) | |
| def _log(event: str, *, user_id: str, operation: str, units: int, policy: UsagePolicy, remaining: int) -> None: | |
| logger.info( | |
| event, | |
| extra={ | |
| "event": event, | |
| "operation": operation, | |
| "user_ref": stable_idempotency_key(user_id)[:16], | |
| "units": units, | |
| "remaining": max(0, remaining), | |
| "limit": policy.limit, | |
| "period": policy.period, | |
| }, | |
| ) | |
| def _daily_budget_denial( | |
| db: Session, *, user_id: str, operation: str, now: datetime | |
| ) -> tuple[float, float, float] | None: | |
| """Return (day_cost, this_cost, cap) when the per-user daily AI budget is | |
| exceeded, else None. | |
| Uses the same day period_key convention and reserved/completed statuses as | |
| the unit-limit query above. Fails OPEN: any accounting error returns None so | |
| a broken cost lookup can never take down AI generation for students. | |
| """ | |
| try: | |
| cap = float(get_settings().ai_router_per_user_daily_budget_usd or 0) | |
| if cap <= 0: | |
| return None | |
| cost = float(_OPERATION_ESTIMATED_COST_USD.get(operation, 0) or 0) | |
| if cost <= 0: | |
| return None | |
| day_key = now.strftime("%Y-%m-%d") | |
| rows = db.execute( | |
| select(UsageOperation.operation, func.coalesce(func.sum(UsageOperation.units), 0)) | |
| .where( | |
| UsageOperation.user_id == user_id, | |
| UsageOperation.operation.in_(list(_OPERATION_ESTIMATED_COST_USD)), | |
| UsageOperation.period_key == day_key, | |
| UsageOperation.status.in_(["reserved", "completed"]), | |
| ) | |
| .group_by(UsageOperation.operation) | |
| ).all() | |
| day_cost = sum( | |
| float(_OPERATION_ESTIMATED_COST_USD.get(op, 0)) * int(units or 0) | |
| for op, units in rows | |
| ) | |
| if day_cost + cost > cap: | |
| return day_cost, cost, cap | |
| return None | |
| except Exception: # noqa: BLE001 - accounting must fail open | |
| logger.warning( | |
| "usage_budget_check_failed", | |
| exc_info=True, | |
| extra={"event": "usage_budget_check_failed", "operation": operation}, | |
| ) | |
| return None | |
| def reserve_usage( | |
| db: Session, | |
| *, | |
| user_id: str, | |
| operation: UsageOperationName, | |
| idempotency_key: str, | |
| units: int = 1, | |
| reject_completed_duplicate: bool = False, | |
| ) -> UsageReservation: | |
| """Reserve quota before work. | |
| A completed row whose idempotency key is reused is normally returned as a | |
| no-charge duplicate. That is correct for resource-backed operations | |
| (uploads, video renders) which look up the stored ``resource_id`` and return | |
| the existing artefact. Callers whose completed row stores no result — chat, | |
| where replaying the key would otherwise re-run a paid AI call for free — | |
| pass ``reject_completed_duplicate=True`` to reject the replay instead. | |
| """ | |
| if units <= 0: | |
| raise ValueError("Usage units must be positive") | |
| entitlement = require_entitlement_feature(db, user_id, _OPERATION_FEATURES[operation]) | |
| policy = _policies(entitlement.usage_policy_key)[operation] | |
| if not get_settings().usage_limits_enabled: | |
| policy = UsagePolicy(limit=2_147_483_647, period=policy.period, max_active=policy.max_active) | |
| now = datetime.now(timezone.utc) | |
| period_key = _period_key(policy, now) | |
| key = idempotency_key.strip()[:180] or stable_idempotency_key(request_id_ctx_var.get("-")) | |
| existing = db.scalar( | |
| select(UsageOperation).where( | |
| UsageOperation.user_id == user_id, | |
| UsageOperation.operation == operation, | |
| UsageOperation.idempotency_key == key, | |
| ) | |
| ) | |
| if existing is not None and existing.status == "completed": | |
| if reject_completed_duplicate: | |
| _log("usage_duplicate_rejected", user_id=user_id, operation=operation, units=0, policy=policy, remaining=policy.limit) | |
| raise _duplicate_completed_error() | |
| _log("usage_allowed", user_id=user_id, operation=operation, units=0, policy=policy, remaining=policy.limit) | |
| return UsageReservation(existing, duplicate_completed=True) | |
| if existing is not None and existing.status == "reserved": | |
| cutoff = now - timedelta(seconds=get_settings().usage_reservation_ttl_seconds) | |
| updated_at = existing.updated_at | |
| if updated_at.tzinfo is None: | |
| updated_at = updated_at.replace(tzinfo=timezone.utc) | |
| if updated_at >= cutoff: | |
| raise _student_error( | |
| "OPERATION_IN_PROGRESS", | |
| "That request is already running.", | |
| http_status=status.HTTP_409_CONFLICT, | |
| ) | |
| existing.status = "released" | |
| db.add(existing) | |
| db.commit() | |
| # PostgreSQL serializes reservations per user. SQLite remains deterministic | |
| # in local tests through its write lock plus the unique logical key. | |
| db.scalar(select(User.id).where(User.id == user_id).with_for_update()) | |
| used = int( | |
| db.scalar( | |
| select(func.coalesce(func.sum(UsageOperation.units), 0)).where( | |
| UsageOperation.user_id == user_id, | |
| UsageOperation.operation == operation, | |
| UsageOperation.period_key == period_key, | |
| UsageOperation.status.in_(["reserved", "completed"]), | |
| ) | |
| ) | |
| or 0 | |
| ) | |
| if used + units > policy.limit: | |
| _log("usage_denied", user_id=user_id, operation=operation, units=units, policy=policy, remaining=policy.limit - used) | |
| raise _student_error( | |
| "USAGE_LIMIT_REACHED", | |
| "You've reached today's AI study limit. Try again when your allowance resets.", | |
| http_status=status.HTTP_402_PAYMENT_REQUIRED, | |
| ) | |
| # Per-user daily USD budget: a cost backstop on top of the count limits | |
| # above. Denies through the same path/shape as the unit limit. | |
| budget_denial = _daily_budget_denial(db, user_id=user_id, operation=operation, now=now) | |
| if budget_denial is not None: | |
| day_cost, operation_cost, cap = budget_denial | |
| logger.warning( | |
| "usage_budget_denied", | |
| extra={ | |
| "event": "usage_budget_denied", | |
| "operation": operation, | |
| "user_ref": stable_idempotency_key(user_id)[:16], | |
| "estimated_day_cost_usd": round(day_cost, 6), | |
| "estimated_operation_cost_usd": round(operation_cost, 6), | |
| "cap_usd": cap, | |
| }, | |
| ) | |
| raise _student_error( | |
| "USAGE_LIMIT_REACHED", | |
| "You've reached today's AI study budget. Try again when your allowance resets.", | |
| http_status=status.HTTP_402_PAYMENT_REQUIRED, | |
| ) | |
| active = int( | |
| db.scalar( | |
| select(func.count()).select_from(UsageOperation).where( | |
| UsageOperation.user_id == user_id, | |
| UsageOperation.operation == operation, | |
| UsageOperation.status == "reserved", | |
| ) | |
| ) | |
| or 0 | |
| ) | |
| if active >= policy.max_active: | |
| raise _student_error( | |
| "OPERATION_IN_PROGRESS", | |
| "That request is already running.", | |
| http_status=status.HTTP_409_CONFLICT, | |
| ) | |
| if existing is not None: | |
| existing.status = "reserved" | |
| existing.period_key = period_key | |
| existing.units = units | |
| existing.request_id = request_id_ctx_var.get("-") | |
| existing.completed_at = None | |
| record = existing | |
| else: | |
| record = UsageOperation( | |
| user_id=user_id, | |
| operation=operation, | |
| period_key=period_key, | |
| idempotency_key=key, | |
| units=units, | |
| status="reserved", | |
| request_id=request_id_ctx_var.get("-"), | |
| ) | |
| db.add(record) | |
| try: | |
| db.commit() | |
| db.refresh(record) | |
| except IntegrityError: | |
| db.rollback() | |
| raced = db.scalar( | |
| select(UsageOperation).where( | |
| UsageOperation.user_id == user_id, | |
| UsageOperation.operation == operation, | |
| UsageOperation.idempotency_key == key, | |
| ) | |
| ) | |
| if raced is not None and raced.status == "completed": | |
| if reject_completed_duplicate: | |
| raise _duplicate_completed_error() | |
| return UsageReservation(raced, duplicate_completed=True) | |
| raise _student_error( | |
| "OPERATION_IN_PROGRESS", | |
| "That request is already running.", | |
| http_status=status.HTTP_409_CONFLICT, | |
| ) | |
| _log("usage_allowed", user_id=user_id, operation=operation, units=units, policy=policy, remaining=policy.limit - used - units) | |
| return UsageReservation(record) | |
| def finalize_usage(db: Session, reservation: UsageReservation, *, resource_id: str | None = None) -> None: | |
| if not reservation.should_charge: | |
| return | |
| db.execute( | |
| update(UsageOperation) | |
| .where(UsageOperation.id == reservation.record.id, UsageOperation.status == "reserved") | |
| .values(status="completed", completed_at=datetime.now(timezone.utc), resource_id=resource_id) | |
| ) | |
| db.commit() | |
| entitlement = resolve_entitlements(db, reservation.record.user_id) | |
| policy = _policies(entitlement.usage_policy_key)[reservation.record.operation] | |
| _log("usage_recorded", user_id=reservation.record.user_id, operation=reservation.record.operation, units=reservation.record.units, policy=policy, remaining=policy.limit) | |
| def release_usage(db: Session, reservation: UsageReservation) -> None: | |
| if not reservation.should_charge: | |
| return | |
| db.execute( | |
| update(UsageOperation) | |
| .where(UsageOperation.id == reservation.record.id, UsageOperation.status == "reserved") | |
| .values(status="released", completed_at=None) | |
| ) | |
| db.commit() | |
| entitlement = resolve_entitlements(db, reservation.record.user_id) | |
| policy = _policies(entitlement.usage_policy_key)[reservation.record.operation] | |
| _log("usage_reservation_released", user_id=reservation.record.user_id, operation=reservation.record.operation, units=reservation.record.units, policy=policy, remaining=policy.limit) | |
| def get_usage_status(db: Session, user_id: str) -> dict[str, Any]: | |
| entitlement = resolve_entitlements(db, user_id) | |
| items: list[dict[str, Any]] = [] | |
| for operation, policy in _policies(entitlement.usage_policy_key).items(): | |
| period_key = _period_key(policy, datetime.now(timezone.utc)) | |
| used = int( | |
| db.scalar( | |
| select(func.coalesce(func.sum(UsageOperation.units), 0)).where( | |
| UsageOperation.user_id == user_id, | |
| UsageOperation.operation == operation, | |
| UsageOperation.period_key == period_key, | |
| UsageOperation.status.in_(["reserved", "completed"]), | |
| ) | |
| ) | |
| or 0 | |
| ) | |
| items.append( | |
| { | |
| "operation": operation, | |
| "period": policy.period, | |
| "used": used, | |
| "limit": policy.limit, | |
| "remaining": max(0, policy.limit - used), | |
| "allowed": used < policy.limit, | |
| } | |
| ) | |
| return { | |
| "tier": entitlement.tier, | |
| "entitlement_status": entitlement.status, | |
| "usage_policy_key": entitlement.usage_policy_key, | |
| "items": items, | |
| } | |