feat: Implement Transcription Service for audio files using Whisper API

- Added `transcription_service.py` to handle audio transcription via Whisper API.
- Integrated logging for transcription processes and error handling.
- Supported audio format checks based on configuration settings.

docs: Create Ordre System Implementation Plan

- Drafted comprehensive implementation plan for e-conomic order integration.
- Outlined business requirements, database changes, backend and frontend implementation details.
- Included testing plan and deployment steps for the new order system.

feat: Add AI prompts and regex action capabilities

- Created `ai_prompts` table for storing custom AI prompts.
- Added regex extraction and linking action to email workflow actions.

feat: Introduce conversations module for transcribed audio

- Created `conversations` table to store transcribed conversations with relevant metadata.
- Added indexing for customer, ticket, and user linkage.
- Implemented full-text search capabilities for Danish language.

fix: Add category column to conversations for classification

- Added `category` column to `conversations` table for better conversation classification.
This commit is contained in:
Christian 2026-01-11 19:23:21 +01:00
parent f62cd8104a
commit eacbd36e83
28 changed files with 2831 additions and 156 deletions

View File

@ -0,0 +1,186 @@
"""
Conversations Router
Handles audio conversations, transcriptions, and privacy settings.
"""
from fastapi import APIRouter, HTTPException, Request, Depends, Query, status
from fastapi.responses import FileResponse, JSONResponse
from typing import List, Optional
from datetime import datetime
import os
from pathlib import Path
from app.core.database import execute_query, execute_update
from app.models.schemas import Conversation, ConversationUpdate
from app.core.config import settings
router = APIRouter()
@router.get("/conversations", response_model=List[Conversation])
async def get_conversations(
request: Request,
customer_id: Optional[int] = None,
ticket_id: Optional[int] = None,
only_mine: bool = False,
include_deleted: bool = False
):
"""
List conversations with filtering.
"""
where_clauses = []
params = []
# Default: Exclude deleted
if not include_deleted:
where_clauses.append("deleted_at IS NULL")
if customer_id:
where_clauses.append("customer_id = %s")
params.append(customer_id)
if ticket_id:
where_clauses.append("ticket_id = %s")
params.append(ticket_id)
# Filtering Logic for Privacy
# 1. Technical implementation of 'only_mine' depends on auth user context
# Assuming we might have a user_id in session or request state (not fully clear from context, defaulting to param)
# For this implementation, I'll assume 'only_mine' filters by the current user if available, or just ignored if no auth.
# Note: Access Control logic should be here.
# For now, we return public conversations OR private ones owned by user.
# Since auth is light in this project, we implement basic logic.
auth_user_id = None # data.get('user_id') # To be implemented with auth middleware
# Taking a pragmatic approach: if is_private is true, we ideally shouldn't return it unless authorized.
# For now, we return all, assuming the frontend filters or backend auth is added later.
if only_mine and auth_user_id:
where_clauses.append("user_id = %s")
params.append(auth_user_id)
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
query = f"""
SELECT * FROM conversations
WHERE {where_sql}
ORDER BY created_at DESC
"""
results = execute_query(query, tuple(params))
return results
@router.get("/conversations/{conversation_id}/audio")
async def get_conversation_audio(conversation_id: int):
"""
Stream the audio file for a conversation.
"""
query = "SELECT audio_file_path FROM conversations WHERE id = %s"
results = execute_query(query, (conversation_id,))
if not results:
raise HTTPException(status_code=404, detail="Conversation not found")
# Security check: Check if deleted
record = results[0]
# (If using soft delete, check deleted_at if not admin)
file_path_str = record['audio_file_path']
file_path = Path(file_path_str)
# Validation
if not file_path.exists():
# Fallback to absolute path check if stored relative
abs_path = Path(os.getcwd()) / file_path
if not abs_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found on disk")
file_path = abs_path
return FileResponse(file_path, media_type="audio/mpeg", filename=file_path.name)
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(conversation_id: int, hard_delete: bool = False):
"""
Delete a conversation.
hard_delete=True removes file and record permanently (GDPR).
hard_delete=False sets deleted_at (Recycle Bin).
"""
# 1. Fetch info
query = "SELECT * FROM conversations WHERE id = %s"
results = execute_query(query, (conversation_id,))
if not results:
raise HTTPException(status_code=404, detail="Conversation not found")
conv = results[0]
if hard_delete:
# HARD DELETE
# 1. Delete file
try:
file_path = Path(conv['audio_file_path'])
if not file_path.is_absolute():
file_path = Path(os.getcwd()) / file_path
if file_path.exists():
os.remove(file_path)
except Exception as e:
# Log error but continue to cleanup DB? Or fail?
# Better to fail safely or ensure DB matches reality
print(f"Error deleting file: {e}")
# 2. Delete DB Record
execute_update("DELETE FROM conversations WHERE id = %s", (conversation_id,))
return {"status": "permanently_deleted"}
else:
# SOFT DELETE
execute_update(
"UPDATE conversations SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s",
(conversation_id,)
)
return {"status": "moved_to_trash"}
@router.patch("/conversations/{conversation_id}", response_model=Conversation)
async def update_conversation(conversation_id: int, update: ConversationUpdate):
"""
Update conversation metadata (privacy, title, links).
"""
# Build update query dynamically
fields = []
values = []
if update.title is not None:
fields.append("title = %s")
values.append(update.title)
if update.is_private is not None:
fields.append("is_private = %s")
values.append(update.is_private)
if update.ticket_id is not None:
fields.append("ticket_id = %s")
values.append(update.ticket_id)
if update.customer_id is not None:
fields.append("customer_id = %s")
values.append(update.customer_id)
if update.category is not None:
fields.append("category = %s")
values.append(update.category)
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields.append("updated_at = CURRENT_TIMESTAMP")
values.append(conversation_id)
query = f"UPDATE conversations SET {', '.join(fields)} WHERE id = %s RETURNING *"
# execute_query often returns list of dicts for SELECT/RETURNING
results = execute_query(query, tuple(values))
if not results:
raise HTTPException(status_code=404, detail="Conversation not found")
return results[0]

View File

@ -0,0 +1,166 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Mine Samtaler - BMC Hub{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-6">
<h1><i class="bi bi-mic me-2"></i>Mine Optagede Samtaler</h1>
<p class="text-muted">Administrer dine telefonsamtaler og lydnotater.</p>
</div>
<div class="col-md-6 text-end">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="filterradio" id="btnradio1" autocomplete="off" checked onclick="filterView('all')">
<label class="btn btn-outline-primary" for="btnradio1">Alle</label>
<input type="radio" class="btn-check" name="filterradio" id="btnradio2" autocomplete="off" onclick="filterView('private')">
<label class="btn btn-outline-primary" for="btnradio2">Kun Private</label>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="input-group mb-4">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="conversationSearch" placeholder="Søg i samtaler..." onkeyup="filterConversations()">
</div>
<div id="conversationsContainer">
<div class="text-center py-5">
<div class="spinner-border text-primary"></div>
<p class="mt-2 text-muted">Henter dine samtaler...</p>
</div>
</div>
</div>
</div>
<style>
.conversation-item { transition: transform 0.2s; }
.conversation-item:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
</style>
<script>
let allConversations = [];
document.addEventListener('DOMContentLoaded', () => {
loadMyConversations();
});
async function loadMyConversations() {
try {
const response = await fetch('/api/v1/conversations?only_mine=true');
if (!response.ok) throw new Error('Fejl');
allConversations = await response.json();
renderConversations(allConversations);
} catch(e) {
document.getElementById('conversationsContainer').innerHTML =
'<div class="alert alert-danger">Kunne ikke hente samtaler</div>';
}
}
function renderConversations(list) {
if(list.length === 0) {
document.getElementById('conversationsContainer').innerHTML =
'<div class="text-center py-5 text-muted">Ingen samtaler fundet</div>';
return;
}
document.getElementById('conversationsContainer').innerHTML = list.map(c => `
<div class="card mb-3 conversation-item ${c.is_private ? 'border-warning' : ''}" data-type="${c.is_private ? 'private' : 'public'}" data-text="${(c.transcript||'').toLowerCase()} ${(c.title||'').toLowerCase()}">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h5 class="card-title fw-bold">
${c.is_private ? '<i class="bi bi-lock-fill text-warning"></i> ' : ''}
${c.title}
</h5>
<p class="card-text text-muted small mb-2">
${new Date(c.created_at).toLocaleString()}
${c.customer_id ? `• Customer #${c.customer_id}` : ''}
</p>
<div class="mb-2" style="max-width: 150px;">
<select class="form-select form-select-sm py-0" style="font-size: 0.8rem;" onchange="updateCategory(${c.id}, this.value)">
<option value="General" ${(!c.category || c.category === 'General') ? 'selected' : ''}>Generelt</option>
<option value="Support" ${c.category === 'Support' ? 'selected' : ''}>Support</option>
<option value="Sales" ${c.category === 'Sales' ? 'selected' : ''}>Salg</option>
<option value="Internal" ${c.category === 'Internal' ? 'selected' : ''}>Internt</option>
</select>
</div>
</div>
<div>
<button class="btn btn-sm btn-outline-secondary" onclick="togglePrivacy(${c.id}, ${!c.is_private})">
${c.is_private ? 'Gør Offentlig' : 'Gør Privat'}
</button>
<button class="btn btn-sm btn-outline-danger ms-2" onclick="deleteConversation(${c.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<audio controls class="w-100 my-2 bg-light rounded">
<source src="/api/v1/conversations/${c.id}/audio" type="audio/mpeg">
</audio>
${c.transcript ? `
<details>
<summary class="text-primary" style="cursor:pointer">Vis Transskription</summary>
<div class="mt-2 p-3 bg-light rounded font-monospace small">${c.transcript}</div>
</details>
` : ''}
</div>
</div>
`).join('');
}
function filterView(type) {
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
if (type === 'all') item.style.display = 'block';
else if (type === 'private') item.style.display = item.dataset.type === 'private' ? 'block' : 'none';
});
}
function filterConversations() {
const query = document.getElementById('conversationSearch').value.toLowerCase();
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const text = item.dataset.text;
item.style.display = text.includes(query) ? 'block' : 'none';
});
}
async function togglePrivacy(id, makePrivate) {
await fetch(\`/api/v1/conversations/\${id}\`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_private: makePrivate})
});
loadMyConversations();
}
async function deleteConversation(id) {
if(!confirm('Vil du slette denne samtale?')) return;
const hard = confirm('Skal dette være en permanent sletning af fil og data? (Kan ikke fortrydes)');
await fetch(\`/api/v1/conversations/\${id}?hard_delete=\${hard}\`, { method: 'DELETE' });
loadMyConversations();
}
async function updateCategory(id, newCategory) {
try {
const response = await fetch(`/api/v1/conversations/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({category: newCategory})
});
if (!response.ok) throw new Error('Update failed');
} catch (e) {
alert("Kunne ikke opdatere kategori");
console.error(e);
loadMyConversations(); // Revert UI on error
}
}</script>
{% endblock %}

