From bfe1cd897c500727385409ccf9cb574462c93ffa Mon Sep 17 00:00:00 2001 From: Maciej Pienczyn Date: Sun, 11 Jan 2026 08:21:07 +0100 Subject: [PATCH] feat: Add AI usage monitoring dashboard - Add AIUsageLog, AIUsageDaily, AIRateLimit models to database.py - Update gemini_service.py to log to new AIUsageLog table - Create /admin/ai-usage dashboard with stats and charts - Show daily/weekly/monthly requests, tokens, costs - Track usage by type (chat, news_evaluation, etc.) Co-Authored-By: Claude Opus 4.5 --- app.py | 124 +++++++ database.py | 115 ++++++- gemini_service.py | 64 +++- templates/admin/ai_usage_dashboard.html | 432 ++++++++++++++++++++++++ 4 files changed, 721 insertions(+), 14 deletions(-) create mode 100644 templates/admin/ai_usage_dashboard.html diff --git a/app.py b/app.py index 1f8cbfa..1a92add 100644 --- a/app.py +++ b/app.py @@ -5719,6 +5719,130 @@ def chat_analytics(): db.close() +@app.route('/admin/ai-usage') +@login_required +def admin_ai_usage(): + """Admin dashboard for AI (Gemini) API usage monitoring""" + if not current_user.is_admin: + flash('Brak uprawnień do tej strony.', 'error') + return redirect(url_for('dashboard')) + + from database import AIUsageLog, AIUsageDaily + from sqlalchemy import func, desc + from datetime import timedelta + + db = SessionLocal() + try: + now = datetime.now() + today = now.date() + week_ago = today - timedelta(days=7) + month_ago = today - timedelta(days=30) + day_ago = now - timedelta(hours=24) + + # Today's stats + today_stats = db.query( + func.count(AIUsageLog.id).label('requests'), + func.coalesce(func.sum(AIUsageLog.tokens_input), 0).label('tokens_input'), + func.coalesce(func.sum(AIUsageLog.tokens_output), 0).label('tokens_output'), + func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents') + ).filter( + func.date(AIUsageLog.created_at) == today + ).first() + + # Week stats + week_requests = db.query(func.count(AIUsageLog.id)).filter( + func.date(AIUsageLog.created_at) >= week_ago + ).scalar() or 0 + + # Month stats + month_stats = db.query( + func.count(AIUsageLog.id).label('requests'), + func.coalesce(func.sum(AIUsageLog.cost_cents), 0).label('cost_cents') + ).filter( + func.date(AIUsageLog.created_at) >= month_ago + ).first() + + # Error rate (last 24h) + last_24h_total = db.query(func.count(AIUsageLog.id)).filter( + AIUsageLog.created_at >= day_ago + ).scalar() or 0 + + last_24h_errors = db.query(func.count(AIUsageLog.id)).filter( + AIUsageLog.created_at >= day_ago, + AIUsageLog.success == False + ).scalar() or 0 + + error_rate = (last_24h_errors / last_24h_total * 100) if last_24h_total > 0 else 0 + + # Average response time (last 24h) + avg_response_time = db.query(func.avg(AIUsageLog.response_time_ms)).filter( + AIUsageLog.created_at >= day_ago, + AIUsageLog.success == True + ).scalar() or 0 + + # Usage by type (last 30 days) + type_stats = db.query( + AIUsageLog.request_type, + func.count(AIUsageLog.id).label('count') + ).filter( + func.date(AIUsageLog.created_at) >= month_ago + ).group_by(AIUsageLog.request_type).order_by(desc('count')).all() + + # Calculate percentages for type breakdown + total_type_count = sum(t.count for t in type_stats) if type_stats else 0 + type_labels = { + 'chat': ('Chat AI', 'chat'), + 'news_evaluation': ('Ocena newsów', 'news'), + 'user_creation': ('Tworzenie user', 'user'), + 'image_analysis': ('Analiza obrazu', 'image'), + 'general': ('Ogólne', 'other') + } + usage_by_type = [] + for t in type_stats: + label, css_class = type_labels.get(t.request_type, (t.request_type, 'other')) + percentage = (t.count / total_type_count * 100) if total_type_count > 0 else 0 + usage_by_type.append({ + 'type': t.request_type, + 'type_label': label, + 'type_class': css_class, + 'count': t.count, + 'percentage': round(percentage, 1) + }) + + # Recent logs + recent_logs = db.query(AIUsageLog).order_by(desc(AIUsageLog.created_at)).limit(20).all() + for log in recent_logs: + label, _ = type_labels.get(log.request_type, (log.request_type, 'other')) + log.type_label = label + + # Daily history (last 14 days) + daily_history = db.query(AIUsageDaily).filter( + AIUsageDaily.date >= today - timedelta(days=14) + ).order_by(desc(AIUsageDaily.date)).all() + + stats = { + 'today_requests': today_stats.requests or 0, + 'today_tokens_input': int(today_stats.tokens_input) or 0, + 'today_tokens_output': int(today_stats.tokens_output) or 0, + 'today_cost': float(today_stats.cost_cents or 0) / 100, + 'week_requests': week_requests, + 'month_requests': month_stats.requests or 0, + 'month_cost': float(month_stats.cost_cents or 0) / 100, + 'error_rate': error_rate, + 'avg_response_time': int(avg_response_time) + } + + return render_template( + 'admin/ai_usage_dashboard.html', + stats=stats, + usage_by_type=usage_by_type, + recent_logs=recent_logs, + daily_history=daily_history + ) + finally: + db.close() + + @app.route('/api/admin/chat-stats') @login_required def api_chat_stats(): diff --git a/database.py b/database.py index 0a74f7e..221c91f 100644 --- a/database.py +++ b/database.py @@ -16,10 +16,11 @@ Models: - GBPAudit: Google Business Profile audit results - ITAudit: IT infrastructure audit results - ITCollaborationMatch: IT collaboration matches between companies +- AIUsageLog, AIUsageDaily, AIRateLimit: AI API usage monitoring Author: Norda Biznes Development Team Created: 2025-11-23 -Updated: 2026-01-09 (IT Audit Tool, IT Collaboration Matching) +Updated: 2026-01-11 (AI Usage Tracking) """ import os @@ -1941,6 +1942,118 @@ class ZOPKNewsFetchJob(Base): user = relationship('User', foreign_keys=[triggered_by_user]) +# ============================================================ +# AI USAGE TRACKING MODELS +# ============================================================ + +class AIUsageLog(Base): + """ + Individual AI API call logs. + Tracks tokens, costs, and performance for each Gemini API request. + """ + __tablename__ = 'ai_usage_logs' + + id = Column(Integer, primary_key=True) + + # Request info + request_type = Column(String(50), nullable=False) # chat, news_evaluation, user_creation, image_analysis + model = Column(String(100), nullable=False) # gemini-2.0-flash, gemini-1.5-pro, etc. + + # Token counts + tokens_input = Column(Integer, default=0) + tokens_output = Column(Integer, default=0) + # Note: tokens_total is a generated column in PostgreSQL + + # Cost (in USD cents for precision) + cost_cents = Column(Numeric(10, 4), default=0) + + # Context + user_id = Column(Integer, ForeignKey('users.id')) + company_id = Column(Integer, ForeignKey('companies.id')) + related_entity_type = Column(String(50)) # zopk_news, chat_message, company, etc. + related_entity_id = Column(Integer) + + # Request details + prompt_length = Column(Integer) + response_length = Column(Integer) + response_time_ms = Column(Integer) # How long the API call took + + # Status + success = Column(Boolean, default=True) + error_message = Column(Text) + + # Timestamps + created_at = Column(DateTime, default=datetime.now) + + # Relationships + user = relationship('User', foreign_keys=[user_id]) + company = relationship('Company', foreign_keys=[company_id]) + + +class AIUsageDaily(Base): + """ + Pre-aggregated daily AI usage statistics. + Auto-updated by PostgreSQL trigger on ai_usage_logs insert. + """ + __tablename__ = 'ai_usage_daily' + + id = Column(Integer, primary_key=True) + date = Column(Date, unique=True, nullable=False) + + # Request counts by type + chat_requests = Column(Integer, default=0) + news_evaluation_requests = Column(Integer, default=0) + user_creation_requests = Column(Integer, default=0) + image_analysis_requests = Column(Integer, default=0) + other_requests = Column(Integer, default=0) + total_requests = Column(Integer, default=0) + + # Token totals + total_tokens_input = Column(Integer, default=0) + total_tokens_output = Column(Integer, default=0) + total_tokens = Column(Integer, default=0) + + # Cost (in USD cents) + total_cost_cents = Column(Numeric(10, 4), default=0) + + # Performance + avg_response_time_ms = Column(Integer) + error_count = Column(Integer, default=0) + + # Timestamps + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class AIRateLimit(Base): + """ + Rate limit tracking for AI API quota management. + """ + __tablename__ = 'ai_rate_limits' + + id = Column(Integer, primary_key=True) + + # Limit type + limit_type = Column(String(50), nullable=False) # daily, hourly, per_minute + limit_scope = Column(String(50), nullable=False) # global, user, ip + scope_identifier = Column(String(255)) # user_id, ip address, or NULL for global + + # Limits + max_requests = Column(Integer, nullable=False) + current_requests = Column(Integer, default=0) + + # Reset + reset_at = Column(DateTime, nullable=False) + + # Timestamps + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = ( + UniqueConstraint('limit_type', 'limit_scope', 'scope_identifier', name='uq_rate_limit'), + ) + + # ============================================================ # DATABASE INITIALIZATION # ============================================================ diff --git a/gemini_service.py b/gemini_service.py index f773ddf..655e0ee 100644 --- a/gemini_service.py +++ b/gemini_service.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) # Database imports for cost tracking try: - from database import SessionLocal, AIAPICostLog + from database import SessionLocal, AIAPICostLog, AIUsageLog DB_AVAILABLE = True except ImportError: logger.warning("Database not available - cost tracking disabled") @@ -101,7 +101,10 @@ class GeminiService: max_tokens: Optional[int] = None, stream: bool = False, feature: str = 'general', - user_id: Optional[int] = None + user_id: Optional[int] = None, + company_id: Optional[int] = None, + related_entity_type: Optional[str] = None, + related_entity_id: Optional[int] = None ) -> str: """ Generate text using Gemini API with automatic cost tracking. @@ -111,10 +114,11 @@ class GeminiService: temperature: Sampling temperature (0.0-1.0). Higher = more creative max_tokens: Maximum tokens to generate (None = model default) stream: Whether to stream the response - feature: Feature name for cost tracking ('ai_coach', 'ai_nutrition', etc.) + feature: Feature name for cost tracking ('chat', 'news_evaluation', etc.) user_id: Optional user ID for cost tracking - user_id: Optional rider ID for cost tracking - event_id: Optional event ID for cost tracking + company_id: Optional company ID for context + related_entity_type: Entity type ('zopk_news', 'chat_message', etc.) + related_entity_id: Entity ID for reference Returns: Generated text response @@ -191,7 +195,10 @@ class GeminiService: latency_ms=latency_ms, success=True, feature=feature, - user_id=user_id + user_id=user_id, + company_id=company_id, + related_entity_type=related_entity_type, + related_entity_id=related_entity_id ) return response_text @@ -209,7 +216,10 @@ class GeminiService: success=False, error_message=str(e), feature=feature, - user_id=user_id + user_id=user_id, + company_id=company_id, + related_entity_type=related_entity_type, + related_entity_id=related_entity_id ) logger.error(f"Gemini API error: {str(e)}") @@ -302,7 +312,10 @@ class GeminiService: success: bool = True, error_message: Optional[str] = None, feature: str = 'general', - user_id: Optional[int] = None + user_id: Optional[int] = None, + company_id: Optional[int] = None, + related_entity_type: Optional[str] = None, + related_entity_id: Optional[int] = None ): """ Log API call costs to database for monitoring @@ -315,10 +328,11 @@ class GeminiService: latency_ms: Response time in milliseconds success: Whether API call succeeded error_message: Error details if failed - feature: Feature name ('ai_coach', 'ai_nutrition', 'test', etc.) + feature: Feature name ('chat', 'news_evaluation', 'user_creation', etc.) user_id: Optional user ID - user_id: Optional rider ID - event_id: Optional event ID + company_id: Optional company ID for context + related_entity_type: Entity type ('zopk_news', 'chat_message', etc.) + related_entity_id: Entity ID for reference """ if not DB_AVAILABLE: return @@ -330,13 +344,17 @@ class GeminiService: output_cost = (output_tokens / 1_000_000) * pricing['output'] total_cost = input_cost + output_cost + # Cost in cents for AIUsageLog (more precise) + cost_cents = total_cost * 100 + # Create prompt hash (for debugging, not storing full prompt for privacy) prompt_hash = hashlib.sha256(prompt.encode()).hexdigest() # Save to database db = SessionLocal() try: - log_entry = AIAPICostLog( + # Log to legacy AIAPICostLog table + legacy_log = AIAPICostLog( timestamp=datetime.now(), api_provider='gemini', model_name=self.model_name, @@ -353,7 +371,27 @@ class GeminiService: latency_ms=latency_ms, prompt_hash=prompt_hash ) - db.add(log_entry) + db.add(legacy_log) + + # Log to new AIUsageLog table (with automatic daily aggregation via trigger) + usage_log = AIUsageLog( + request_type=feature, + model=self.model_name, + tokens_input=input_tokens, + tokens_output=output_tokens, + cost_cents=cost_cents, + user_id=user_id, + company_id=company_id, + related_entity_type=related_entity_type, + related_entity_id=related_entity_id, + prompt_length=len(prompt), + response_length=len(response_text), + response_time_ms=latency_ms, + success=success, + error_message=error_message + ) + db.add(usage_log) + db.commit() logger.info( diff --git a/templates/admin/ai_usage_dashboard.html b/templates/admin/ai_usage_dashboard.html new file mode 100644 index 0000000..2b46896 --- /dev/null +++ b/templates/admin/ai_usage_dashboard.html @@ -0,0 +1,432 @@ +{% extends "base.html" %} + +{% block title %}Monitoring AI - Panel Admina{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + + + +
+
+
{{ stats.today_requests }}
+
Dzisiaj
+
zapytań do AI
+
+
+
{{ stats.week_requests }}
+
Ten tydzień
+
zapytań
+
+
+
{{ stats.month_requests }}
+
Ten miesiąc
+
zapytań
+
+
+
${{ "%.4f"|format(stats.today_cost) }}
+
Koszt dziś
+
USD
+
+
+
${{ "%.4f"|format(stats.month_cost) }}
+
Koszt miesiąc
+
USD
+
+
+
{{ "%.1f"|format(stats.error_rate) }}%
+
Błędy
+
ostatnie 24h
+
+
+ + +
+
+
{{ "{:,}".format(stats.today_tokens_input) }}
+
Tokeny input
+
dzisiaj
+
+
+
{{ "{:,}".format(stats.today_tokens_output) }}
+
Tokeny output
+
dzisiaj
+
+
+
{{ stats.avg_response_time }}ms
+
Avg czas odpowiedzi
+
ostatnie 24h
+
+
+ +
+ +
+

