diff --git a/app.py b/app.py index 9c83970..5794200 100644 --- a/app.py +++ b/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//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//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//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(): diff --git a/blueprints/__init__.py b/blueprints/__init__.py index 2433825..9b35611 100644 --- a/blueprints/__init__.py +++ b/blueprints/__init__.py @@ -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): diff --git a/blueprints/chat/__init__.py b/blueprints/chat/__init__.py new file mode 100644 index 0000000..6b2ebe4 --- /dev/null +++ b/blueprints/chat/__init__.py @@ -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 diff --git a/blueprints/chat/routes.py b/blueprints/chat/routes.py new file mode 100644 index 0000000..27e115f --- /dev/null +++ b/blueprints/chat/routes.py @@ -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//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//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//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() diff --git a/docs/REFACTORING_STATUS.md b/docs/REFACTORING_STATUS.md index 3a72ca4..4282b27 100644 --- a/docs/REFACTORING_STATUS.md +++ b/docs/REFACTORING_STATUS.md @@ -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** ---