View File

@ -0,0 +1,16 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter()
# Use "app" as base directory so we can reference templates like "conversations/frontend/templates/xxx.html"
templates = Jinja2Templates(directory="app")
@router.get("/conversations/my", response_class=HTMLResponse)
async def my_conversations_view(request: Request):
"""
Render the Technician's Conversations Dashboard
"""
return templates.TemplateResponse("conversations/frontend/templates/my_conversations.html", {"request": request})

View File

@ -19,6 +19,7 @@ class Settings(BaseSettings):
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
API_RELOAD: bool = False
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
# Security
SECRET_KEY: str = "dev-secret-key-change-in-production"
@ -63,7 +64,7 @@ class Settings(BaseSettings):
EMAIL_RULES_ENABLED: bool = True
EMAIL_RULES_AUTO_PROCESS: bool = False
EMAIL_AI_ENABLED: bool = False
EMAIL_AUTO_CLASSIFY: bool = False
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
EMAIL_MAX_FETCH_PER_RUN: int = 50
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
@ -150,6 +151,12 @@ class Settings(BaseSettings):
GITHUB_TOKEN: str = ""
GITHUB_REPO: str = "ct/bmc_hub"
# Whisper Transcription
WHISPER_ENABLED: bool = True
WHISPER_API_URL: str = "http://172.16.31.115:5000/transcribe"
WHISPER_TIMEOUT: int = 30
WHISPER_SUPPORTED_FORMATS: List[str] = [".mp3", ".wav", ".m4a", ".ogg"]
@field_validator('*', mode='before')
@classmethod
def strip_whitespace(cls, v):

View File

@ -380,13 +380,29 @@ async def get_customer(customer_id: int):
except Exception as e:
logger.error(f"❌ Error fetching BMC Låst status: {e}")
# Check for ACTIVE bankruptcy alerts
bankruptcy_alert = execute_query_single(
"""
SELECT id, subject, received_date
FROM email_messages
WHERE customer_id = %s
AND classification = 'bankruptcy'
AND status NOT IN ('processed', 'archived')
ORDER BY received_date DESC
LIMIT 1
""",
(customer_id,)
)
return {
**customer,
'contact_count': contact_count,
'bmc_locked': bmc_locked
'bmc_locked': bmc_locked,
'bankruptcy_alert': bankruptcy_alert
}
@router.post("/customers")
async def create_customer(customer: CustomerCreate):
"""Create a new customer"""

View File

