refactor: Extract chat blueprint (Phase 5)
- Create blueprints/chat/ with 9 routes: - chat, chat_settings, chat_start, chat_send_message - chat_get_history, chat_list_conversations, chat_delete_conversation - chat_feedback, chat_analytics - Register chat blueprint with backward-compatible aliases - Remove dead code from app.py (-458 lines) - app.py: 13,058 → 12,600 lines (-3.5%) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0f482cc4aa
commit
55718ed211
458
app.py
458
app.py
@ -2790,304 +2790,6 @@ def api_delete_recommendation(rec_id):
|
||||
# MOJE KONTO - MOVED TO blueprints/auth/routes.py
|
||||
# ============================================================
|
||||
# USER DASHBOARD - MOVED TO blueprints/public/routes.py
|
||||
# ============================================================
|
||||
# AI CHAT ROUTES
|
||||
# ============================================================
|
||||
|
||||
@app.route('/chat')
|
||||
@login_required
|
||||
def chat():
|
||||
"""AI Chat interface"""
|
||||
return render_template('chat.html')
|
||||
|
||||
|
||||
@app.route('/api/chat/settings', methods=['GET', 'POST'])
|
||||
@csrf.exempt
|
||||
@login_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
|
||||
model = session.get('chat_model', 'flash')
|
||||
|
||||
# Calculate monthly cost for current user
|
||||
monthly_cost = 0.0
|
||||
try:
|
||||
from database import AIAPICostLog
|
||||
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
|
||||
|
||||
|
||||
@app.route('/api/chat/start', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_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
|
||||
|
||||
|
||||
@app.route('/api/chat/<int:conversation_id>/message', methods=['POST'])
|
||||
@csrf.exempt
|
||||
@login_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, 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
|
||||
from database import AIAPICostLog
|
||||
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
|
||||
model_map = {
|
||||
'flash': '3-flash',
|
||||
'pro': '3-pro'
|
||||
}
|
||||
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='minimal' if model_choice == 'flash' else '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
|
||||
|
||||
|
||||
@app.route('/api/chat/<int:conversation_id>/history', methods=['GET'])
|
||||
@login_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
|
||||
|
||||
|
||||
@app.route('/api/chat/conversations', methods=['GET'])
|
||||
@login_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()
|
||||
|
||||
|
||||
@app.route('/api/chat/<int:conversation_id>/delete', methods=['DELETE'])
|
||||
@login_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()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API ROUTES (for frontend)
|
||||
# ============================================================
|
||||
@ -5355,166 +5057,6 @@ def api_model_info():
|
||||
}), 500
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AI CHAT FEEDBACK & ANALYTICS
|
||||
# ============================================================
|
||||
|
||||
@app.route('/api/chat/feedback', methods=['POST'])
|
||||
@login_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
|
||||
|
||||
|
||||
@app.route('/admin/chat-analytics')
|
||||
@login_required
|
||||
def chat_analytics():
|
||||
"""Admin dashboard for chat analytics"""
|
||||
# Only admins can access
|
||||
if not current_user.is_admin:
|
||||
flash('Brak uprawnień do tej strony.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from sqlalchemy import func, desc
|
||||
from datetime import date
|
||||
|
||||
# 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 func, 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()
|
||||
|
||||
|
||||
@app.route('/api/admin/test-sanitization', methods=['POST'])
|
||||
@login_required
|
||||
def test_sanitization():
|
||||
|
||||
@ -171,7 +171,31 @@ def register_blueprints(app):
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering messages blueprint: {e}")
|
||||
|
||||
# Phase 5-10: Future blueprints will be added here
|
||||
# Phase 5: Chat blueprint
|
||||
try:
|
||||
from blueprints.chat import bp as chat_bp
|
||||
app.register_blueprint(chat_bp)
|
||||
logger.info("Registered blueprint: chat")
|
||||
|
||||
# Create aliases for backward compatibility
|
||||
_create_endpoint_aliases(app, chat_bp, {
|
||||
'chat': 'chat.chat',
|
||||
'chat_settings': 'chat.chat_settings',
|
||||
'chat_start': 'chat.chat_start',
|
||||
'chat_send_message': 'chat.chat_send_message',
|
||||
'chat_get_history': 'chat.chat_get_history',
|
||||
'chat_list_conversations': 'chat.chat_list_conversations',
|
||||
'chat_delete_conversation': 'chat.chat_delete_conversation',
|
||||
'chat_feedback': 'chat.chat_feedback',
|
||||
'chat_analytics': 'chat.chat_analytics',
|
||||
})
|
||||
logger.info("Created chat endpoint aliases")
|
||||
except ImportError as e:
|
||||
logger.debug(f"Blueprint chat not yet available: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering chat blueprint: {e}")
|
||||
|
||||
# Phase 6-10: Future blueprints will be added here
|
||||
|
||||
|
||||
def _create_endpoint_aliases(app, blueprint, aliases):
|
||||
|
||||
12
blueprints/chat/__init__.py
Normal file
12
blueprints/chat/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""
|
||||
Chat Blueprint
|
||||
==============
|
||||
|
||||
AI Chat routes and feedback.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint('chat', __name__)
|
||||
|
||||
from . import routes # noqa: E402, F401
|
||||
476
blueprints/chat/routes.py
Normal file
476
blueprints/chat/routes.py
Normal file
@ -0,0 +1,476 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
from nordabiz_chat import NordaBizChatEngine
|
||||
from extensions import csrf
|
||||
|
||||
# Logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# AI CHAT ROUTES
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/chat')
|
||||
@login_required
|
||||
def chat():
|
||||
"""AI Chat interface"""
|
||||
return render_template('chat.html')
|
||||
|
||||
|
||||
@bp.route('/api/chat/settings', methods=['GET', 'POST'])
|
||||
@csrf.exempt
|
||||
@login_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
|
||||
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'])
|
||||
@csrf.exempt
|
||||
@login_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'])
|
||||
@csrf.exempt
|
||||
@login_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, 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
|
||||
model_map = {
|
||||
'flash': '3-flash',
|
||||
'pro': '3-pro'
|
||||
}
|
||||
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='minimal' if model_choice == 'flash' else '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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 admins can access
|
||||
if not current_user.is_admin:
|
||||
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()
|
||||
@ -130,7 +130,7 @@ Usuń funkcje z prefiksem `_old_` z app.py.
|
||||
| **2a** | auth + public + cleanup | 31 | ✅ WDROŻONA |
|
||||
| **3** | forum (10 routes) | 10 | ✅ WDROŻONA |
|
||||
| **4** | messages + notifications (11 routes) | 11 | ✅ WDROŻONA |
|
||||
| **5** | chat | ~8 | ⏳ |
|
||||
| **5** | chat (9 routes) | 9 | ✅ WDROŻONA |
|
||||
| **6** | admin (8 modułów) | ~60 | ⏳ |
|
||||
| **7** | audits (6 modułów) | ~35 | ⏳ |
|
||||
| **8** | zopk (5 modułów) | ~32 | ⏳ |
|
||||
@ -166,6 +166,7 @@ Usuń funkcje z prefiksem `_old_` z app.py.
|
||||
- Po Fazie 2a: 13,820 linii (-11.2% od startu)
|
||||
- Po Fazie 3: 13,398 linii (-13.9% od startu)
|
||||
- Po Fazie 4: 13,058 linii (-16.1% od startu)
|
||||
- Po Fazie 5: 12,600 linii (-19.1% od startu)
|
||||
- **Cel końcowy: ~500 linii**
|
||||
|
||||
---
|
||||
|
||||
Loading…
Reference in New Issue
Block a user