Add AI usage limits with progress bars and higher-limits request
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions

Replace old Pro-only cost limits with unified system for all models:
- Per-user limits: $0.15/day, $0.50/week, $1.00/month (degressive)
- Global portal budget: $25/month (~100 PLN) tracked and displayed
- Two progress bars in chat header: personal daily + global portal usage
- Color-coded bars (green→yellow→red at 60%/90%)
- Limit exceeded banner with "request higher limits" button
- Backend endpoint logs requests for admin review
- Flash model recommended as default (economical)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-03-25 13:58:52 +01:00
parent a3e5407a87
commit 2776a371b3
2 changed files with 365 additions and 93 deletions

View File

@ -6,7 +6,7 @@ AI Chat interface, API, and analytics.
"""
import logging
from datetime import datetime, date
from datetime import datetime, date, timedelta
from flask import render_template, request, redirect, url_for, flash, jsonify, session
from flask_login import login_required, current_user
@ -23,6 +23,101 @@ from utils.decorators import member_required
# Logger
logger = logging.getLogger(__name__)
# ============================================================
# AI COST LIMITS
# ============================================================
# Global budget: 100 PLN/month (~$25) for all users except UNLIMITED_USERS.
# Per-user limits are degressive (weekly < 7x daily, monthly < 4x weekly).
UNLIMITED_USERS = ['maciej.pienczyn@inpi.pl']
# Per-user limits in USD (both Flash and Pro combined)
USER_DAILY_LIMIT = 0.15 # ~2-3 Flash queries/day
USER_WEEKLY_LIMIT = 0.50 # ~9 Flash queries/week (not 7x daily)
USER_MONTHLY_LIMIT = 1.00 # ~18 Flash queries/month (not 4x weekly)
GLOBAL_MONTHLY_BUDGET = 25.00 # $25 = ~100 PLN
def get_user_usage(user_id):
"""Calculate user's AI cost usage for current day, week, and month, plus global usage."""
now = datetime.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=today_start.weekday()) # Monday
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
db = SessionLocal()
try:
# User's costs this month
monthly_costs = db.query(AIAPICostLog).filter(
AIAPICostLog.user_id == user_id,
AIAPICostLog.timestamp >= month_start,
AIAPICostLog.feature == 'ai_chat'
).all()
daily_total = sum(float(c.total_cost or 0) for c in monthly_costs if c.timestamp >= today_start)
weekly_total = sum(float(c.total_cost or 0) for c in monthly_costs if c.timestamp >= week_start)
monthly_total = sum(float(c.total_cost or 0) for c in monthly_costs)
# Global portal usage this month (all users except unlimited)
from database import User
unlimited_ids = db.query(User.id).filter(User.email.in_(UNLIMITED_USERS)).all()
unlimited_ids = [uid[0] for uid in unlimited_ids]
global_q = db.query(func.coalesce(func.sum(AIAPICostLog.total_cost), 0)).filter(
AIAPICostLog.timestamp >= month_start,
AIAPICostLog.feature == 'ai_chat'
)
if unlimited_ids:
global_q = global_q.filter(~AIAPICostLog.user_id.in_(unlimited_ids))
global_total = float(global_q.scalar() or 0)
return {
'daily': round(daily_total, 4),
'weekly': round(weekly_total, 4),
'monthly': round(monthly_total, 4),
'daily_limit': USER_DAILY_LIMIT,
'weekly_limit': USER_WEEKLY_LIMIT,
'monthly_limit': USER_MONTHLY_LIMIT,
'daily_percent': round(min(daily_total / USER_DAILY_LIMIT * 100, 100), 1) if USER_DAILY_LIMIT > 0 else 0,
'weekly_percent': round(min(weekly_total / USER_WEEKLY_LIMIT * 100, 100), 1) if USER_WEEKLY_LIMIT > 0 else 0,
'monthly_percent': round(min(monthly_total / USER_MONTHLY_LIMIT * 100, 100), 1) if USER_MONTHLY_LIMIT > 0 else 0,
'global_monthly': round(global_total, 4),
'global_monthly_limit': GLOBAL_MONTHLY_BUDGET,
'global_monthly_percent': round(min(global_total / GLOBAL_MONTHLY_BUDGET * 100, 100), 1) if GLOBAL_MONTHLY_BUDGET > 0 else 0,
}
finally:
db.close()
def check_user_limits(user_id, user_email):
"""Check if user has exceeded any limit. Returns (exceeded, message) tuple."""
if user_email in UNLIMITED_USERS:
return False, None
usage = get_user_usage(user_id)
if usage['monthly'] >= USER_MONTHLY_LIMIT:
return True, {
'error': 'Osiągnięto miesięczny limit zapytań AI. Jeśli potrzebujesz więcej, skontaktuj się z administratorem.',
'limit_exceeded': 'monthly',
'usage': usage
}
if usage['weekly'] >= USER_WEEKLY_LIMIT:
return True, {
'error': 'Osiągnięto tygodniowy limit zapytań AI. Limit odnawia się w poniedziałek.',
'limit_exceeded': 'weekly',
'usage': usage
}
if usage['daily'] >= USER_DAILY_LIMIT:
return True, {
'error': 'Osiągnięto dzienny limit zapytań AI. Spróbuj ponownie jutro.',
'limit_exceeded': 'daily',
'usage': usage
}
return False, None
# ============================================================
# AI CHAT ROUTES
@ -42,30 +137,26 @@ def chat():
@login_required
@member_required
def chat_settings():
"""Get or update chat settings (model selection, monthly cost)"""
"""Get or update chat settings (model selection, usage limits)"""
if request.method == 'GET':
# Get current model from session or default to flash-lite
model = session.get('chat_model', 'flash')
is_unlimited = current_user.email in UNLIMITED_USERS
# Calculate monthly cost for current user
monthly_cost = 0.0
try:
db = SessionLocal()
# Get first day of current month
first_day = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
costs = db.query(AIAPICostLog).filter(
AIAPICostLog.user_id == current_user.id,
AIAPICostLog.timestamp >= first_day
).all()
monthly_cost = sum(float(c.total_cost or 0) for c in costs)
db.close()
usage = get_user_usage(current_user.id)
except Exception as e:
logger.warning(f"Error calculating monthly cost: {e}")
logger.warning(f"Error calculating usage: {e}")
usage = {'daily': 0, 'weekly': 0, 'monthly': 0,
'daily_limit': USER_DAILY_LIMIT, 'weekly_limit': USER_WEEKLY_LIMIT,
'monthly_limit': USER_MONTHLY_LIMIT,
'daily_percent': 0, 'weekly_percent': 0, 'monthly_percent': 0}
return jsonify({
'success': True,
'model': model,
'monthly_cost': round(monthly_cost, 4)
'monthly_cost': round(usage['monthly'], 4),
'usage': usage,
'is_unlimited': is_unlimited
})
# POST - update settings
@ -93,6 +184,27 @@ def chat_settings():
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/chat/request-higher-limits', methods=['POST'])
@login_required
@member_required
def chat_request_higher_limits():
"""User requests higher AI limits — logs the request for admin review."""
try:
usage = get_user_usage(current_user.id)
logger.info(
f"HIGHER_LIMITS_REQUEST: User {current_user.id} ({current_user.name}, {current_user.email}) "
f"requested higher AI limits. Current usage: daily=${usage['daily']}, "
f"weekly=${usage['weekly']}, monthly=${usage['monthly']}"
)
return jsonify({
'success': True,
'message': 'Twoje zgłoszenie zostało zarejestrowane. Administrator skontaktuje się z Tobą w sprawie indywidualnych limitów.'
})
except Exception as e:
logger.error(f"Error logging higher limits request: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/chat/start', methods=['POST'])
@login_required
@member_required
@ -147,50 +259,10 @@ def chat_send_message(conversation_id):
# Get model from request or session (flash = default with thinking, pro = premium)
model_choice = data.get('model') or session.get('chat_model', 'flash')
# Check Pro model limits (Flash is free - no limits)
if model_choice == 'pro':
# Users without limits (admins)
UNLIMITED_USERS = ['maciej.pienczyn@inpi.pl', 'artur.wiertel@waterm.pl']
if current_user.email not in UNLIMITED_USERS:
# Check daily and monthly limits for Pro
db_check = SessionLocal()
try:
# Daily limit: $2.00
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
daily_costs = db_check.query(AIAPICostLog).filter(
AIAPICostLog.user_id == current_user.id,
AIAPICostLog.timestamp >= today_start,
AIAPICostLog.model_name.like('%pro%')
).all()
daily_total = sum(float(c.total_cost or 0) for c in daily_costs)
if daily_total >= 2.0:
return jsonify({
'success': False,
'error': 'Osiągnięto dzienny limit Pro ($2.00). Spróbuj jutro lub użyj darmowego modelu Flash.',
'limit_exceeded': 'daily',
'daily_used': round(daily_total, 2)
}), 429
# Monthly limit: $20.00
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
monthly_costs = db_check.query(AIAPICostLog).filter(
AIAPICostLog.user_id == current_user.id,
AIAPICostLog.timestamp >= month_start,
AIAPICostLog.model_name.like('%pro%')
).all()
monthly_total = sum(float(c.total_cost or 0) for c in monthly_costs)
if monthly_total >= 20.0:
return jsonify({
'success': False,
'error': 'Osiągnięto miesięczny limit Pro ($20.00). Użyj darmowego modelu Flash.',
'limit_exceeded': 'monthly',
'monthly_used': round(monthly_total, 2)
}), 429
finally:
db_check.close()
# Check usage limits (applies to all models)
exceeded, limit_msg = check_user_limits(current_user.id, current_user.email)
if exceeded:
return jsonify({'success': False, **limit_msg}), 429
# Map model choice to actual model name and thinking level
model_map = {

View File

@ -937,32 +937,115 @@
}
/* Monthly Cost Badge */
.monthly-cost-badge {
.usage-bars-container {
display: flex;
flex-direction: column;
gap: 3px;
margin-left: var(--spacing-sm);
min-width: 120px;
}
.usage-bar-mini {
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
font-size: 10px;
}
.usage-bar-label {
opacity: 0.7;
min-width: 32px;
text-align: right;
color: rgba(255,255,255,0.8);
}
.usage-bar-track {
flex: 1;
height: 6px;
background: rgba(255,255,255,0.15);
padding: 6px 12px;
border-radius: var(--radius-md);
font-size: 12px;
margin-left: var(--spacing-sm);
border-radius: 3px;
overflow: hidden;
min-width: 50px;
}
.monthly-cost-badge .cost-icon {
font-size: 14px;
.usage-bar-track.global {
background: rgba(255,255,255,0.1);
}
.monthly-cost-badge .cost-label {
opacity: 0.8;
.usage-bar-fill {
height: 100%;
border-radius: 3px;
background: #4ade80;
transition: width 0.5s ease, background 0.3s ease;
}
.monthly-cost-badge .cost-value {
.usage-bar-fill.warning {
background: #fbbf24;
}
.usage-bar-fill.danger {
background: #f87171;
}
.usage-bar-fill.global {
background: #60a5fa;
}
.usage-bar-fill.global.warning {
background: #fbbf24;
}
.usage-bar-fill.global.danger {
background: #f87171;
}
.usage-bar-pct {
min-width: 24px;
text-align: right;
font-weight: 600;
color: #fef08a;
color: rgba(255,255,255,0.9);
font-size: 9px;
}
/* Limit exceeded banner */
.limit-exceeded-banner {
display: none;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #92400e;
padding: 12px 16px;
border-radius: var(--radius);
margin: 8px 16px;
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.limit-exceeded-banner.active {
display: block;
}
.limit-exceeded-banner .limit-btn {
display: inline-block;
margin-top: 8px;
padding: 6px 16px;
background: #92400e;
color: white;
border: none;
border-radius: var(--radius);
font-size: 12px;
cursor: pointer;
font-weight: 600;
}
.limit-exceeded-banner .limit-btn:hover {
background: #78350f;
}
@media (max-width: 768px) {
.monthly-cost-badge .cost-label {
.usage-bars-container {
min-width: 80px;
}
.usage-bar-label {
display: none;
}
}
@ -1449,10 +1532,10 @@
<div class="thinking-option-header">
<span class="thinking-option-icon"></span>
<span class="thinking-option-name">Flash</span>
<span class="thinking-option-badge">Domyślny</span>
<span class="thinking-option-badge">Zalecany</span>
</div>
<p class="thinking-option-desc">Thinking mode — szybki i inteligentny. Dla pytań o firmy, kontakty, strategie.</p>
<span class="thinking-option-price">~$0.04/pytanie · 10 000 zapytań/dzień</span>
<p class="thinking-option-desc">Szybki i inteligentny. Dla pytań o firmy, kontakty, strategie.</p>
<span class="thinking-option-price">Ekonomiczny — zalecany do codziennego użytku</span>
</div>
<div class="thinking-option" data-model="pro" onclick="setModel('pro')">
<div class="thinking-option-header">
@ -1460,16 +1543,27 @@
<span class="thinking-option-name">Pro</span>
<span class="thinking-option-badge premium">Premium</span>
</div>
<p class="thinking-option-desc">Najlepsza analiza i rozumowanie. Dla złożonych raportów i rekomendacji.</p>
<span class="thinking-option-price">~$0.20/pytanie · limit: $2/dzień</span>
<p class="thinking-option-desc">Najlepsza analiza i rozumowanie. Dla złożonych raportów.</p>
<span class="thinking-option-price">~4x droższy — szybciej zużywa limit</span>
</div>
</div>
</div>
<!-- Monthly cost display -->
<div class="monthly-cost-badge" title="Twoje koszty AI w tym miesiącu">
<span class="cost-icon">💰</span>
<span class="cost-label">Ten miesiąc:</span>
<span class="cost-value" id="monthlyCostDisplay">$0.00</span>
<!-- Usage bars -->
<div class="usage-bars-container" id="usageBarsContainer" title="Zużycie limitu AI">
<div class="usage-bar-mini" title="Twój limit dzienny">
<span class="usage-bar-label">Dziś</span>
<div class="usage-bar-track">
<div class="usage-bar-fill" id="dailyUsageBar" style="width: 0%"></div>
</div>
<span class="usage-bar-pct" id="dailyUsagePct">0%</span>
</div>
<div class="usage-bar-mini" title="Zużycie wszystkich użytkowników portalu w tym miesiącu">
<span class="usage-bar-label">Portal</span>
<div class="usage-bar-track global">
<div class="usage-bar-fill global" id="globalUsageBar" style="width: 0%"></div>
</div>
<span class="usage-bar-pct" id="globalUsagePct">0%</span>
</div>
</div>
<button class="model-info-btn" onclick="openModelInfoModal()" title="Informacje o modelu AI">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" width="16" height="16">
@ -1485,6 +1579,13 @@
</div>
</header>
<!-- Limit exceeded banner -->
<div class="limit-exceeded-banner" id="limitExceededBanner">
<span id="limitExceededMsg"></span>
<br>
<button class="limit-btn" onclick="requestHigherLimits()">Jestem zainteresowany wyższymi limitami</button>
</div>
<!-- Modal z informacjami o modelu AI i historią rozwoju -->
<div class="model-info-modal" id="modelInfoModal">
<div class="model-info-content">
@ -1505,8 +1606,12 @@
</div>
</div>
<p class="model-description">
<strong>Flash</strong> — Gemini 3 Flash z thinking mode, 10 000 zapytań/dzień.
<strong>Pro</strong> — Gemini 3 Pro, najlepsza analiza dla złożonych pytań.
<strong>Flash</strong> — Gemini 3 Flash z thinking mode. Zalecany do codziennego użytku.
<strong>Pro</strong> — Gemini 3 Pro, najlepsza analiza. ~4x droższy, szybciej zużywa limit.
</p>
<p class="model-description" style="margin-top: 8px; font-size: 12px; color: var(--text-secondary);">
Każdy użytkownik ma indywidualny limit zapytań (dzienny, tygodniowy, miesięczny).
Jeśli potrzebujesz więcej, kliknij przycisk „Jestem zainteresowany wyższymi limitami".
</p>
</div>
@ -1775,9 +1880,89 @@ async function saveModelPreference(model) {
function updateMonthlyCost(cost) {
monthlyUsageCost += cost;
const costDisplay = document.getElementById('monthlyCostDisplay');
if (costDisplay) {
costDisplay.textContent = '$' + monthlyUsageCost.toFixed(2);
// Refresh usage bars after each message
refreshUsageBars();
}
function updateUsageBars(usage) {
if (!usage) return;
// Daily bar (user)
const dailyBar = document.getElementById('dailyUsageBar');
const dailyPct = document.getElementById('dailyUsagePct');
if (dailyBar && dailyPct) {
const dp = Math.min(usage.daily_percent || 0, 100);
dailyBar.style.width = dp + '%';
dailyPct.textContent = Math.round(dp) + '%';
dailyBar.className = 'usage-bar-fill' + (dp >= 90 ? ' danger' : dp >= 60 ? ' warning' : '');
}
// Global bar (portal)
const globalBar = document.getElementById('globalUsageBar');
const globalPct = document.getElementById('globalUsagePct');
if (globalBar && globalPct) {
const gp = Math.min(usage.global_monthly_percent || 0, 100);
globalBar.style.width = gp + '%';
globalPct.textContent = Math.round(gp) + '%';
globalBar.className = 'usage-bar-fill global' + (gp >= 90 ? ' danger' : gp >= 60 ? ' warning' : '');
}
// Check if any limit is close or exceeded
const maxPct = Math.max(usage.daily_percent || 0, usage.weekly_percent || 0, usage.monthly_percent || 0);
if (maxPct >= 100) {
showLimitBanner(usage);
}
}
function showLimitBanner(usage) {
const banner = document.getElementById('limitExceededBanner');
const msg = document.getElementById('limitExceededMsg');
if (!banner || !msg) return;
let text = '';
if ((usage.monthly_percent || 0) >= 100) {
text = 'Osiągnięto miesięczny limit zapytań AI. Limit odnawia się 1. dnia kolejnego miesiąca.';
} else if ((usage.weekly_percent || 0) >= 100) {
text = 'Osiągnięto tygodniowy limit zapytań AI. Limit odnawia się w poniedziałek.';
} else if ((usage.daily_percent || 0) >= 100) {
text = 'Osiągnięto dzienny limit zapytań AI. Spróbuj ponownie jutro.';
}
if (text) {
msg.textContent = text;
banner.classList.add('active');
}
}
async function requestHigherLimits() {
try {
const response = await fetch('/api/chat/request-higher-limits', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }
});
const data = await response.json();
if (data.success) {
const btn = document.querySelector('.limit-exceeded-banner .limit-btn');
if (btn) {
btn.textContent = 'Zgłoszenie wysłane';
btn.disabled = true;
btn.style.opacity = '0.6';
}
}
} catch (error) {
console.error('Error requesting higher limits:', error);
}
}
async function refreshUsageBars() {
try {
const response = await fetch('/api/chat/settings');
const data = await response.json();
if (data.success && data.usage) {
updateUsageBars(data.usage);
}
} catch (e) {
console.log('Could not refresh usage bars');
}
}
@ -1857,13 +2042,13 @@ document.addEventListener('DOMContentLoaded', function() {
loadModelSettings();
});
// Load model settings and monthly cost from server
// Load model settings and usage from server
async function loadModelSettings() {
try {
const response = await fetch('/api/chat/settings');
const data = await response.json();
if (data.success) {
// Always start with Flash (Gemini 3 Flash, thinking mode) - ignore saved preference
// Always start with Flash - ignore saved preference
currentModel = 'flash';
const config = MODEL_CONFIG['flash'];
document.getElementById('modelLabel').textContent = config.label;
@ -1871,10 +2056,17 @@ async function loadModelSettings() {
document.querySelectorAll('.thinking-option').forEach(opt => {
opt.classList.toggle('active', opt.dataset.model === 'flash');
});
// Load monthly cost
// Load usage bars
if (data.monthly_cost !== undefined) {
monthlyUsageCost = data.monthly_cost;
document.getElementById('monthlyCostDisplay').textContent = '$' + monthlyUsageCost.toFixed(2);
}
if (data.usage) {
updateUsageBars(data.usage);
}
// Hide usage bars for unlimited users
if (data.is_unlimited) {
const container = document.getElementById('usageBarsContainer');
if (container) container.style.display = 'none';
}
}
} catch (error) {
@ -2219,8 +2411,16 @@ async function sendMessage() {
if (data.success) {
addMessage('assistant', data.message, true, data.tech_info);
// Reload conversations to update list
loadConversations();
// Update cost if available
if (data.tech_info && data.tech_info.cost_usd) {
updateMonthlyCost(data.tech_info.cost_usd);
}
} else if (data.limit_exceeded) {
// Show limit banner and usage info
if (data.usage) updateUsageBars(data.usage);
showLimitBanner(data.usage || {daily_percent: 100});
addMessage('assistant', data.error);
} else {
addMessage('assistant', 'Przepraszam, wystąpił błąd: ' + (data.error || 'Nieznany błąd'));
}