Update chatbot
This commit is contained in:
@@ -27,8 +27,8 @@ IT_DEPT_EMAIL=da.nguyen8744@gmail.com
|
|||||||
# Application URL (used in email links)
|
# Application URL (used in email links)
|
||||||
APP_BASE_URL=https://tickets.ltservicesinc.com
|
APP_BASE_URL=https://tickets.ltservicesinc.com
|
||||||
|
|
||||||
# Anthropic API Key (for AI Chatbot)
|
# Gemini opic API Key (for AI Chatbot)
|
||||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
GROQ_API_KEY=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
|
||||||
|
|
||||||
# File Upload Configuration
|
# File Upload Configuration
|
||||||
# Must be an absolute path so all gunicorn workers resolve the same directory
|
# Must be an absolute path so all gunicorn workers resolve the same directory
|
||||||
|
|||||||
+66
-31
@@ -29,6 +29,53 @@ Your job is to:
|
|||||||
7. Always ask clarifying questions if you need more detail before creating a ticket.
|
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'])
|
@chatbot_bp.route('/message', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -40,40 +87,28 @@ def chat():
|
|||||||
if not user_msg:
|
if not user_msg:
|
||||||
return jsonify({'error': 'Empty message'}), 400
|
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:
|
if not api_key:
|
||||||
return jsonify({'reply': "The AI assistant is not configured yet. Please contact your IT administrator.", 'ticket': None})
|
return jsonify({
|
||||||
|
'reply' : 'The AI assistant is not configured yet. Please contact your IT administrator.',
|
||||||
messages = history + [{'role': 'user', 'content': user_msg}]
|
'ticket': None,
|
||||||
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
reply_text = _call_groq(api_key, history, user_msg)
|
||||||
'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()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f'[CHATBOT API ERROR] {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
|
# Check if the AI wants to create a ticket
|
||||||
ticket_data = None
|
ticket_data = None
|
||||||
if '"action": "create_ticket"' in reply_text or "'action': 'create_ticket'" in reply_text:
|
if '"action": "create_ticket"' in reply_text or "'action': 'create_ticket'" in reply_text:
|
||||||
try:
|
try:
|
||||||
start = reply_text.find('{')
|
start = reply_text.find('{')
|
||||||
end = reply_text.rfind('}') + 1
|
end = reply_text.rfind('}') + 1
|
||||||
parsed = json.loads(reply_text[start:end])
|
parsed = json.loads(reply_text[start:end])
|
||||||
if parsed.get('action') == 'create_ticket':
|
if parsed.get('action') == 'create_ticket':
|
||||||
# Validate AI-provided enum values against allowed sets to
|
# Validate AI-provided enum values against allowed sets to
|
||||||
@@ -89,8 +124,8 @@ def chat():
|
|||||||
TicketPriority.LOW, TicketPriority.MEDIUM,
|
TicketPriority.LOW, TicketPriority.MEDIUM,
|
||||||
TicketPriority.HIGH, TicketPriority.CRITICAL,
|
TicketPriority.HIGH, TicketPriority.CRITICAL,
|
||||||
}
|
}
|
||||||
raw_category = parsed.get('category', TicketCategory.OTHER)
|
raw_category = parsed.get('category', TicketCategory.OTHER)
|
||||||
raw_priority = parsed.get('priority', TicketPriority.MEDIUM)
|
raw_priority = parsed.get('priority', TicketPriority.MEDIUM)
|
||||||
safe_category = raw_category if raw_category in _valid_categories else TicketCategory.OTHER
|
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
|
safe_priority = raw_priority if raw_priority in _valid_priorities else TicketPriority.MEDIUM
|
||||||
if raw_category != safe_category:
|
if raw_category != safe_category:
|
||||||
@@ -118,10 +153,10 @@ def chat():
|
|||||||
notify_new_ticket(ticket)
|
notify_new_ticket(ticket)
|
||||||
|
|
||||||
ticket_data = {
|
ticket_data = {
|
||||||
'id' : ticket.id,
|
'id' : ticket.id,
|
||||||
'ticket_number' : ticket.ticket_number,
|
'ticket_number': ticket.ticket_number,
|
||||||
'title' : ticket.title,
|
'title' : ticket.title,
|
||||||
'url' : f'/tickets/{ticket.id}',
|
'url' : f'/tickets/{ticket.id}',
|
||||||
}
|
}
|
||||||
reply_text = (
|
reply_text = (
|
||||||
f"✅ **Ticket Created!**\n\n"
|
f"✅ **Ticket Created!**\n\n"
|
||||||
@@ -132,4 +167,4 @@ def chat():
|
|||||||
except (json.JSONDecodeError, KeyError) as exc:
|
except (json.JSONDecodeError, KeyError) as exc:
|
||||||
logger.warning(f'[CHATBOT PARSE ERROR] Could not parse ticket JSON: {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})
|
||||||
@@ -628,13 +628,18 @@ async function sendChat(){
|
|||||||
typing.remove();
|
typing.remove();
|
||||||
const reply = d.reply || 'Sorry, I encountered an error.';
|
const reply = d.reply || 'Sorry, I encountered an error.';
|
||||||
appendBubble(reply,'bot');
|
appendBubble(reply,'bot');
|
||||||
chatHistory.push({role:'assistant',content:reply});
|
|
||||||
if(d.ticket){
|
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');
|
const notif=document.createElement('div');
|
||||||
notif.className='chat-bubble bot';
|
notif.className='chat-bubble bot';
|
||||||
notif.style.cssText='background:rgba(45,212,191,.1);border:1px solid rgba(45,212,191,.3);';
|
notif.style.cssText='background:rgba(45,212,191,.1);border:1px solid rgba(45,212,191,.3);';
|
||||||
notif.innerHTML=`🎫 <strong>${d.ticket.ticket_number}</strong> — <a href="${d.ticket.url}">View Ticket</a>`;
|
notif.innerHTML=`🎫 <strong>${d.ticket.ticket_number}</strong> — <a href="${d.ticket.url}">View Ticket</a>`;
|
||||||
document.getElementById('chat-messages').appendChild(notif);
|
document.getElementById('chat-messages').appendChild(notif);
|
||||||
|
} else {
|
||||||
|
chatHistory.push({role:'assistant',content:reply});
|
||||||
}
|
}
|
||||||
}catch(e){typing.remove();appendBubble('Sorry, something went wrong.','bot');}
|
}catch(e){typing.remove();appendBubble('Sorry, something went wrong.','bot');}
|
||||||
scrollChat();
|
scrollChat();
|
||||||
|
|||||||
@@ -19,11 +19,14 @@
|
|||||||
{% if notifs.items %}
|
{% if notifs.items %}
|
||||||
{% for n in notifs.items %}
|
{% for n in notifs.items %}
|
||||||
<a href="{{ n.link or '#' }}"
|
<a href="{{ n.link or '#' }}"
|
||||||
|
data-notif-id="{{ n.id }}"
|
||||||
|
data-is-read="{{ 'true' if n.is_read else 'false' }}"
|
||||||
|
class="notif-list-item"
|
||||||
style="display:flex;gap:14px;padding:16px 20px;border-bottom:1px solid var(--border);color:var(--text);
|
style="display:flex;gap:14px;padding:16px 20px;border-bottom:1px solid var(--border);color:var(--text);
|
||||||
{% if not n.is_read %}border-left:3px solid var(--accent3);background:rgba(0,180,216,.03);{% endif %}">
|
{% if not n.is_read %}border-left:3px solid var(--accent3);background:rgba(0,180,216,.03);{% endif %}">
|
||||||
<div style="margin-top:2px;flex-shrink:0;">
|
<div style="margin-top:2px;flex-shrink:0;">
|
||||||
{% if not n.is_read %}
|
{% if not n.is_read %}
|
||||||
<div style="width:8px;height:8px;border-radius:50%;background:var(--accent3);margin-top:3px;"></div>
|
<div class="notif-unread-dot" style="width:8px;height:8px;border-radius:50%;background:var(--accent3);margin-top:3px;"></div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="bi bi-check2" style="color:var(--muted);font-size:14px;"></i>
|
<i class="bi bi-check2" style="color:var(--muted);font-size:14px;"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -78,4 +81,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// On page load, fetch the live unread count and sync the bell badge.
|
||||||
|
// The server-rendered {{ unread_notifications }} is only accurate at render
|
||||||
|
// time — if the user has been clicking around, it can be stale.
|
||||||
|
fetch('/api/notifications/unread-count')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if(typeof updateBadge === 'function') updateBadge(d.count); })
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Intercept every notification link click:
|
||||||
|
// 1. If the notification is unread, call the mark-read API.
|
||||||
|
// 2. Decrement the bell badge immediately (optimistic update).
|
||||||
|
// 3. Navigate to the destination.
|
||||||
|
document.querySelectorAll('a.notif-list-item').forEach(function(link){
|
||||||
|
link.addEventListener('click', async function(e){
|
||||||
|
const id = this.dataset.notifId;
|
||||||
|
const isRead = this.dataset.isRead === 'true';
|
||||||
|
const dest = this.getAttribute('href');
|
||||||
|
|
||||||
|
if(!isRead && id){
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await csrfPost('/api/notifications/' + id + '/read');
|
||||||
|
} catch(_){}
|
||||||
|
// Update badge — decrement by 1
|
||||||
|
if(typeof updateBadge === 'function') updateBadge(-1, true);
|
||||||
|
// Mark the row as visually read
|
||||||
|
this.dataset.isRead = 'true';
|
||||||
|
this.style.borderLeft = '';
|
||||||
|
this.style.background = '';
|
||||||
|
const dot = this.querySelector('.notif-unread-dot');
|
||||||
|
if(dot) dot.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(dest && dest !== '#') window.location.href = dest;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
+8
-3
@@ -1,7 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
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:
|
class Config:
|
||||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
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))
|
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'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
|
||||||
|
|
||||||
# Anthropic AI
|
# AI Chatbot — Groq (free tier: 14,400 req/day, no credit card, no region restrictions)
|
||||||
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY', '')
|
# 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.
|
# Rate limiting storage.
|
||||||
# The `limits` library (used by Flask-Limiter) does not support MySQL as a
|
# The `limits` library (used by Flask-Limiter) does not support MySQL as a
|
||||||
|
|||||||
Reference in New Issue
Block a user