199 lines
9.0 KiB
Python
199 lines
9.0 KiB
Python
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
|
||
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 _call_groq(api_key, history, user_msg):
|
||
"""
|
||
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
|
||
]
|
||
|
||
messages = (
|
||
[{'role': 'system', 'content': _SYSTEM_PROMPT}]
|
||
+ 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,
|
||
})
|
||
|
||
try:
|
||
reply_text = _call_groq(api_key, history, user_msg)
|
||
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}) |