Files
resolutionflow/backend/app/core/ai_quota_service.py
chihlasm 4d2c4930fd feat: Slate & Ice Modern aesthetic redesign (#94)
* chore: update Google Fonts to Bricolage Grotesque, IBM Plex Sans, JetBrains Mono

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update Tailwind config to Slate & Ice theme colors and fonts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update CSS variables and glass-card utilities for Slate & Ice theme

- Replace all color variables with Slate & Ice palette
- Add glass system vars (--glass-bg, --glass-blur, --shadow-float)
- Replace legacy glass-card with new variable-driven glass classes
- Add breatheGlow, bellWobble, slideDown, fadeInRight keyframes
- Update font references to IBM Plex Sans and Bricolage Grotesque

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: recolor BrandLogo to cyan gradient, split BrandWordmark for gradient Flow text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update TopBar with glassmorphism backdrop and cyan accent styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update Sidebar with glassmorphism backdrop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ambient atmosphere gradient orbs behind app shell

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update QuickStats and SessionsPanel with glass-card styling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add WeeklyCalendar, QuickActions, OpenSessions, RecentActivity dashboard components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: redesign dashboard layout with calendar, open sessions, and glass-card panels

New layout: greeting → calendar+actions → sessions+stats → activity
Replaces old QuickStats and SessionsPanel with new dashboard components

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace remaining purple hex references with ice-cyan accent

Sweep of hardcoded purple hex values (#818cf8, #6366f1) replaced with
new cyan accent (#06b6d4) in QuickActions, RecentActivity, QuickLaunch,
and SVG brand assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md branding and design system for Slate & Ice Modern

Updated Last Updated date, branding section (fonts, colors, glass
utilities, atmosphere orbs), component styling rules, and Design System
section to reflect the new ice-cyan glassmorphism theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add Slate & Ice Modern design doc and implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: redesign login page with Slate & Ice Modern design system

Apply glassmorphism styling, atmosphere orbs, branded wordmark, and
consistent design tokens to match the updated app shell aesthetic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: raise TopBar z-index so profile dropdown renders above main content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI assistant with in-session copilot and standalone chat with RAG

Implements three-phase AI assistant feature:
- Phase 0: RAG infrastructure with pgvector embeddings, Voyage AI integration,
  tree chunking service, and semantic search over team's flow library
- Phase 1: In-session copilot panel during flow navigation with contextual
  AI help, current step awareness, and suggested related flows
- Phase 2: Standalone AI chat page with persistent conversation history,
  pin/delete, and configurable retention policies (account-level)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add account management, email verification, AI fixes, and user guides

- Profile settings, account transfer, delete/leave account flows
- Email verification with JWT tokens and Resend integration
- AI assistant/copilot fixes: markdown rendering, shared RAG helpers,
  token tracking, input refocus, model_validate usage
- User guides hub + detail pages with 13 topic guides
- Sidebar and top bar navigation for guides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent stale chunk errors after deployments

- Set Cache-Control no-cache on index.html in nginx so browsers always
  fetch fresh chunk references after a deploy
- Auto-reload on chunk load failures (stale deploy detection) with
  loop prevention via sessionStorage
- Show friendly "App Updated" message if auto-reload doesn't resolve it

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add email verification toggle to admin settings

Adds platform-level toggle to enable/disable email verification.
When disabled, the verification banner is hidden and the send
endpoint returns 403.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:44:25 -05:00

193 lines
6.4 KiB
Python

"""AI generation quota management.
Enforces monthly and daily limits on AI flow builder usage.
Monthly quota consumed only on successful tree assembly (counts_toward_quota=True).
Daily limit is an anti-abuse guard consumed on conversation start.
"""
import calendar
from datetime import datetime, timezone, timedelta
from typing import Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai_usage import AIUsage
from app.models.plan_limits import PlanLimits
from app.models.account_limit_override import AccountLimitOverride
from app.core.subscriptions import get_account_subscription, get_plan_limits
async def get_user_plan(account_id: Optional[UUID], db: AsyncSession) -> str:
"""Get the plan tier for an account."""
if not account_id:
return "free"
sub = await get_account_subscription(account_id, db)
if sub is None:
return "free"
return sub.plan if sub.plan else "free"
async def _get_effective_limits(
account_id: UUID, plan: str, db: AsyncSession
) -> tuple[Optional[int], Optional[int]]:
"""Get effective AI limits (monthly, daily), applying account overrides.
Returns (monthly_limit, daily_limit). None means unlimited.
"""
limits = await get_plan_limits(plan, db)
monthly = limits.max_ai_builds_per_month if limits else None
daily = limits.max_ai_builds_per_24h if limits else None
# Check for account-level overrides
result = await db.execute(
select(AccountLimitOverride).where(
AccountLimitOverride.account_id == account_id
)
)
override = result.scalar_one_or_none()
if override:
if override.override_max_ai_builds_per_month is not None:
monthly = override.override_max_ai_builds_per_month
if override.override_max_ai_builds_per_24h is not None:
daily = override.override_max_ai_builds_per_24h
return monthly, daily
def _get_billing_anchor_month_start(anchor: Optional[datetime]) -> datetime:
"""Calculate the start of the current billing month from the anchor date.
If the anchor is day 15, the billing month runs from the 15th of each month.
Falls back to calendar month if anchor is None.
"""
now = datetime.now(timezone.utc)
if not anchor:
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
anchor_day = min(anchor.day, 28) # Clamp to avoid month overflow
this_month_anchor = now.replace(
day=anchor_day, hour=0, minute=0, second=0, microsecond=0
)
if now >= this_month_anchor:
return this_month_anchor
else:
# We're before the anchor day, so billing month started last month
if now.month == 1:
return this_month_anchor.replace(year=now.year - 1, month=12)
else:
return this_month_anchor.replace(month=now.month - 1)
async def check_ai_quota(
user_id: UUID,
account_id: UUID,
db: AsyncSession,
billing_anchor: Optional[datetime] = None,
is_super_admin: bool = False,
) -> tuple[bool, dict]:
"""Check if user can make an AI generation.
Returns (allowed, quota_status_dict).
Monthly counts only rows with counts_toward_quota=True.
Daily counts only rows with generation_type in ('scaffold', 'branch_detail').
Super admins bypass all limits.
"""
plan = await get_user_plan(account_id, db)
monthly_limit, daily_limit = await _get_effective_limits(account_id, plan, db)
now = datetime.now(timezone.utc)
month_start = _get_billing_anchor_month_start(billing_anchor)
day_start = now - timedelta(hours=24)
# Monthly: count successful quota-consuming records
monthly_count = await db.scalar(
select(func.count(AIUsage.id)).where(
AIUsage.user_id == user_id,
AIUsage.counts_toward_quota == True, # noqa: E712
AIUsage.created_at >= month_start,
)
) or 0
# Daily: count all AI API calls (scaffold + branch_detail) in last 24h
daily_count = await db.scalar(
select(func.count(AIUsage.id)).where(
AIUsage.user_id == user_id,
AIUsage.succeeded == True, # noqa: E712
AIUsage.generation_type.in_(["scaffold", "branch_detail", "chat_message", "chat_generate", "copilot_message", "assistant_message"]),
AIUsage.created_at >= day_start,
)
) or 0
allowed = True
deny_reason = None
if is_super_admin:
# Super admins bypass all limits
monthly_limit = None
daily_limit = None
if monthly_limit is not None and monthly_count >= monthly_limit:
allowed = False
deny_reason = "monthly"
if daily_limit is not None and daily_count >= daily_limit:
allowed = False
deny_reason = "daily"
# Calculate reset timestamps
next_month = month_start.month % 12 + 1
next_year = month_start.year + (1 if month_start.month == 12 else 0)
max_day = calendar.monthrange(next_year, next_month)[1]
monthly_reset_at = month_start.replace(
month=next_month,
year=next_year,
day=min(month_start.day, max_day),
)
daily_reset_at = day_start + timedelta(hours=24)
return allowed, {
"plan": plan,
"monthly_used": monthly_count,
"monthly_limit": monthly_limit,
"monthly_reset_at": monthly_reset_at.isoformat(),
"daily_used": daily_count,
"daily_limit": daily_limit,
"daily_reset_at": daily_reset_at.isoformat(),
"allowed": allowed,
"deny_reason": deny_reason,
}
async def record_ai_usage(
user_id: UUID,
account_id: UUID,
conversation_id: Optional[UUID],
generation_type: str,
tier: str,
input_tokens: int,
output_tokens: int,
estimated_cost: float,
succeeded: bool,
counts_toward_quota: bool,
error_code: Optional[str],
extra_data: Optional[dict],
db: AsyncSession,
) -> AIUsage:
"""Record an AI usage entry."""
usage = AIUsage(
user_id=user_id,
account_id=account_id,
conversation_id=conversation_id,
generation_type=generation_type,
tier_at_time=tier,
input_tokens=input_tokens,
output_tokens=output_tokens,
estimated_cost_usd=estimated_cost,
succeeded=succeeded,
counts_toward_quota=counts_toward_quota,
error_code=error_code,
extra_data=extra_data or {},
)
db.add(usage)
await db.flush()
return usage