import json import logging import requests from flask import Blueprint, request, jsonify, current_app from flask_login import login_required, current_user from app import db, limiter from app.models import Ticket, TicketStatus, TicketPriority, TicketCategory, KnowledgeBase from app.services.notification_service import notify_new_ticket from app.services.log_service import log_action chatbot_bp = Blueprint('chatbot', __name__, url_prefix='/chatbot') logger = logging.getLogger(__name__) _SYSTEM_PROMPT = """You are an IT Helpdesk Assistant for an internal IT ticket system. Your job is to: 1. Help employees report IT issues conversationally. 2. Gather all required information to create a support ticket: - Issue title (short summary) - Detailed description - Category (hardware, software, network, access, email, printer, phone, security, other) - Priority (low, medium, high, critical) - Location (optional) - Asset tag (optional – device serial / asset number) 3. When you have enough information, respond with a JSON block like this (and ONLY this, no extra text): {"action": "create_ticket", "title": "...", "description": "...", "category": "...", "priority": "...", "location": "...", "asset_tag": "..."} 4. For general IT questions, answer helpfully but briefly. 5. If the user seems frustrated or has a critical outage, set priority to "critical". 6. Keep your tone professional, friendly, and concise. 7. Always ask clarifying questions if you need more detail before creating a ticket. """ # Groq API — OpenAI-compatible, free tier, no region restrictions. # Free tier: 14,400 requests/day. Get a key at: https://console.groq.com _GROQ_API_URL = 'https://api.groq.com/openai/v1/chat/completions' _GROQ_MODEL = 'llama-3.3-70b-versatile' def _search_kb(query, limit=3): """Return up to `limit` published KB articles relevant to the user query. Uses a simple keyword presence check against the title and tags columns. This avoids full-text indexes and works across all MySQL configs. """ import re words = [w for w in re.split(r'\W+', query.lower()) if len(w) > 3] if not words: return [] articles = KnowledgeBase.query.filter_by(is_published=True).all() scored = [] for art in articles: haystack = (art.title + ' ' + (art.tags or '')).lower() score = sum(1 for w in words if w in haystack) if score: scored.append((score, art)) scored.sort(key=lambda x: -x[0]) return [art for _, art in scored[:limit]] def _call_groq(api_key, history, user_msg, kb_context=''): """ Call the Groq API (OpenAI-compatible) and return the assistant's reply text. The system prompt is prepended as a system message. Prior history and the new user message are appended in order. History is capped at the most recent _MAX_HISTORY_TURNS turns and each message content is truncated to _MAX_MSG_CHARS characters before being forwarded. This prevents a malicious or runaway client from exhausting the model's context window or inflating token costs. Raises requests.HTTPError or requests.exceptions.RequestException on failure. """ # ── History sanitisation ────────────────────────────────────────────────── # 1. Strip create_ticket action blocks — re-sending them causes the model # to re-trigger ticket creation on every subsequent turn. # 2. Cap to the most recent N turns so the client cannot inflate context. # 3. Truncate each message's content to avoid per-message token blowout. _MAX_HISTORY_TURNS = 20 _MAX_MSG_CHARS = 2000 model = current_app.config.get('GROQ_MODEL', _GROQ_MODEL) clean_history = [ msg for msg in history if not (msg.get('role') == 'assistant' and '"action": "create_ticket"' in msg.get('content', '')) ] # Keep only the most recent turns after filtering if len(clean_history) > _MAX_HISTORY_TURNS: logger.warning( f'[CHATBOT] history truncated from {len(clean_history)} to ' f'{_MAX_HISTORY_TURNS} turns for user_id={current_user.id}' ) clean_history = clean_history[-_MAX_HISTORY_TURNS:] # Truncate individual message content lengths clean_history = [ {**msg, 'content': msg.get('content', '')[:_MAX_MSG_CHARS]} for msg in clean_history ] system_content = _SYSTEM_PROMPT if kb_context: system_content += '\n\n' + kb_context messages = ( [{'role': 'system', 'content': system_content}] + clean_history + [{'role': 'user', 'content': user_msg[:_MAX_MSG_CHARS]}] ) resp = requests.post( _GROQ_API_URL, headers={ 'Authorization': f'Bearer {api_key}', 'Content-Type' : 'application/json', }, json={ 'model' : model, 'messages' : messages, 'max_tokens' : 1024, 'temperature': 0.4, }, timeout=30, ) resp.raise_for_status() return resp.json()['choices'][0]['message']['content'].strip() @chatbot_bp.route('/message', methods=['POST']) @login_required @limiter.limit('20 per minute; 100 per hour') def chat(): data = request.get_json(force=True) history = data.get('history', []) # [{role, content}, ...] user_msg = data.get('message', '').strip() if not user_msg: return jsonify({'error': 'Empty message'}), 400 api_key = current_app.config.get('GROQ_API_KEY', '') if not api_key: return jsonify({ 'reply' : 'The AI assistant is not configured yet. Please contact your IT administrator.', 'ticket': None, }) # Inject relevant KB articles as context so the chatbot can reference # self-help content before suggesting a ticket is needed. kb_articles = _search_kb(user_msg) kb_context = '' if kb_articles: lines = ['RELEVANT KNOWLEDGE BASE ARTICLES (reference these if applicable):'] base_url = current_app.config.get('APP_BASE_URL', '') for art in kb_articles: lines.append(f'- {art.title}: {base_url}/kb/{art.id}') kb_context = '\n'.join(lines) try: reply_text = _call_groq(api_key, history, user_msg, kb_context=kb_context) except Exception as exc: logger.error(f'[CHATBOT API ERROR] {exc}') return jsonify({ 'reply' : 'Sorry, I encountered an error. Please try again or submit a ticket manually.', 'ticket': None, }) # Check if the AI wants to create a ticket ticket_data = None if '"action": "create_ticket"' in reply_text or "'action': 'create_ticket'" in reply_text: try: start = reply_text.find('{') end = reply_text.rfind('}') + 1 parsed = json.loads(reply_text[start:end]) if parsed.get('action') == 'create_ticket': # Validate AI-provided enum values against allowed sets to # prevent arbitrary strings reaching the database. _valid_categories = { TicketCategory.HARDWARE, TicketCategory.SOFTWARE, TicketCategory.NETWORK, TicketCategory.ACCESS, TicketCategory.EMAIL, TicketCategory.PRINTER, TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER, } _valid_priorities = { TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.CRITICAL, } raw_category = parsed.get('category', TicketCategory.OTHER) raw_priority = parsed.get('priority', TicketPriority.MEDIUM) safe_category = raw_category if raw_category in _valid_categories else TicketCategory.OTHER safe_priority = raw_priority if raw_priority in _valid_priorities else TicketPriority.MEDIUM if raw_category != safe_category: logger.warning(f'[CHATBOT VALIDATION] invalid category="{raw_category}" coerced to "{safe_category}"') if raw_priority != safe_priority: logger.warning(f'[CHATBOT VALIDATION] invalid priority="{raw_priority}" coerced to "{safe_priority}"') ticket = Ticket( title = parsed.get('title', 'Untitled Issue'), description = parsed.get('description', ''), category = safe_category, priority = safe_priority, location = parsed.get('location', ''), asset_tag = parsed.get('asset_tag', ''), created_by_id = current_user.id, status = TicketStatus.OPEN, ai_generated = True, ) ticket.ticket_number = ticket.generate_ticket_number() db.session.add(ticket) # Flush to obtain ticket.id from the DB sequence before logging. # Without this flush, ticket.id is None and the activity log # entry records entity_id=None, making the log entry unlinkable. db.session.flush() log_action(current_user.id, 'ticket_create_chatbot', 'ticket', ticket.id, f'ticket_number={ticket.ticket_number} ai_generated=True') db.session.commit() logger.info(f'[CHATBOT TICKET CREATE] ticket_id={ticket.id} number={ticket.ticket_number} user_id={current_user.id}') notify_new_ticket(ticket) ticket_data = { 'id' : ticket.id, 'ticket_number': ticket.ticket_number, 'title' : ticket.title, 'url' : f'/tickets/{ticket.id}', } reply_text = ( f"✅ **Ticket Created!**\n\n" f"I've submitted your ticket **{ticket.ticket_number}**: _{ticket.title}_\n\n" f"Our IT team has been notified and will get back to you shortly. " f"You can track your ticket [here](/tickets/{ticket.id})." ) except (json.JSONDecodeError, KeyError) as exc: logger.warning(f'[CHATBOT PARSE ERROR] Could not parse ticket JSON: {exc}') return jsonify({'reply': reply_text, 'ticket': ticket_data})