nordabiz/blueprints/chat/routes.py
Maciej Pienczyn 3bc69f9455
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
feat: update Gemini models — migrate 3-pro to 3.1-pro, add 3.1-flash-lite, remove old SDK
- Replace gemini-3-pro-preview with gemini-3.1-pro-preview (old deprecated March 9)
- Add gemini-3.1-flash-lite-preview as quality fallback in chain
- Remove last google.generativeai import from zopk_knowledge_service.py
- Update pricing, thinking models, and preview models sets
- Keep '3-pro' alias for backward compatibility across codebase

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:45:33 +01:00

489 lines
18 KiB
Python

"""
Chat Routes
===========
AI Chat interface, API, and analytics.
"""
import logging
from datetime import datetime, date
from flask import render_template, request, redirect, url_for, flash, jsonify, session
from flask_login import login_required, current_user
from sqlalchemy import func, desc
from . import bp
from database import (
SessionLocal, AIChatConversation, AIChatMessage, AIChatFeedback, AIAPICostLog,
SystemRole
)
from nordabiz_chat import NordaBizChatEngine
from utils.decorators import member_required
# Logger
logger = logging.getLogger(__name__)
# ============================================================
# AI CHAT ROUTES
# ============================================================
@bp.route('/chat')
@login_required
def chat():
"""AI Chat interface - requires MEMBER role"""
# SECURITY: NordaGPT is only for members (MEMBER role or higher)
if not current_user.has_role(SystemRole.MEMBER):
return render_template('chat_members_only.html'), 403
return render_template('chat.html')
@bp.route('/api/chat/settings', methods=['GET', 'POST'])
@login_required
@member_required
def chat_settings():
"""Get or update chat settings (model selection, monthly cost)"""
if request.method == 'GET':
# Get current model from session or default to flash-lite
model = session.get('chat_model', 'flash')
# 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()
except Exception as e:
logger.warning(f"Error calculating monthly cost: {e}")
return jsonify({
'success': True,
'model': model,
'monthly_cost': round(monthly_cost, 4)
})
# POST - update settings
try:
data = request.get_json()
model = data.get('model', 'flash')
# Validate model
valid_models = ['flash', 'pro']
if model not in valid_models:
model = 'flash'
# Store in session
session['chat_model'] = model
logger.info(f"User {current_user.id} set chat_model to: {model}")
return jsonify({
'success': True,
'model': model
})
except Exception as e:
logger.error(f"Error updating chat settings: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/chat/start', methods=['POST'])
@login_required
@member_required
def chat_start():
"""Start new chat conversation"""
try:
data = request.get_json()
title = data.get('title', f"Rozmowa - {datetime.now().strftime('%Y-%m-%d %H:%M')}")
chat_engine = NordaBizChatEngine()
conversation = chat_engine.start_conversation(
user_id=current_user.id,
title=title
)
return jsonify({
'success': True,
'conversation_id': conversation.id,
'title': conversation.title
})
except Exception as e:
logger.error(f"Error starting chat: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/chat/<int:conversation_id>/message', methods=['POST'])
@login_required
@member_required
def chat_send_message(conversation_id):
"""Send message to AI chat"""
try:
data = request.get_json()
message = data.get('message', '').strip()
if not message:
return jsonify({'success': False, 'error': 'Wiadomość nie może być pusta'}), 400
# Verify conversation belongs to user
db = SessionLocal()
try:
conversation = db.query(AIChatConversation).filter_by(
id=conversation_id,
user_id=current_user.id
).first()
if not conversation:
return jsonify({'success': False, 'error': 'Conversation not found'}), 404
finally:
db.close()
# 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()
# Map model choice to actual model name and thinking level
model_map = {
'flash': '3-flash', # Gemini 3 Flash - 10K RPD, thinking mode
'pro': '3-pro' # Gemini 3.1 Pro - premium reasoning
}
thinking_map = {
'flash': 'high',
'pro': 'high'
}
model_key = model_map.get(model_choice, '3-flash')
chat_engine = NordaBizChatEngine(model=model_key)
response = chat_engine.send_message(
conversation_id=conversation_id,
user_message=message,
user_id=current_user.id,
thinking_level=thinking_map.get(model_choice, 'high')
)
# Get actual cost from response
tokens_in = response.tokens_input or 0
tokens_out = response.tokens_output or 0
actual_cost = response.cost_usd or 0.0
return jsonify({
'success': True,
'message': response.content,
'message_id': response.id,
'created_at': response.created_at.isoformat(),
# Technical metadata
'tech_info': {
'model': model_choice,
'tokens_input': tokens_in,
'tokens_output': tokens_out,
'tokens_total': tokens_in + tokens_out,
'latency_ms': response.latency_ms or 0,
'cost_usd': round(actual_cost, 6)
}
})
except Exception as e:
logger.error(f"Error sending message: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/chat/<int:conversation_id>/history', methods=['GET'])
@login_required
@member_required
def chat_get_history(conversation_id):
"""Get conversation history"""
try:
# Verify conversation belongs to user
db = SessionLocal()
try:
conversation = db.query(AIChatConversation).filter_by(
id=conversation_id,
user_id=current_user.id
).first()
if not conversation:
return jsonify({'success': False, 'error': 'Conversation not found'}), 404
finally:
db.close()
chat_engine = NordaBizChatEngine()
# SECURITY: Pass user_id for defense-in-depth ownership validation
history = chat_engine.get_conversation_history(conversation_id, user_id=current_user.id)
return jsonify({
'success': True,
'messages': history
})
except Exception as e:
logger.error(f"Error getting history: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/chat/conversations', methods=['GET'])
@login_required
@member_required
def chat_list_conversations():
"""Get list of user's conversations for sidebar"""
db = SessionLocal()
try:
conversations = db.query(AIChatConversation).filter_by(
user_id=current_user.id
).order_by(AIChatConversation.updated_at.desc()).limit(50).all()
return jsonify({
'success': True,
'conversations': [
{
'id': c.id,
'title': c.title,
'created_at': c.started_at.isoformat() if c.started_at else None,
'updated_at': c.updated_at.isoformat() if c.updated_at else None,
'message_count': len(c.messages) if c.messages else 0
}
for c in conversations
]
})
except Exception as e:
logger.error(f"Error listing conversations: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
@bp.route('/api/chat/<int:conversation_id>/delete', methods=['DELETE'])
@login_required
@member_required
def chat_delete_conversation(conversation_id):
"""Delete a conversation"""
db = SessionLocal()
try:
conversation = db.query(AIChatConversation).filter_by(
id=conversation_id,
user_id=current_user.id
).first()
if not conversation:
return jsonify({'success': False, 'error': 'Conversation not found'}), 404
# Delete messages first
db.query(AIChatMessage).filter_by(conversation_id=conversation_id).delete()
db.delete(conversation)
db.commit()
return jsonify({'success': True})
except Exception as e:
logger.error(f"Error deleting conversation: {e}")
db.rollback()
return jsonify({'success': False, 'error': str(e)}), 500
finally:
db.close()
# ============================================================
# AI CHAT FEEDBACK & ANALYTICS
# ============================================================
@bp.route('/api/chat/feedback', methods=['POST'])
@login_required
@member_required
def chat_feedback():
"""API: Submit feedback for AI response"""
try:
data = request.get_json()
message_id = data.get('message_id')
rating = data.get('rating') # 1 = thumbs down, 2 = thumbs up
if not message_id or rating not in [1, 2]:
return jsonify({'success': False, 'error': 'Invalid data'}), 400
db = SessionLocal()
try:
# Verify message exists and belongs to user's conversation
message = db.query(AIChatMessage).filter_by(id=message_id).first()
if not message:
return jsonify({'success': False, 'error': 'Message not found'}), 404
conversation = db.query(AIChatConversation).filter_by(
id=message.conversation_id,
user_id=current_user.id
).first()
if not conversation:
return jsonify({'success': False, 'error': 'Not authorized'}), 403
# Update message feedback
message.feedback_rating = rating
message.feedback_at = datetime.now()
message.feedback_comment = data.get('comment', '')
# Create detailed feedback record if provided
if data.get('is_helpful') is not None or data.get('comment'):
existing_feedback = db.query(AIChatFeedback).filter_by(message_id=message_id).first()
if existing_feedback:
existing_feedback.rating = rating
existing_feedback.is_helpful = data.get('is_helpful')
existing_feedback.is_accurate = data.get('is_accurate')
existing_feedback.found_company = data.get('found_company')
existing_feedback.comment = data.get('comment')
else:
feedback = AIChatFeedback(
message_id=message_id,
user_id=current_user.id,
rating=rating,
is_helpful=data.get('is_helpful'),
is_accurate=data.get('is_accurate'),
found_company=data.get('found_company'),
comment=data.get('comment'),
original_query=data.get('original_query'),
expected_companies=data.get('expected_companies')
)
db.add(feedback)
db.commit()
logger.info(f"Feedback received: message_id={message_id}, rating={rating}")
return jsonify({'success': True})
finally:
db.close()
except Exception as e:
logger.error(f"Error saving feedback: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/admin/chat-analytics')
@login_required
def chat_analytics():
"""Admin dashboard for chat analytics"""
# Only users with admin panel access can view chat analytics
if not current_user.can_access_admin_panel():
flash('Brak uprawnień do tej strony.', 'error')
return redirect(url_for('dashboard'))
db = SessionLocal()
try:
# Basic stats
total_conversations = db.query(AIChatConversation).count()
total_messages = db.query(AIChatMessage).count()
total_user_messages = db.query(AIChatMessage).filter_by(role='user').count()
# Feedback stats
feedback_count = db.query(AIChatMessage).filter(AIChatMessage.feedback_rating.isnot(None)).count()
positive_feedback = db.query(AIChatMessage).filter_by(feedback_rating=2).count()
negative_feedback = db.query(AIChatMessage).filter_by(feedback_rating=1).count()
# Recent conversations with feedback
recent_feedback = db.query(AIChatMessage).filter(
AIChatMessage.feedback_rating.isnot(None)
).order_by(desc(AIChatMessage.feedback_at)).limit(20).all()
# SECURITY: Query statistics only - do NOT expose raw user content
# This protects user privacy while still providing useful analytics
# Raw message content is NOT passed to the template
# Query categories/stats instead of raw content
from sqlalchemy import case
query_stats = {
'total_today': db.query(AIChatMessage).filter(
AIChatMessage.role == 'user',
func.date(AIChatMessage.created_at) == date.today()
).count(),
'avg_length': db.query(func.avg(func.length(AIChatMessage.content))).filter(
AIChatMessage.role == 'user'
).scalar() or 0,
'queries_with_company': db.query(AIChatMessage).filter(
AIChatMessage.role == 'user',
AIChatMessage.content.ilike('%firma%')
).count(),
'queries_with_contact': db.query(AIChatMessage).filter(
AIChatMessage.role == 'user',
AIChatMessage.content.ilike('%kontakt%') | AIChatMessage.content.ilike('%telefon%') | AIChatMessage.content.ilike('%email%')
).count()
}
# Recent queries - anonymized (show only metadata, not content)
recent_queries_raw = db.query(AIChatMessage).filter_by(role='user').order_by(
desc(AIChatMessage.created_at)
).limit(50).all()
# Anonymize: show length and timestamp only
recent_queries = [
{
'length': len(q.content) if q.content else 0,
'created_at': q.created_at,
'has_company_mention': 'firma' in (q.content or '').lower(),
'has_contact_request': any(kw in (q.content or '').lower() for kw in ['kontakt', 'telefon', 'email', 'www'])
}
for q in recent_queries_raw
]
# Calculate satisfaction rate
satisfaction_rate = (positive_feedback / feedback_count * 100) if feedback_count > 0 else 0
return render_template(
'admin/chat_analytics.html',
total_conversations=total_conversations,
total_messages=total_messages,
total_user_messages=total_user_messages,
feedback_count=feedback_count,
positive_feedback=positive_feedback,
negative_feedback=negative_feedback,
satisfaction_rate=round(satisfaction_rate, 1),
recent_feedback=recent_feedback,
recent_queries=recent_queries,
query_stats=query_stats # SECURITY: Aggregated stats only
)
finally:
db.close()