📊 Wykorzystanie wg typu

+ {% if usage_by_type %} +
+ {% for item in usage_by_type %} +
+
{{ item.type_label }}
+
+
+ {{ item.percentage }}% +
+
+
{{ item.count }}
+
+ {% endfor %} +
+ {% else %} +
+
📭
+

Brak danych

+
+ {% endif %} +
+ + +
+

📜 Ostatnie zapytania

+ {% if recent_logs %} +
    + {% for log in recent_logs %} +
  • +
    + {{ log.type_label }} + {% if not log.success %} +
    {{ log.error_message[:50] }}...
    + {% endif %} +
    +
    + {{ log.tokens_input }}+{{ log.tokens_output }} + {{ log.created_at.strftime('%H:%M:%S') }} +
    +
  • + {% endfor %} +
+ {% else %} +
+

Brak ostatnich zapytań

+
+ {% endif %} +
+
+ + +
+

📅 Historia dzienna (ostatnie 14 dni)

+ {% if daily_history %} + + + + + + + + + + + + + + {% for day in daily_history %} + + + + + + + + + + {% endfor %} + +
DataZapytaniaChatNewsTokenyKosztBłędy
{{ day.date.strftime('%d.%m.%Y') }}{{ day.total_requests }}{{ day.chat_requests }}{{ day.news_evaluation_requests }}{{ "{:,}".format(day.total_tokens) }} + + ${{ "%.4f"|format(day.total_cost_cents / 100) }} + + {% if day.error_count > 0 %}{{ day.error_count }}{% else %}-{% endif %}
+ {% else %} +
+
📭
+

Brak historii - dane pojawią się po pierwszym użyciu AI

+
+ {% endif %} +
+{% endblock %}