From 368700393542da91ab7fbeb4e7d0e6d7d45bf1c3 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 26 Mar 2026 15:45:54 -0400 Subject: [PATCH] Update chatbot --- .env | 4 +- app/routes/chatbot.py | 97 ++++++++++++++++-------- app/templates/base.html | 7 +- app/templates/tickets/notifications.html | 48 +++++++++++- config/config.py | 11 ++- 5 files changed, 128 insertions(+), 39 deletions(-) diff --git a/.env b/.env index be271e6..0889dd0 100644 --- a/.env +++ b/.env @@ -27,8 +27,8 @@ IT_DEPT_EMAIL=da.nguyen8744@gmail.com # Application URL (used in email links) APP_BASE_URL=https://tickets.ltservicesinc.com -# Anthropic API Key (for AI Chatbot) -ANTHROPIC_API_KEY=your-anthropic-api-key-here +# Gemini opic API Key (for AI Chatbot) +GROQ_API_KEY=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 # File Upload Configuration # Must be an absolute path so all gunicorn workers resolve the same directory diff --git a/app/routes/chatbot.py b/app/routes/chatbot.py index 064d51c..028fd53 100644 --- a/app/routes/chatbot.py +++ b/app/routes/chatbot.py @@ -29,6 +29,53 @@ Your job is to: 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. + + Raises requests.HTTPError or requests.exceptions.RequestException on failure. + """ + model = current_app.config.get('GROQ_MODEL', _GROQ_MODEL) + + # Strip any assistant messages containing a create_ticket action block. + # These should never reach the model — if they do, the model re-triggers + # ticket creation on every subsequent turn. + clean_history = [ + msg for msg in history + if not (msg.get('role') == 'assistant' and '"action": "create_ticket"' in msg.get('content', '')) + ] + + messages = ( + [{'role': 'system', 'content': _SYSTEM_PROMPT}] + + clean_history + + [{'role': 'user', 'content': user_msg}] + ) + 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 @@ -40,40 +87,28 @@ def chat(): if not user_msg: return jsonify({'error': 'Empty message'}), 400 - api_key = current_app.config.get('ANTHROPIC_API_KEY', '') + 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}) - - messages = history + [{'role': 'user', 'content': user_msg}] + return jsonify({ + 'reply' : 'The AI assistant is not configured yet. Please contact your IT administrator.', + 'ticket': None, + }) try: - resp = requests.post( - 'https://api.anthropic.com/v1/messages', - headers={ - 'x-api-key' : api_key, - 'anthropic-version': '2023-06-01', - 'content-type' : 'application/json', - }, - json={ - 'model' : 'claude-sonnet-4-20250514', - 'max_tokens': 1024, - 'system' : _SYSTEM_PROMPT, - 'messages' : messages, - }, - timeout=30, - ) - resp.raise_for_status() - reply_text = resp.json()['content'][0]['text'].strip() + 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}) + 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 + 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 @@ -89,8 +124,8 @@ def chat(): TicketPriority.LOW, TicketPriority.MEDIUM, TicketPriority.HIGH, TicketPriority.CRITICAL, } - raw_category = parsed.get('category', TicketCategory.OTHER) - raw_priority = parsed.get('priority', TicketPriority.MEDIUM) + 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: @@ -118,10 +153,10 @@ def chat(): notify_new_ticket(ticket) ticket_data = { - 'id' : ticket.id, - 'ticket_number' : ticket.ticket_number, - 'title' : ticket.title, - 'url' : f'/tickets/{ticket.id}', + 'id' : ticket.id, + 'ticket_number': ticket.ticket_number, + 'title' : ticket.title, + 'url' : f'/tickets/{ticket.id}', } reply_text = ( f"✅ **Ticket Created!**\n\n" @@ -132,4 +167,4 @@ def chat(): 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}) + return jsonify({'reply': reply_text, 'ticket': ticket_data}) \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index bde97a5..c0ddfca 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -628,13 +628,18 @@ async function sendChat(){ typing.remove(); const reply = d.reply || 'Sorry, I encountered an error.'; appendBubble(reply,'bot'); - chatHistory.push({role:'assistant',content:reply}); if(d.ticket){ + // Clear history after ticket creation — keeping the prior conversation + // (which contains the AI's JSON action block) causes the model to + // re-trigger ticket creation on every subsequent message. + chatHistory = []; const notif=document.createElement('div'); notif.className='chat-bubble bot'; notif.style.cssText='background:rgba(45,212,191,.1);border:1px solid rgba(45,212,191,.3);'; notif.innerHTML=`🎫 ${d.ticket.ticket_number}View Ticket`; document.getElementById('chat-messages').appendChild(notif); + } else { + chatHistory.push({role:'assistant',content:reply}); } }catch(e){typing.remove();appendBubble('Sorry, something went wrong.','bot');} scrollChat(); diff --git a/app/templates/tickets/notifications.html b/app/templates/tickets/notifications.html index 499b748..c32ede5 100644 --- a/app/templates/tickets/notifications.html +++ b/app/templates/tickets/notifications.html @@ -19,11 +19,14 @@ {% if notifs.items %} {% for n in notifs.items %}
{% if not n.is_read %} -
+
{% else %} {% endif %} @@ -78,4 +81,45 @@
-{% endblock %} + + +{% endblock %} \ No newline at end of file diff --git a/config/config.py b/config/config.py index 44c3380..4098296 100644 --- a/config/config.py +++ b/config/config.py @@ -1,7 +1,10 @@ import os from dotenv import load_dotenv -load_dotenv() +# Use an absolute path so .env is found regardless of the process working +# directory — under systemd the CWD may not be the project root. +_here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +load_dotenv(os.path.join(_here, '.env')) class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' @@ -44,8 +47,10 @@ class Config: MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024)) ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'} - # Anthropic AI - ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY', '') + # AI Chatbot — Groq (free tier: 14,400 req/day, no credit card, no region restrictions) + # Get a key at: https://console.groq.com → API Keys + GROQ_API_KEY = os.environ.get('GROQ_API_KEY', '') + GROQ_MODEL = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') # Rate limiting storage. # The `limits` library (used by Flask-Limiter) does not support MySQL as a