Files
IT_Ticket_System/app/routes/chatbot.py
T
2026-05-22 16:31:19 -04:00

243 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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():
from app.services.license_service import feature_enabled
if not feature_enabled('chatbot'):
return jsonify({
'reply' : 'The AI chatbot is not available on the Community plan. '
'Upgrade to a Business or Enterprise license to enable it.',
'ticket': None,
}), 403
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})