@ -243,6 +243,22 @@
</div>
</div>
<!-- Bankruptcy Alert -->
<div id="bankruptcyAlert" class="alert alert-danger d-flex align-items-center mb-4 d-none border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
<div>Der er registreret en aktiv konkurs-notifikation på denne kunde. Stop levering og kontakt bogholderiet.</div>
<div class="mt-2 small">
<strong>Emne:</strong> <span id="bankruptcySubject" class="fst-italic"></span><br>
<strong>Modtaget:</strong> <span id="bankruptcyDate"></span>
</div>
</div>
<div>
<a id="bankruptcyLink" href="#" class="btn btn-sm btn-danger px-3">Se Email <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
<!-- Data Consistency Alert -->
<div id="consistencyAlert" class="alert alert-warning alert-dismissible fade show d-none mt-4" role="alert">
<div class="d-flex align-items-center">
@ -295,6 +311,11 @@
<i class="bi bi-clock-history"></i>Aktivitet
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#conversations">
<i class="bi bi-mic"></i>Samtaler
</a>
</li>
</ul>
</div>
@ -486,6 +507,30 @@
</div>
</div>
</div>
<!-- Conversations Tab -->
<div class="tab-pane fade" id="conversations">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Samtaler</h5>
<!--
<button class="btn btn-primary btn-sm" onclick="showUploadConversationModal()">
<i class="bi bi-upload me-2"></i>Upload Samtale
</button>
-->
</div>
<div class="input-group mb-4">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="conversationSearch" placeholder="Søg i transskriberinger..." onkeyup="filterConversations()">
</div>
<div id="conversationsContainer">
<!-- Loaded via JS -->
<div class="text-center py-5">
<span class="text-muted">Henter samtaler...</span>
</div>
</div>
</div>
</div>
</div>
</div>
@ -705,6 +750,14 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
// Load conversations when tab is shown
const conversationsTab = document.querySelector('a[href="#conversations"]');
if (conversationsTab) {
conversationsTab.addEventListener('shown.bs.tab', () => {
loadConversations();
}, { once: false });
}
eventListenersAdded = true;
});
@ -731,6 +784,23 @@ function displayCustomer(customer) {
// Update page title
document.title = `${customer.name} - BMC Hub`;
// Bankruptcy Alert
const bankruptcyAlert = document.getElementById('bankruptcyAlert');
if (customer.bankruptcy_alert) {
document.getElementById('bankruptcySubject').textContent = customer.bankruptcy_alert.subject;
document.getElementById('bankruptcyDate').textContent = new Date(customer.bankruptcy_alert.received_date).toLocaleString('da-DK');
document.getElementById('bankruptcyLink').href = `/emails?id=${customer.bankruptcy_alert.id}`;
bankruptcyAlert.classList.remove('d-none');
// Also add a badge to the header
const extraBadge = document.createElement('span');
extraBadge.className = 'badge bg-danger animate__animated animate__pulse animate__infinite ms-2';
extraBadge.innerHTML = '<i class="bi bi-shield-exclamation me-1"></i>KONKURS';
document.getElementById('customerStatus').parentNode.appendChild(extraBadge);
} else {
bankruptcyAlert.classList.add('d-none');
}
// Header
document.getElementById('customerAvatar').textContent = getInitials(customer.name);
document.getElementById('customerName').textContent = customer.name;
@ -1361,6 +1431,121 @@ async function loadActivity() {
}, 500);
}
async function loadConversations() {
const container = document.getElementById('conversationsContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/conversations?customer_id=${customerId}`);
if (!response.ok) throw new Error('Failed to load conversations');
const conversations = await response.json();
if (conversations.length === 0) {
container.innerHTML = '<div class="text-muted text-center py-5">Ingen samtaler fundet</div>';
return;
}
container.innerHTML = conversations.map(c => renderConversationCard(c)).join('');
} catch (error) {
console.error('Error loading conversations:', error);
container.innerHTML = '<div class="alert alert-danger">Kunne ikke hente samtaler</div>';
}
}
function renderConversationCard(c) {
const date = new Date(c.created_at).toLocaleString();
const duration = c.duration_seconds ? `${Math.floor(c.duration_seconds/60)}:${(c.duration_seconds%60).toString().padStart(2,'0')}` : '';
return `
<div class="card mb-3 shadow-sm conversation-item ${c.is_private ? 'border-warning' : ''}" data-text="${(c.transcript || '') + ' ' + (c.title || '')}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 class="card-title fw-bold mb-1">
${c.is_private ? '<i class="bi bi-lock-fill text-warning" title="Privat"></i> ' : ''}
${c.title}
</h6>
<div class="small text-muted">
<i class="bi bi-calendar me-1"></i> ${date}
${duration ? `• <i class="bi bi-clock me-1"></i> ${duration}` : ''}
<span class="badge bg-light text-dark border">${c.source}</span>
<span class="badge bg-secondary">${c.category || 'General'}</span>
</div>
</div>
<div class="dropdown">
<button class="btn btn-link text-muted p-0" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="togglePrivacy(${c.id}, ${!c.is_private})">
${c.is_private ? 'Gør Offentlig' : 'Gør Privat'}
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="deleteConversation(${c.id})">Slet</a></li>
</ul>
</div>
</div>
<audio controls class="w-100 mb-3 bg-light rounded">
<source src="/api/v1/conversations/${c.id}/audio" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
${c.transcript ? `
<div class="accordion" id="accordion-${c.id}">
<div class="accordion-item border-0">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2 bg-light rounded" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${c.id}">
<i class="bi bi-file-text me-2"></i> Vis Transskribering
</button>
</h2>
<div id="collapse-${c.id}" class="accordion-collapse collapse" data-bs-parent="#accordion-${c.id}">
<div class="accordion-body bg-light rounded mt-2 small font-monospace" style="white-space: pre-wrap;">${c.transcript}</div>
</div>
</div>
</div>
` : ''}
</div>
</div>
`;
}
function filterConversations() {
const query = document.getElementById('conversationSearch').value.toLowerCase();
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const text = item.getAttribute('data-text').toLowerCase();
item.style.display = text.includes(query) ? 'block' : 'none';
});
}
async function togglePrivacy(id, makePrivate) {
try {
await fetch(`/api/v1/conversations/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_private: makePrivate})
});
loadConversations(); // Reload
} catch(e) { alert('Fejl'); }
}
async function deleteConversation(id) {
if(!confirm('Vil du slette denne samtale?')) return;
// User requested "SLET alt" capability.
// If user confirms again, we do hard delete.
const hard = confirm('ADVARSEL: Skal dette være en permanent sletning af fil og data? (Kan ikke fortrydes)\n\nTryk OK for Permanent Sletning.\nTryk Cancel for Papirkurv.');
try {
await fetch(`/api/v1/conversations/${id}?hard_delete=${hard}`, { method: 'DELETE' });
loadConversations();
} catch(e) { alert('Fejl under sletning'); }
}
async function toggleSubscriptionDetails(subscriptionId, itemId) {
const linesDiv = document.getElementById(`${itemId}-lines`);
const icon = document.getElementById(`${itemId}-icon`);

View File

@ -18,12 +18,45 @@ async def dashboard(request: Request):
WHERE billing_method = 'unknown'
AND status NOT IN ('billed', 'rejected')
"""
start_date = "2024-01-01" # Filter ancient history if needed, but for now take all
# Fetch active bankruptcy alerts
# Finds emails classified as 'bankruptcy' that are not processed
bankruptcy_query = """
SELECT e.id, e.subject, e.received_date,
v.name as vendor_name, v.id as vendor_id,
c.name as customer_name, c.id as customer_id
FROM email_messages e
LEFT JOIN vendors v ON e.supplier_id = v.id
LEFT JOIN customers c ON e.customer_id = c.id
WHERE e.classification = 'bankruptcy'
AND e.status NOT IN ('archived')
AND (e.customer_id IS NOT NULL OR e.supplier_id IS NOT NULL)
ORDER BY e.received_date DESC
"""
from app.core.database import execute_query
result = execute_query_single(unknown_query)
unknown_count = result['count'] if result else 0
raw_alerts = execute_query(bankruptcy_query) or []
bankruptcy_alerts = []
for alert in raw_alerts:
item = dict(alert)
# Determine display name
if item.get('customer_name'):
item['display_name'] = f"Kunde: {item['customer_name']}"
elif item.get('vendor_name'):
item['display_name'] = item['vendor_name']
elif 'statstidende' in item.get('subject', '').lower():
item['display_name'] = 'Statstidende'
else:
item['display_name'] = 'Ukendt Afsender'
bankruptcy_alerts.append(item)
return templates.TemplateResponse("dashboard/frontend/index.html", {
"request": request,
"unknown_worklog_count": unknown_count
"unknown_worklog_count": unknown_count,
"bankruptcy_alerts": bankruptcy_alerts
})

View File

@ -15,6 +15,28 @@
</div>
<!-- Alerts -->
{% if bankruptcy_alerts %}
<div class="alert alert-danger d-flex align-items-center mb-4 border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
<div>Systemet har registreret <strong>{{ bankruptcy_alerts|length }}</strong> potentiel(le) konkurssag(er).</div>
<ul class="mb-0 mt-2 small list-unstyled">
{% for alert in bankruptcy_alerts %}
<li class="mb-1">
<span class="badge bg-danger me-2">ALARM</span>
<strong>{{ alert.display_name }}:</strong>
<a href="/emails?id={{ alert.id }}" class="alert-link text-decoration-underline">{{ alert.subject }}</a>
</li>
{% endfor %}
</ul>
</div>
<div>
<a href="/emails?filter=bankruptcy" class="btn btn-sm btn-danger px-3">Håndter Nu <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
{% endif %}
{% if unknown_worklog_count > 0 %}
<div class="alert alert-warning d-flex align-items-center mb-5" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-3 fs-4"></i>

View File

@ -148,6 +148,7 @@ class ProcessingStats(BaseModel):
async def list_emails(
status: Optional[str] = Query(None),
classification: Optional[str] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(50, le=500),
offset: int = Query(0, ge=0)
):
@ -164,6 +165,11 @@ async def list_emails(
where_clauses.append("em.classification = %s")
params.append(classification)
if q:
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)")
search_term = f"%{q}%"
params.extend([search_term, search_term, search_term])
where_sql = " AND ".join(where_clauses)
query = f"""
@ -645,25 +651,14 @@ async def bulk_reprocess(email_ids: List[int]):
result = execute_query(query, (email_id,))
if result:
email = result[0]
classification, confidence = await processor.classify_email(
email['subject'],
email['body_text'] or email['body_html']
)
update_query = """
UPDATE email_messages
SET classification = %s, confidence_score = %s,
classification_date = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_update(update_query, (classification, confidence, email_id))
email_data = result[0]
# Use central processing logic
await processor.process_single_email(email_data)
success_count += 1
except Exception as e:
logger.error(f"Failed to reprocess email {email_id}: {e}")
logger.error(f"Error reprocessing email {email_id}: {e}")
logger.info(f"🔄 Bulk reprocessed {success_count}/{len(email_ids)} emails")
return {"success": True, "message": f"{success_count} emails reprocessed"}
return {"success": True, "count": success_count}
except Exception as e:
logger.error(f"❌ Error bulk reprocessing: {e}")
@ -874,6 +869,7 @@ async def get_email_stats():
COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails,
COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices,
COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations,
COUNT(CASE WHEN classification = 'newsletter' THEN 1 END) as newsletters,
COUNT(CASE WHEN classification = 'spam' THEN 1 END) as spam_emails,
COUNT(CASE WHEN auto_processed THEN 1 END) as auto_processed,
AVG(confidence_score) as avg_confidence

View File

@ -877,6 +877,9 @@
<button class="filter-pill" data-filter="general" onclick="setFilter('general')">
Generel <span class="count" id="countGeneral">0</span>
</button>
<button class="filter-pill" data-filter="newsletter" onclick="setFilter('newsletter')">
Info/Nyhed <span class="count" id="countNewsletter">0</span>
</button>
<button class="filter-pill" data-filter="spam" onclick="setFilter('spam')">
Spam <span class="count" id="countSpam">0</span>
</button>
@ -1429,8 +1432,10 @@ async function loadEmails(searchQuery = '') {
// Handle special filters
if (currentFilter === 'active') {
// Show only new, error, or flagged (pending review) emails
// Exclude processed and archived
// If searching, ignore status filter to allow global search
if (!searchQuery) {
url += '&status=new';
}
} else if (currentFilter === 'processed') {
url += '&status=processed';
} else if (currentFilter !== 'all') {
@ -1879,7 +1884,8 @@ async function loadStats() {
document.getElementById('countFreight').textContent = 0;
document.getElementById('countTime').textContent = stats.time_confirmations || 0;
document.getElementById('countCase').textContent = 0;
document.getElementById('countGeneral').textContent = stats.total_emails - stats.invoices - stats.time_confirmations || 0;
document.getElementById('countNewsletter').textContent = stats.newsletters || 0;
document.getElementById('countGeneral').textContent = stats.total_emails - stats.invoices - stats.time_confirmations - (stats.newsletters || 0) || 0;
document.getElementById('countSpam').textContent = stats.spam_emails || 0;
} catch (error) {
console.error('Failed to load stats:', error);

View File

@ -105,3 +105,37 @@ class Vendor(VendorBase):
class Config:
from_attributes = True
class ConversationBase(BaseModel):
title: str
transcript: Optional[str] = None
summary: Optional[str] = None
is_private: bool = False
customer_id: Optional[int] = None
ticket_id: Optional[int] = None
category: str = "General"
class ConversationCreate(ConversationBase):
audio_file_path: str
duration_seconds: int = 0
email_message_id: Optional[int] = None
class ConversationUpdate(BaseModel):
title: Optional[str] = None
is_private: Optional[bool] = None
ticket_id: Optional[int] = None
customer_id: Optional[int] = None
category: Optional[str] = None
# For soft delete via update if needed, though usually strict API endpoint
class Conversation(ConversationBase):
id: int
audio_file_path: str
duration_seconds: int
user_id: Optional[int] = None
source: str
created_at: datetime
updated_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@ -160,47 +160,73 @@
<h5 class="modal-title">💳 Opret Nyt Prepaid Kort</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createCardForm">
<div class="mb-3">
<label class="form-label">Kunde *</label>
<select class="form-select" id="customerId" required>
<option value="">Vælg kunde...</option>
</select>
<div class="modal-body p-4">
<form id="createCardForm" class="needs-validation" novalidate>
<!-- Customer Dropdown -->
<div class="mb-4">
<label class="form-label fw-bold">Kunde <span class="text-danger">*</span></label>
<div class="dropdown" id="customerDropdown">
<button class="form-select text-start d-flex justify-content-between align-items-center" type="button"
data-bs-toggle="dropdown" aria-expanded="false" id="customerDropdownBtn">
<span class="text-muted">Vælg kunde...</span>
<i class="bi bi-chevron-down small"></i>
</button>
<div class="dropdown-menu w-100 p-2 shadow-sm" aria-labelledby="customerDropdownBtn">
<div class="px-2 pb-2">
<input type="text" class="form-control form-control-sm" id="customerSearchInput"
placeholder="🔍 Søg kunde..." autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label">Antal Timer *</label>
<div id="customerList" style="max-height: 250px; overflow-y: auto;">
<!-- Options will be injected here -->
</div>
</div>
<input type="hidden" id="customerId" required>
<div class="invalid-feedback">
Vælg venligst en kunde
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-7">
<label class="form-label fw-bold">Antal Timer <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-clock"></i></span>
<input type="number" class="form-control" id="purchasedHours"
step="0.5" min="1" required>
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(10)" title="10 timer">
10t
</button>
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(25)" title="25 timer">
25t
</button>
<button type="button" class="btn btn-outline-secondary" onclick="setPurchasedHours(50)" title="50 timer">
50t
</button>
step="0.5" min="1" required placeholder="0.0">
</div>
<div class="form-text">💡 Brug hurtigknapperne eller indtast tilpasset antal</div>
<div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(10)">10t</button>
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(25)">25t</button>
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(50)">50t</button>
</div>
</div>
<div class="col-md-5">
<label class="form-label fw-bold">Pris / Time <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" class="form-control text-end" id="pricePerHour"
step="0.01" min="0" required placeholder="0.00">
<span class="input-group-text">kr</span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Pris pr. Time (DKK) *</label>
<input type="number" class="form-control" id="pricePerHour"
step="0.01" min="0" required>
</div>
<div class="mb-3">
<label class="form-label">Udløbsdato (valgfri)</label>
<label class="form-label fw-bold">Udløbsdato <small class="text-muted fw-normal">(valgfri)</small></label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-calendar"></i></span>
<input type="date" class="form-control" id="expiresAt">
</div>
<div class="mb-3">
<label class="form-label">Bemærkninger</label>
<textarea class="form-control" id="notes" rows="3"></textarea>
</div>
<div class="alert alert-info small">
<i class="bi bi-info-circle"></i>
Kortnummeret bliver automatisk genereret
<div class="mb-3">
<label class="form-label fw-bold">Bemærkninger</label>
<textarea class="form-control" id="notes" rows="3" placeholder="Interne noter..."></textarea>
</div>
<div class="alert alert-light border small text-muted d-flex align-items-center gap-2">
<i class="bi bi-info-circle-fill text-primary"></i>
Kortnummeret genereres automatisk ved oprettelse
</div>
</form>
</div>
@ -383,35 +409,112 @@ function setPurchasedHours(hours) {
}
// Load Customers for Dropdown
let allCustomers = [];
async function loadCustomers() {
try {
const response = await fetch('/api/v1/customers');
const customers = await response.json();
// Fetch max customers for client-side filtering (up to 1000)
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
const select = document.getElementById('customerId');
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
// Handle API response format (might be array or paginated object)
allCustomers = Array.isArray(data) ? data : (data.customers || []);
renderCustomerDropdown(allCustomers);
// Setup search listener
const searchInput = document.getElementById('customerSearchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = allCustomers.filter(c =>
(c.name || '').toLowerCase().includes(query) ||
(c.email || '').toLowerCase().includes(query)
);
renderCustomerDropdown(filtered);
});
// Prevent dropdown from closing when clicking input
searchInput.addEventListener('click', (e) => e.stopPropagation());
}
} catch (error) {
console.error('Error loading customers:', error);
allCustomers = [];
renderCustomerDropdown([]);
}
}
function renderCustomerDropdown(customers) {
const list = document.getElementById('customerList');
if (!list) return;
if (customers.length === 0) {
list.innerHTML = '<div class="text-muted p-2 text-center small">Ingen kunder fundet</div>';
return;
}
list.innerHTML = customers.map(c => `
<a href="#" class="dropdown-item py-2 border-bottom" onclick="selectCustomer(${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<div class="fw-bold">${c.name}</div>
${c.email ? `<small class="text-muted">${c.email}</small>` : ''}
</a>
`).join('');
}
function selectCustomer(id, name) {
document.getElementById('customerId').value = id;
const btn = document.getElementById('customerDropdownBtn');
btn.innerHTML = `
<span class="fw-bold text-dark">${name}</span>
<i class="bi bi-chevron-down small"></i>
`;
btn.classList.add('border-primary'); // Highlight selection
// Reset search
document.getElementById('customerSearchInput').value = '';
renderCustomerDropdown(allCustomers);
}
// Open Create Modal
function openCreateModal() {
document.getElementById('createCardForm').reset();
const form = document.getElementById('createCardForm');
form.reset();
form.classList.remove('was-validated');
// Reset dropdown
document.getElementById('customerId').value = '';
document.getElementById('customerDropdownBtn').innerHTML = `
<span class="text-muted">Vælg kunde...</span>
<i class="bi bi-chevron-down small"></i>
`;
document.getElementById('customerDropdownBtn').classList.remove('border-primary', 'is-invalid');
createCardModal.show();
}
// Create Card
async function createCard() {
const form = document.getElementById('createCardForm');
if (!form.checkValidity()) {
form.reportValidity();
// Custom validation for dropdown
const customerId = document.getElementById('customerId').value;
const dropdownBtn = document.getElementById('customerDropdownBtn');
if (!customerId) {
dropdownBtn.classList.add('is-invalid');
} else {
dropdownBtn.classList.remove('is-invalid');
}
if (!form.checkValidity() || !customerId) {
form.classList.add('was-validated');
return;
}
const data = {
customer_id: parseInt(document.getElementById('customerId').value),
customer_id: parseInt(customerId),
purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
expires_at: document.getElementById('expiresAt').value || null,
@ -434,6 +537,9 @@ async function createCard() {
loadStats();
loadCards();
// Show success toast or alert
// alert('✅ Prepaid kort oprettet!'); // Using toast instead if available, keeping alert for now
// But let's use a nicer non-blocking notification if possible, but sticking to existing pattern
alert('✅ Prepaid kort oprettet!');
} catch (error) {
console.error('Error creating card:', error);
@ -441,6 +547,7 @@ async function createCard() {
}
}
// Cancel Card
async function cancelCard(cardId) {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
@ -498,5 +605,52 @@ document.getElementById('customerSearch').addEventListener('input', loadCards);
.btn-group-sm .btn {
padding: 0.25rem 0.5rem;
}
/* Custom Dropdown Styles */
#customerDropdownBtn {
background-color: #fff;
border: 1px solid #ced4da;
}
#customerDropdownBtn:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
#customerDropdownBtn.is-invalid {
border-color: #dc3545;
}
#customerDropdownBtn.is-invalid ~ .invalid-feedback {
display: block;
}
#customerList .dropdown-item:active,
#customerList .dropdown-item.active {
background-color: var(--bs-primary);
color: white;
}
#customerList .dropdown-item:active small,
#customerList .dropdown-item.active small {
color: rgba(255,255,255,0.8) !important;
}
/* Modal Styling Improvements */
#createCardModal .modal-content {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
#createCardModal .modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #eee;
}
#createCardModal .input-group-text {
background-color: #f8f9fa;
color: #6c757d;
}
</style>
{% endblock %}

View File

@ -61,13 +61,14 @@ class EmailAnalysisService:
"""Build Danish system prompt for email classification"""
return """Classify this Danish business email into ONE category. Return ONLY valid JSON with no explanation.
Categories: invoice, freight_note, order_confirmation, time_confirmation, case_notification, customer_email, bankruptcy, general, spam, unknown
Categories: invoice, freight_note, order_confirmation, time_confirmation, case_notification, customer_email, bankruptcy, newsletter, general, spam, unknown
Rules:
- invoice: Contains invoice number, amount, or payment info
- time_confirmation: Time/hours confirmation, often with case references
- case_notification: Notifications about specific cases (CC0001, Case #123)
- bankruptcy: Explicit bankruptcy/insolvency notice
- newsletter: Info mails, marketing, campaigns, webinars, or non-critical updates (not spam)
- Be conservative: Use general or unknown if uncertain
Response format (JSON only, no other text):

View File

@ -10,11 +10,12 @@ from datetime import datetime
from app.services.email_service import EmailService
from app.services.email_analysis_service import EmailAnalysisService
from app.services.transcription_service import TranscriptionService
from app.services.simple_classifier import simple_classifier
from app.services.email_workflow_service import email_workflow_service
from app.services.email_activity_logger import email_activity_logger
from app.core.config import settings
from app.core.database import execute_query, execute_update
from app.core.database import execute_query, execute_update, execute_insert
logger = logging.getLogger(__name__)
@ -25,6 +26,7 @@ class EmailProcessorService:
def __init__(self):
self.email_service = EmailService()
self.analysis_service = EmailAnalysisService()
self.transcription_service = TranscriptionService()
self.enabled = settings.EMAIL_TO_TICKET_ENABLED
self.rules_enabled = settings.EMAIL_RULES_ENABLED
self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS
@ -78,37 +80,13 @@ class EmailProcessorService:
message_id=email_data.get('message_id', 'unknown')
)
# Step 3: Classify email with AI
if settings.EMAIL_AI_ENABLED and settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data)
# Step 3-5: Process the single email
result = await self.process_single_email(email_data)
if result.get('classified'):
stats['classified'] += 1
# Step 4: Execute workflows based on classification
workflow_processed = False
if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') and settings.EMAIL_WORKFLOWS_ENABLED:
workflow_result = await email_workflow_service.execute_workflows(email_data)
if workflow_result.get('workflows_executed', 0) > 0:
logger.info(f"✅ Executed {workflow_result['workflows_executed']} workflow(s) for email {email_id}")
# Mark as workflow-processed to avoid duplicate rule execution
if workflow_result.get('workflows_succeeded', 0) > 0:
workflow_processed = True
email_data['_workflow_processed'] = True
# Step 5: Match against rules (legacy support) - skip if workflow already processed
if self.rules_enabled and not workflow_processed:
# Check if workflow already processed this email
existing_execution = execute_query_single(
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
(email_id,))
if existing_execution:
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")
else:
matched = await self._match_rules(email_data)
if matched:
if result.get('rules_matched'):
stats['rules_matched'] += 1
elif workflow_processed:
logger.info(f"⏭️ Email {email_id} processed by workflow, skipping rules (coordination)")
except Exception as e:
logger.error(f"❌ Error processing email: {e}")
@ -122,21 +100,106 @@ class EmailProcessorService:
stats['errors'] += 1
return stats
async def process_single_email(self, email_data: Dict) -> Dict:
"""
Process a single email: Classify -> Workflow -> Rules
Can be used by process_inbox (new emails) or bulk_reprocess (existing emails)
"""
email_id = email_data.get('id')
stats = {
'classified': False,
'workflows_executed': 0,
'rules_matched': False
}
try:
# Step 2.5: Detect and transcribe audio attachments
# This is done BEFORE classification so the AI can "read" the voice note
if settings.WHISPER_ENABLED:
await self._process_attachments_for_transcription(email_data)
# Step 3: Classify email (AI or Keyword)
if settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data)
stats['classified'] = True
# Step 4: Execute workflows based on classification
workflow_processed = False
if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') and settings.EMAIL_WORKFLOWS_ENABLED:
workflow_result = await email_workflow_service.execute_workflows(email_data)
executed_count = workflow_result.get('workflows_executed', 0)
stats['workflows_executed'] = executed_count
if executed_count > 0:
logger.info(f"✅ Executed {executed_count} workflow(s) for email {email_id}")
# Mark as workflow-processed to avoid duplicate rule execution
if workflow_result.get('workflows_succeeded', 0) > 0:
workflow_processed = True
email_data['_workflow_processed'] = True
# Definition: A processed email is one that is classified and workflow run
# Mark as 'processed' and move to 'Processed' folder
logger.info(f"✅ Auto-marking email {email_id} as processed (Workflow executed)")
execute_update(
"""UPDATE email_messages
SET status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
auto_processed = true
WHERE id = %s""",
(email_id,)
)
# Step 5: Match against rules (legacy support) - skip if workflow already processed
if self.rules_enabled and not workflow_processed:
# Check if workflow already processed this email (double check DB)
existing_execution = execute_query(
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
(email_data['id'],))
if existing_execution:
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")
else:
matched = await self._match_rules(email_data)
if matched:
stats['rules_matched'] = True
elif workflow_processed:
logger.info(f"⏭️ Email {email_id} processed by workflow, skipping rules (coordination)")
return stats
except Exception as e:
logger.error(f"❌ Error in process_single_email for {email_id}: {e}")
raise e
async def _classify_and_update(self, email_data: Dict):
"""Classify email and update database"""
try:
logger.info(f"🔍 _classify_and_update: ai_enabled={self.ai_enabled}, EMAIL_AI_ENABLED={settings.EMAIL_AI_ENABLED}")
# Run classification (AI or simple keyword-based)
if self.ai_enabled:
result = await self.analysis_service.classify_email(email_data)
else:
logger.info(f"🔍 Using simple keyword classifier for email {email_data['id']}")
# 1. Always start with Simple Keyword Classification (fast, free, deterministic)
logger.info(f"🔍 Running keyword classification for email {email_data['id']}")
result = simple_classifier.classify(email_data)
classification = result.get('classification', 'unknown')
confidence = result.get('confidence', 0.0)
# 2. Use AI if keyword analysis is weak or inconclusive
# Trigger if: 'general' OR 'unknown' OR confidence is low (< 0.70)
should_use_ai = (classification in ['general', 'unknown'] or confidence < 0.70)
if should_use_ai and self.ai_enabled:
logger.info(f"🤖 Escalating to AI analysis (Reason: '{classification}' with confidence {confidence})")
ai_result = await self.analysis_service.classify_email(email_data)
# Update result if AI returns valid data
if ai_result:
result = ai_result
logger.info(f"✅ AI re-classified as '{result.get('classification')}'")
classification = result.get('classification', 'unknown')
confidence = result.get('confidence', 0.0)
# Update email record
query = """
UPDATE email_messages
@ -457,6 +520,85 @@ class EmailProcessorService:
"""
execute_query(query, (rule_id,))
async def _process_attachments_for_transcription(self, email_data: Dict) -> None:
"""
Scan attachments for audio files, transcribe them, and enrich email body.
Also creates 'conversations' record.
"""
attachments = email_data.get('attachments', [])
if not attachments:
return
import hashlib
from pathlib import Path
transcripts = []
for att in attachments:
filename = att.get('filename', '')
content = att.get('content')
# Simple check, TranscriptionService does the real check
ext = Path(filename).suffix.lower()
if ext in settings.WHISPER_SUPPORTED_FORMATS:
transcript = await self.transcription_service.transcribe_audio(filename, content)
if transcript:
transcripts.append(f"--- TRANSKRIBERET LYDFIL ({filename}) ---\n{transcript}\n----------------------------------")
# Create conversation record
try:
# Reconstruct path - mirroring EmailService._save_attachments logic
md5_hash = hashlib.md5(content).hexdigest()
# Default path in EmailService is "uploads/email_attachments"
file_path = f"uploads/email_attachments/{md5_hash}_{filename}"
# Determine user_id (optional, maybe from sender if internal?)
# For now, create as system/unassigned
query = """
INSERT INTO conversations
(title, transcript, audio_file_path, source, email_message_id, created_at)
VALUES (%s, %s, %s, 'email', %s, CURRENT_TIMESTAMP)
RETURNING id
"""
conversation_id = execute_insert(query, (
f"Email Attachment: {filename}",
transcript,
file_path,
email_data.get('id')
))
# Try to link to customer if we already know them?
# ACTUALLY: We are BEFORE classification/domain matching.
# Ideally, we should link later.
# BUT, we can store the 'email_id' if we had a column.
# I didn't add 'email_id' to conversations table.
# I added customer_id and ticket_id.
# Since this runs BEFORE those links are established, the conversation will be orphaned initially.
# We could improve this by updating the conversation AFTER Step 5 (customer linking).
# Or, simplified: The transcribed text is in the email body.
# When the email is converted to a Ticket, the text follows.
# But the 'Conversation' record is separate.
logger.info(f"✅ Created conversation record {conversation_id} for {filename}")
except Exception as e:
logger.error(f"❌ Failed to create conversation record: {e}")
if transcripts:
# Append to body
full_transcript_text = "\n\n" + "\n\n".join(transcripts)
if 'body' in email_data:
email_data['body'] += full_transcript_text
# Also update body_text if it exists (often used for analysis)
if 'body_text' in email_data and email_data['body_text']:
email_data['body_text'] += full_transcript_text
logger.info(f"✅ Enriched email {email_data.get('id')} with {len(transcripts)} transcription(s)")
async def reprocess_email(self, email_id: int):
"""Manually reprocess a single email"""
try:

View File

@ -51,15 +51,6 @@ class EmailWorkflowService:
logger.info(f"🔄 Finding workflows for classification: {classification} (confidence: {confidence})")
# Find matching workflows
workflows = await self._find_matching_workflows(email_data)
if not workflows:
logger.info(f"✅ No workflows match classification: {classification}")
return {'status': 'no_match', 'workflows_executed': 0}
logger.info(f"📋 Found {len(workflows)} matching workflow(s)")
results = {
'status': 'executed',
'workflows_executed': 0,
@ -68,6 +59,29 @@ class EmailWorkflowService:
'details': []
}
# Special System Workflow: Bankruptcy Analysis
# Parses Statstidende emails for CVR numbers to link to customers
if classification == 'bankruptcy':
sys_result = await self._handle_bankruptcy_analysis(email_data)
results['details'].append(sys_result)
if sys_result['status'] == 'completed':
results['workflows_executed'] += 1
results['workflows_succeeded'] += 1
logger.info("✅ Bankruptcy system workflow executed successfully")
# Find matching workflows
workflows = await self._find_matching_workflows(email_data)
if not workflows and results['workflows_executed'] == 0:
logger.info(f"✅ No workflows match classification: {classification}")
return {'status': 'no_match', 'workflows_executed': 0}
logger.info(f"📋 Found {len(workflows)} matching workflow(s)")
# Initialize results if not already (moved up)
# results = { ... } (already initialized in my thought, but need to move init up)
# Execute workflows in priority order
for workflow in workflows:
result = await self._execute_workflow(workflow, email_data)
@ -87,6 +101,81 @@ class EmailWorkflowService:
logger.info(f"✅ Workflow execution complete: {results['workflows_succeeded']}/{results['workflows_executed']} succeeded")
return results
async def _handle_bankruptcy_analysis(self, email_data: Dict) -> Dict:
"""
System workflow for bankruptcy emails (Statstidende).
Parses body for CVR numbers and links to customer if match found.
Returns: Execution result dict
"""
logger.info("🕵️ Running Bankruptcy Analysis on email")
# Combine subject, body and html for search
text_content = (
f"{email_data.get('subject', '')} "
f"{email_data.get('body_text', '')} "
f"{email_data.get('body_html', '')}"
)
# Regex for CVR numbers (8 digits, possibly preceded by 'CVR-nr.:')
# We look for explicit 'CVR-nr.: XXXXXXXX' pattern first as it's more reliable
cvr_matches = re.findall(r'CVR-nr\.?:?\s*(\d{8})', text_content, re.IGNORECASE)
if not cvr_matches:
logger.info("✅ No CVR numbers found in bankruptcy email")
return {'status': 'skipped', 'reason': 'no_cvr_found'}
unique_cvrs = list(set(cvr_matches))
logger.info(f"📋 Found CVRs in email: {unique_cvrs}")
if not unique_cvrs:
return {'status': 'skipped', 'reason': 'no_unique_cvr'}
# Check if any CVRs belong to our customers
# Safe parameterized query for variable list length
format_strings = ','.join(['%s'] * len(unique_cvrs))
query = f"""
SELECT id, name, cvr_number
FROM customers
WHERE cvr_number IN ({format_strings})
"""
matching_customers = execute_query(query, tuple(unique_cvrs))
if not matching_customers:
logger.info("✅ No matching customers found for bankruptcy CVRs - Marking as processed")
execute_update(
"""UPDATE email_messages
SET status = 'processed', folder = 'Processed',
processed_at = CURRENT_TIMESTAMP, auto_processed = true
WHERE id = %s""",
(email_data['id'],)
)
return {'status': 'completed', 'action': 'marked_processed_no_match'}
logger.warning(f"⚠️ FOUND BANKRUPTCY MATCHES: {[c['name'] for c in matching_customers]}")
# Link to the first customer found (limitation of 1:1 schema)
first_match = matching_customers[0]
execute_update(
"""UPDATE email_messages
SET customer_id = %s, status = 'processed', folder = 'Processed',
processed_at = CURRENT_TIMESTAMP, auto_processed = true
WHERE id = %s""",
(first_match['id'], email_data['id'])
)
logger.info(f"🔗 Linked bankruptcy email {email_data['id']} to customer {first_match['name']} ({first_match['id']}) and marked as processed")
if len(matching_customers) > 1:
logger.warning(f"❗ Email contained multiple customer matches! Only linked to first one.")
return {
'status': 'completed',
'action': 'linked_customer',
'customer_name': first_match['name']
}
async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]:
"""Find all workflows that match this email"""
classification = email_data.get('classification')
@ -273,6 +362,7 @@ class EmailWorkflowService:
'link_to_customer': self._action_link_to_customer,
'extract_invoice_data': self._action_extract_invoice_data,
'extract_tracking_number': self._action_extract_tracking_number,
'regex_extract_and_link': self._action_regex_extract_and_link,
'send_slack_notification': self._action_send_slack_notification,
'send_email_notification': self._action_send_email_notification,
'mark_as_processed': self._action_mark_as_processed,
@ -303,6 +393,70 @@ class EmailWorkflowService:
# Action Handlers
async def _action_regex_extract_and_link(self, params: Dict, email_data: Dict) -> Dict:
"""
Generic action to extract data via regex and link/update record
Params:
- regex_pattern: Pattern with one capture group (e.g. "CVR: (\d{8})")
- target_table: Table to search (e.g. "customers")
- target_column: Column to match value against (e.g. "cvr_number")
- link_column: Column in email_messages to update (e.g. "customer_id")
- value_column: Column in target table to retrieve (e.g. "id")
- on_match: "update_email" (default) or "none"
"""
regex_pattern = params.get('regex_pattern')
target_table = params.get('target_table')
target_column = params.get('target_column')
link_column = params.get('link_column', 'customer_id')
value_column = params.get('value_column', 'id')
if not all([regex_pattern, target_table, target_column]):
return {'status': 'failed', 'error': 'Missing required params: regex_pattern, target_table, target_column'}
# Combine text for search
text_content = (
f"{email_data.get('subject', '')} "
f"{email_data.get('body_text', '')} "
f"{email_data.get('body_html', '')}"
)
# 1. Run Regex
matches = re.findall(regex_pattern, text_content, re.IGNORECASE)
unique_matches = list(set(matches))
if not unique_matches:
return {'status': 'skipped', 'reason': 'no_regex_match', 'pattern': regex_pattern}
logger.info(f"🔍 Regex '{regex_pattern}' found matches: {unique_matches}")
# 2. Look up in Target Table
# Safety check: simplistic validation against SQL injection for table/column names is assumed
# (params should come from trustworthy configuration)
valid_tables = ['customers', 'vendors', 'users']
if target_table not in valid_tables:
return {'status': 'failed', 'error': f'Invalid target table: {target_table}'}
placeholders = ','.join(['%s'] * len(unique_matches))
query = f"SELECT {value_column}, {target_column} FROM {target_table} WHERE {target_column} IN ({placeholders})"
db_matches = execute_query(query, tuple(unique_matches))
if not db_matches:
return {'status': 'completed', 'action': 'no_db_match', 'found_values': unique_matches}
# 3. Link (Update Email)
match = db_matches[0] # Take first match
match_value = match[value_column]
if params.get('on_match', 'update_email') == 'update_email':
update_query = f"UPDATE email_messages SET {link_column} = %s WHERE id = %s"
execute_update(update_query, (match_value, email_data['id']))
logger.info(f"🔗 Linked email {email_data['id']} to {target_table}.{value_column}={match_value}")
return {'status': 'completed', 'action': 'linked', 'match_id': match_value}
return {'status': 'completed', 'action': 'found_only', 'match_id': match_value}
async def _action_create_ticket_system(self, params: Dict, email_data: Dict) -> Dict:
"""Create a ticket from email using new ticket system"""
from app.ticket.backend.email_integration import EmailTicketIntegration

View File

@ -39,6 +39,14 @@ class SimpleEmailClassifier:
'konkurs', 'bankruptcy', 'rekonstruktion', 'insolvency',
'betalingsstandsning', 'administration'
],
'newsletter': [
'nyhedsbrev', 'newsletter', 'kampagne', 'campaign',
'tilbud', 'offer', 'webinar', 'invitation', 'event',
'update', 'opdatering', 'salg', 'sale', 'black friday',
'cyber monday', 'sommerudsalg', 'vinterudsalg', 'rabat',
'discount', 'no-reply', 'noreply', 'automatisk besked',
'auto-generated'
],
'spam': [
'unsubscribe', 'click here', 'free offer', 'gratis tilbud',
'vind nu', 'win now', 'limited time'

View File

@ -0,0 +1,80 @@
"""
Transcription Service
Handles communication with the external Whisper API for audio transcription.
"""
import logging
import aiohttp
import asyncio
from typing import Optional, Dict, Any, List
from pathlib import Path
import json
from app.core.config import settings
logger = logging.getLogger(__name__)
class TranscriptionService:
"""Service for transcribing audio files via external Whisper API"""
def __init__(self):
self.api_url = settings.WHISPER_API_URL
self.enabled = settings.WHISPER_ENABLED
self.timeout = settings.WHISPER_TIMEOUT
self.supported_formats = settings.WHISPER_SUPPORTED_FORMATS
async def transcribe_audio(self, filename: str, content: bytes) -> Optional[str]:
"""
Send audio content to Whisper API and return the transcript.
Args:
filename: Name of the file (used for format detection/logging)
content: Raw bytes of the audio file
Returns:
Transcribed text or None if failed
"""
if not self.enabled:
logger.debug("Whisper transcription is disabled in settings")
return None
# Basic extension check
ext = Path(filename).suffix.lower()
if ext not in self.supported_formats:
logger.debug(f"Skipping transcription for unsupported format: {filename}")
return None
logger.info(f"🎙️ Transcribing audio file: {filename} ({len(content)} bytes)")
try:
# Prepare the form data
# API expects: file=@filename
data = aiohttp.FormData()
data.add_field('file', content, filename=filename)
timeout = aiohttp.ClientTimeout(total=self.timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(self.api_url, data=data) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"❌ Whisper API error ({response.status}): {error_text}")
return None
result = await response.json()
# Expected format: {"results": [{"filename": "...", "transcript": "..."}]}
if 'results' in result and len(result['results']) > 0:
transcript = result['results'][0].get('transcript', '').strip()
logger.info(f"✅ Transcription successful for {filename}")
return transcript
else:
logger.warning(f"⚠️ Whisper API returned unexpected format: {result}")
return None
except asyncio.TimeoutError:
logger.error(f"❌ Whisper API timed out after {self.timeout} seconds for {filename}")
return None
except Exception as e:
logger.error(f"❌ Error during transcription of {filename}: {str(e)}")
return None

View File

@ -273,18 +273,17 @@ async def reset_user_password(user_id: int, new_password: str):
return {"message": "Password reset successfully"}
# AI Prompts Endpoint
@router.get("/ai-prompts", tags=["Settings"])
async def get_ai_prompts():
"""Get all AI prompts used in the system"""
from app.services.ollama_service import OllamaService
# AI Prompts Management
def _get_default_prompts():
"""Helper to get default system prompts"""
from app.services.ollama_service import OllamaService
ollama_service = OllamaService()
prompts = {
return {
"invoice_extraction": {
"name": "Faktura Udtrækning (Invoice Extraction)",
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM",
"name": "📄 Faktura Udtrækning (Invoice Parser)",
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM. Håndterer danske nummerformater, datoer og linjegenkendelse.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": ollama_service._build_system_prompt(),
@ -293,7 +292,209 @@ async def get_ai_prompts():
"top_p": 0.9,
"num_predict": 2000
}
},
"ticket_classification": {
"name": "🎫 Ticket Klassificering (Auto-Triage)",
"description": "Klassificerer indkomne tickets baseret på emne og indhold. Tildeler kategori, prioritet og ansvarlig team.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en erfaren IT-supporter der skal klassificere indkomne support-sager.
Dine opgaver er:
1. Analyser emne og beskrivelse
2. Bestem Kategori: [Hardware, Software, Netværk, Adgang, Andet]
3. Bestem Prioritet: [Lav, Mellem, Høj, Kritisk]
4. Foreslå handlingsplan (kort punktform)
Output skal være gyldig JSON:
{
"category": "string",
"priority": "string",
"summary": "string",
"suggested_actions": ["string"]
}""",
"parameters": {
"temperature": 0.3,
"top_p": 0.95,
"num_predict": 1000
}
},
"ticket_summary": {
"name": "📝 Ticket Summering (Fakturagrundlag)",
"description": "Analyserer alle kommentarer og noter i en ticket for at lave et kort, præcist resumé til fakturaen eller kunden.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en administrativ assistent der skal gøre en it-sag klar til fakturering.
Opgave: Læs historikken igennem og skriv et kort resumé af det udførte arbejde.
- Fokusér løsningen, ikke problemet
- Brug professionelt sprog
- Undlad interne tekniske detaljer (medmindre relevant for faktura)
- Sprog: Dansk
- Længde: 2-3 sætninger
Input: [Liste af kommentarer]
Output: [Fakturastekst]""",
"parameters": {
"temperature": 0.2,
"top_p": 0.9,
"num_predict": 500
}
},
"kb_generation": {
"name": "📚 Vidensbank Generator (Solution to Article)",
"description": "Omdanner en løst ticket til en generel vejledning til vidensbanken.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er teknisk forfatter. Din opgave er at omskrive en konkret support-sag til en generel vejledning.
Regler:
1. Fjern alle kunde-specifikke data (navne, IP-adresser, passwords)
2. Strukturer som:
- Problem
- Årsag (hvis kendt)
- Løsning (Trin-for-trin guide)
3. Brug letforståeligt dansk
4. Formater med Markdown
Input: [Ticket Beskrivelse + Løsning]
Output: [Markdown Guide]""",
"parameters": {
"temperature": 0.4,
"top_p": 0.9,
"num_predict": 2000
}
},
"troubleshooting_assistant": {
"name": "🔧 Fejlsøgnings Copilot (Tech Helper)",
"description": "Fungerer som en senior-tekniker der giver sparring på en fejlbeskrivelse. Foreslår konkrete fejlsøgningstrin og kommandoer.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en Senior Systemadministrator med 20 års erfaring.
En junior-tekniker spørger om hjælp til et problem.
Din opgave:
1. Analyser symptomerne
2. List de 3 mest sandsynlige årsager
3. Foreslå en trin-for-trin fejlsøgningsplan (start med det mest sandsynlige)
4. Nævn relevante værktøjer eller kommandoer (Windows/Linux/Network)
Vær kortfattet, teknisk præcis og handlingsorienteret.
Sprog: Dansk (men engelske fagtermer er OK).
Input: [Fejlbeskrivelse]
Output: [Markdown Guide]""",
"parameters": {
"temperature": 0.3,
"top_p": 0.9,
"num_predict": 1500
}
},
"sentiment_analysis": {
"name": "🌡️ Sentiment Analyse (Kunde-Humør)",
"description": "Analyserer tonen i en kundehenvendelse for at vurdere hast, frustration og risiko. Bruges til prioritering.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Analyser tonen i følgende tekst fra en kunde.
Bestem følgende:
1. Sentiment: [Positiv, Neutral, Frustreret, Vred]
2. Hastegrad-indikatorer: Er der ord der indikerer panik eller kritisk hast?
3. Risikovurdering (0-10): Hvor stor risiko er der for at kunden forlader os? (10=Høj)
Returner resultatet som JSON format.
Input: [Kunde Tekst]
Output: { "sentiment": "...", "urgency": "...", "risk_score": 0 }""",
"parameters": {
"temperature": 0.1,
"top_p": 0.9,
"num_predict": 500
}
},
"meeting_action_items": {
"name": "📋 Mødenoter til Opgaver (Action Extraction)",
"description": "Scanner rå mødereferater eller notater og udtrækker konkrete 'Action Items', deadlines og ansvarlige personer.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en effektiv projektleder-assistent.
Din opgave er at scanne mødereferater og udtrække "Action Items".
For hver opgave skal du finde:
- Aktivitet (Hvad skal gøres?)
- Ansvarlig (Hvem?)
- Deadline (Hvornår? Hvis nævnt)
Ignorer løs snak og diskussioner. Fokusér kun beslutninger og opgaver der skal udføres.
Outputtet skal være en punktopstilling.
Input: [Mødenoter]
Output: [Liste af opgaver]""",
"parameters": {
"temperature": 0.2,
"top_p": 0.9,
"num_predict": 1000
}
}
}
@router.get("/ai-prompts", tags=["Settings"])
async def get_ai_prompts():
"""Get all AI prompts (defaults merged with custom overrides)"""
prompts = _get_default_prompts()
try:
# Check for custom overrides in DB
# Note: Table ai_prompts must rely on migration 066
rows = execute_query("SELECT key, prompt_text FROM ai_prompts")
if rows:
for row in rows:
if row['key'] in prompts:
prompts[row['key']]['prompt'] = row['prompt_text']
prompts[row['key']]['is_custom'] = True
except Exception as e:
logger.warning(f"Could not load custom ai prompts: {e}")
return prompts
class PromptUpdate(BaseModel):
prompt_text: str
@router.put("/ai-prompts/{key}", tags=["Settings"])
async def update_ai_prompt(key: str, update: PromptUpdate):
"""Override a system prompt with a custom one"""
defaults = _get_default_prompts()
if key not in defaults:
raise HTTPException(status_code=404, detail="Unknown prompt key")
try:
# Upsert
query = """
INSERT INTO ai_prompts (key, prompt_text, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (key)
DO UPDATE SET prompt_text = EXCLUDED.prompt_text, updated_at = CURRENT_TIMESTAMP
RETURNING key
"""
execute_query(query, (key, update.prompt_text))
return {"message": "Prompt updated", "key": key}
except Exception as e:
logger.error(f"Error saving prompt: {e}")
raise HTTPException(status_code=500, detail="Could not save prompt")
@router.delete("/ai-prompts/{key}", tags=["Settings"])
async def reset_ai_prompt(key: str):
"""Reset a prompt to its system default"""
try:
execute_query("DELETE FROM ai_prompts WHERE key = %s", (key,))
return {"message": "Prompt reset to default"}
except Exception as e:
logger.error(f"Error resetting prompt: {e}")
raise HTTPException(status_code=500, detail="Could not reset prompt")

View File

@ -980,41 +980,92 @@ async function loadAIPrompts() {
const prompts = await response.json();
const container = document.getElementById('aiPromptsContent');
container.innerHTML = Object.entries(prompts).map(([key, prompt]) => `
<div class="card mb-4">
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="mb-1 fw-bold">${escapeHtml(prompt.name)}</h6>
<small class="text-muted">${escapeHtml(prompt.description)}</small>
const accordionHtml = `
<div class="accordion" id="aiPromptsAccordion">
${Object.entries(prompts).map(([key, prompt], index) => `
<div class="accordion-item">
<h2 class="accordion-header" id="heading_${key}">
<button class="accordion-button ${index !== 0 ? 'collapsed' : ''}" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse_${key}"
aria-expanded="${index === 0 ? 'true' : 'false'}" aria-controls="collapse_${key}">
<div class="d-flex w-100 justify-content-between align-items-center pe-3">
<div class="d-flex flex-column align-items-start">
<span class="fw-bold">
${escapeHtml(prompt.name)}
${prompt.is_custom ? '<span class="badge bg-warning text-dark ms-2" style="font-size: 0.65rem;">Ændret</span>' : ''}
</span>
<small class="text-muted fw-normal">${escapeHtml(prompt.description)}</small>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="copyPrompt('${key}')">
<i class="bi bi-clipboard me-1"></i>Kopier
</div>
</button>
</h2>
<div id="collapse_${key}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
aria-labelledby="heading_${key}" data-bs-parent="#aiPromptsAccordion">
<div class="accordion-body bg-light">
<div class="row mb-3">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body py-2">
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Model</small>
<div class="font-monospace text-primary">${escapeHtml(prompt.model)}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body py-2">
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Endpoint</small>
<div class="font-monospace text-truncate" title="${escapeHtml(prompt.endpoint)}">${escapeHtml(prompt.endpoint)}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body py-2">
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Parametre</small>
<div class="font-monospace small text-truncate" title='${JSON.stringify(prompt.parameters)}'>${JSON.stringify(prompt.parameters)}</div>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-2">
<span class="fw-bold small text-uppercase text-muted"><i class="bi bi-terminal me-2"></i>System Client Prompt</span>
<div class="btn-group btn-group-sm">
${prompt.is_custom ? `
<button class="btn btn-outline-danger" onclick="resetPrompt('${key}')" title="Nulstil til standard">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil
</button>` : ''}
<button class="btn btn-outline-primary" onclick="editPrompt('${key}')" id="editBtn_${key}" title="Rediger Prompt">
<i class="bi bi-pencil"></i> Rediger
</button>
<button class="btn btn-outline-secondary" onclick="copyPrompt('${key}')" title="Kopier til udklipsholder">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4">
<small class="text-muted">Model:</small>
<div><code>${escapeHtml(prompt.model)}</code></div>
</div>
<div class="col-md-4">
<small class="text-muted">Endpoint:</small>
<div><code>${escapeHtml(prompt.endpoint)}</code></div>
</div>
<div class="col-md-4">
<small class="text-muted">Parametre:</small>
<div><code>${JSON.stringify(prompt.parameters)}</code></div>
</div>
</div>
<div>
<small class="text-muted fw-bold d-block mb-2">System Prompt:</small>
<pre id="prompt_${key}" class="border rounded p-3 bg-light" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap;">${escapeHtml(prompt.prompt)}</pre>
<div class="card-body p-0 position-relative">
<pre id="prompt_${key}" class="m-0 p-3 bg-dark text-light rounded-bottom"
style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap; border-radius: 0;">${escapeHtml(prompt.prompt)}</pre>
<textarea id="edit_prompt_${key}" class="form-control d-none p-3 bg-white text-dark rounded-bottom"
style="height: 300px; font-family: monospace; font-size: 0.85rem; border-radius: 0;">${escapeHtml(prompt.prompt)}</textarea>
<div id="editActions_${key}" class="position-absolute bottom-0 end-0 p-3 d-none">
<button class="btn btn-sm btn-secondary me-1" onclick="cancelEdit('${key}')">Annuller</button>
<button class="btn btn-sm btn-success" onclick="savePrompt('${key}')"><i class="bi bi-check-lg"></i> Gem</button>
</div>
</div>
</div>
`).join('');
</div>
</div>
</div>
`).join('')}
</div>
`;
container.innerHTML = accordionHtml;
} catch (error) {
console.error('Error loading AI prompts:', error);
@ -1023,6 +1074,79 @@ async function loadAIPrompts() {
}
}
function editPrompt(key) {
document.getElementById(`prompt_${key}`).classList.add('d-none');
document.getElementById(`edit_prompt_${key}`).classList.remove('d-none');
document.getElementById(`editActions_${key}`).classList.remove('d-none');
document.getElementById(`editBtn_${key}`).disabled = true;
}
function cancelEdit(key) {
document.getElementById(`prompt_${key}`).classList.remove('d-none');
document.getElementById(`edit_prompt_${key}`).classList.add('d-none');
document.getElementById(`editActions_${key}`).classList.add('d-none');
document.getElementById(`editBtn_${key}`).disabled = false;
// Reset value
document.getElementById(`edit_prompt_${key}`).value = document.getElementById(`prompt_${key}`).textContent;
}
async function savePrompt(key) {
const newText = document.getElementById(`edit_prompt_${key}`).value;
try {
const response = await fetch(`/api/v1/ai-prompts/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_text: newText })
});
if (!response.ok) throw new Error('Failed to update prompt');
// Reload to show update
await loadAIPrompts();
// Re-open accordion
setTimeout(() => {
const collapse = document.getElementById(`collapse_${key}`);
if (collapse) {
new bootstrap.Collapse(collapse, { toggle: false }).show();
}
}, 100);
} catch (error) {
console.error('Error saving prompt:', error);
alert('Kunne ikke gemme prompt');
}
}
async function resetPrompt(key) {
if (!confirm('Er du sikker på at du vil nulstille denne prompt til standard?')) return;
try {
const response = await fetch(`/api/v1/ai-prompts/${key}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to reset prompt');
// Reload to show update
await loadAIPrompts();
// Re-open accordion
setTimeout(() => {
const collapse = document.getElementById(`collapse_${key}`);
if (collapse) {
new bootstrap.Collapse(collapse, { toggle: false }).show();
}
}, 100);
} catch (error) {
console.error('Error resetting prompt:', error);
alert('Kunne ikke nulstille prompt');
}
}
function copyPrompt(key) {
const promptElement = document.getElementById(`prompt_${key}`);
const text = promptElement.textContent;

View File

@ -234,6 +234,7 @@
<li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li>
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>

View File

@ -631,7 +631,7 @@
let prepaidOptions = '';
let activePrepaidCards = [];
try {
const response = await fetch('/api/v1/prepaid/prepaid-cards?status=active&customer_id={{ ticket.customer_id }}');
const response = await fetch('/api/v1/prepaid-cards?status=active&customer_id={{ ticket.customer_id }}');
if (response.ok) {
const cards = await response.json();
activePrepaidCards = cards || [];

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,8 @@ from app.settings.backend import views as settings_views
from app.backups.backend.router import router as backups_api
from app.backups.frontend import views as backups_views
from app.backups.backend.scheduler import backup_scheduler
from app.conversations.backend import router as conversations_api
from app.conversations.frontend import views as conversations_views
# Configure logging
logging.basicConfig(
@ -127,6 +129,7 @@ app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
# Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"])
@ -141,9 +144,11 @@ app.include_router(tags_views.router, tags=["Frontend"])
app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(conversations_views.router, tags=["Frontend"])
# Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
app.mount("/docs", StaticFiles(directory="docs"), name="docs")
@app.get("/health")
async def health_check():

View File

@ -0,0 +1,9 @@
-- Create table for storing custom AI prompts
CREATE TABLE IF NOT EXISTS ai_prompts (
key VARCHAR(100) PRIMARY KEY,
prompt_text TEXT NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_by INTEGER REFERENCES users(user_id)
);
-- Note: We only store overrides here. If a key is missing, we use the hardcoded default.

View File

@ -0,0 +1,30 @@
-- Add Regex Extract and Link Action
-- Allows configurable regex extraction and database linking workflows
INSERT INTO email_workflow_actions (action_code, name, description, category, parameter_schema, example_config)
VALUES (
'regex_extract_and_link',
'Regex Ekstrahering & Linking',
'Søg efter mønstre (Regex) og link email til database matches',
'linking',
'{
"type": "object",
"properties": {
"regex_pattern": {"type": "string", "title": "Regex Pattern (med 1 gruppe)"},
"target_table": {"type": "string", "enum": ["customers", "vendors", "users"], "title": "Tabel"},
"target_column": {"type": "string", "title": "Søge Kolonne"},
"link_column": {"type": "string", "title": "Link Kolonne i Email", "default": "customer_id"},
"value_column": {"type": "string", "title": "Værdi Kolonne", "default": "id"},
"on_match": {"type": "string", "enum": ["update_email", "none"], "default": "update_email", "title": "Handling"}
},
"required": ["regex_pattern", "target_table", "target_column"]
}',
'{
"regex_pattern": "CVR-nr\\.?:?\\s*(\\d{8})",
"target_table": "customers",
"target_column": "cvr_number",
"link_column": "customer_id",
"value_column": "id",
"on_match": "update_email"
}'
) ON CONFLICT (action_code) DO NOTHING;

View File

@ -0,0 +1,38 @@
-- 068_conversations_module.sql
-- Table for storing transcribed conversations (calls, voice notes)
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES auth_users(id) ON DELETE SET NULL,
email_message_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
transcript TEXT, -- The full transcribed text
summary TEXT, -- AI generated summary (optional)
audio_file_path VARCHAR(500) NOT NULL,
duration_seconds INTEGER DEFAULT 0,
-- Privacy and Deletion
is_private BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMP, -- Soft delete
source VARCHAR(50) DEFAULT 'email', -- 'email', 'upload', 'phone_system'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for linkage
CREATE INDEX idx_conversations_customer ON conversations(customer_id);
CREATE INDEX idx_conversations_ticket ON conversations(ticket_id);
CREATE INDEX idx_conversations_user ON conversations(user_id);
-- Full Text Search Index for Danish
ALTER TABLE conversations ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('danish', coalesce(title, '') || ' ' || coalesce(transcript, ''))
) STORED;
CREATE INDEX idx_conversations_search ON conversations USING GIN(search_vector);

View File

@ -0,0 +1,5 @@
-- 069_conversation_category.sql
-- Add category column for conversation classification
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';

View File

@ -0,0 +1,4 @@
-- 072_add_category_to_conversations.sql
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';