2026-02-20 07:10:06 +01:00
|
|
|
"""
|
|
|
|
|
Case Analysis Service
|
|
|
|
|
AI-powered case text analysis using Ollama LLM
|
|
|
|
|
Extracts entities for QuickCreate feature
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
import json
|
|
|
|
|
import hashlib
|
|
|
|
|
from typing import Dict, Optional, List
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
import aiohttp
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
from app.core.config import settings
|
|
|
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
|
from app.models.schemas import QuickCreateAnalysis, SagPriority
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CaseAnalysisService:
|
|
|
|
|
"""AI-powered case text analysis for QuickCreate feature"""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.ollama_endpoint = settings.OLLAMA_ENDPOINT
|
|
|
|
|
self.ollama_model = settings.OLLAMA_MODEL
|
|
|
|
|
self.confidence_threshold = getattr(settings, 'QUICK_CREATE_CONFIDENCE_THRESHOLD', 0.6)
|
|
|
|
|
self.ai_timeout = getattr(settings, 'QUICK_CREATE_AI_TIMEOUT', 15) # Increased for larger models
|
|
|
|
|
self._cache = {} # Simple in-memory cache {text_hash: (result, timestamp)}
|
|
|
|
|
self._cache_ttl = 300 # 5 minutes
|
|
|
|
|
|
|
|
|
|
async def analyze_case_text(self, text: str, user_id: int) -> QuickCreateAnalysis:
|
|
|
|
|
"""
|
|
|
|
|
Analyze case text and extract structured data
|
|
|
|
|
Returns QuickCreateAnalysis with suggestions
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
if not text or len(text) < 5:
|
|
|
|
|
return self._empty_analysis(text)
|
|
|
|
|
|
|
|
|
|
# Check cache first
|
|
|
|
|
cached_result = self._get_cached_analysis(text)
|
|
|
|
|
if cached_result:
|
|
|
|
|
logger.info(f"✅ Using cached analysis for text hash")
|
|
|
|
|
return cached_result
|
|
|
|
|
|
|
|
|
|
# Build system prompt (Danish for accuracy)
|
|
|
|
|
system_prompt = self._build_analysis_prompt()
|
|
|
|
|
|
|
|
|
|
# Build user message
|
|
|
|
|
user_message = self._build_text_context(text)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Call Ollama with timeout
|
|
|
|
|
result = await self._call_ollama(system_prompt, user_message)
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
# Enrich with database matches
|
|
|
|
|
analysis = await self._enrich_with_db_matches(result, user_id)
|
|
|
|
|
|
|
|
|
|
# Apply business rules
|
|
|
|
|
analysis = await self._apply_business_rules(analysis, user_id)
|
|
|
|
|
|
|
|
|
|
# Cache the result
|
|
|
|
|
self._cache_analysis(text, analysis)
|
|
|
|
|
|
|
|
|
|
return analysis
|
|
|
|
|
else:
|
2026-03-18 10:29:45 +01:00
|
|
|
logger.warning("⚠️ Ollama returned no result, using heuristic fallback analysis")
|
|
|
|
|
return await self._heuristic_fallback_analysis(text)
|
2026-02-20 07:10:06 +01:00
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Case analysis failed: {e}", exc_info=True)
|
2026-03-18 10:29:45 +01:00
|
|
|
return await self._heuristic_fallback_analysis(text)
|
2026-02-20 07:10:06 +01:00
|
|
|
|
|
|
|
|
def _build_analysis_prompt(self) -> str:
|
|
|
|
|
"""Build Danish system prompt for case analysis"""
|
|
|
|
|
return """Du er en intelligent assistent der analyserer beskeder om IT-support sager og udtrækker struktureret information.
|
|
|
|
|
|
|
|
|
|
VIGTIGE REGLER:
|
|
|
|
|
1. Returner KUN gyldig JSON - ingen forklaring eller ekstra tekst
|
|
|
|
|
2. Hvis et felt ikke findes, sæt det til null eller tom liste
|
|
|
|
|
3. Beregn confidence baseret på hvor sikker du er (0.0-1.0) - SÆT IKKE 0.0 hvis du faktisk kan udtrække information!
|
|
|
|
|
4. Analyser tone og urgency for at bestemme prioritet
|
|
|
|
|
5. OPFIND IKKE ORD - brug kun ord fra den originale tekst
|
|
|
|
|
6. Ved "Ring til X fra Y" mønster: Y er oftest firma, X er kontaktperson
|
|
|
|
|
|
|
|
|
|
FELTER AT UDTRÆKKE:
|
|
|
|
|
|
|
|
|
|
**suggested_title**: Kort beskrivende titel (max 80 tegn) - BASERET PÅ ORIGINAL TEKST
|
|
|
|
|
**suggested_description**: Fuld beskrivelse (kan være hele teksten hvis den er kort)
|
|
|
|
|
**priority**: "low", "normal", "high", eller "urgent"
|
|
|
|
|
- "urgent": Kritiske ord som "nede", "virker ikke", "kritisk", "ASAP", "sur kunde", "omgående"
|
|
|
|
|
- "high": "hurtigt", "snart", "vigtigt", "prioriter", "Haster"
|
|
|
|
|
- "low": "når I får tid", "ikke hastende", "lavprioriteret"
|
|
|
|
|
- "normal": Standard hvis intet tyder på andet
|
|
|
|
|
|
|
|
|
|
**customer_hints**: Liste af hints om kunde (firmanavn, CVR, nummer, lokation) - f.eks. ["norva24", "Nets", "24"]
|
|
|
|
|
**technician_hints**: Liste af INTERNE BMC Hub medarbejdere der skal tildeles sagen (Christian, Peter, osv.)
|
|
|
|
|
- VIG: "Ring til Dennis fra norva24" betyder Dennis er KUNDE-kontaktperson, IKKE vores tekniker
|
|
|
|
|
- Kun sæt technician_hints hvis der står "Send til Christian" eller "Christian skal ordne det"
|
|
|
|
|
- Lad være tom liste hvis der ikke nævnes en INTERN medarbejder
|
|
|
|
|
**tags**: Relevante nøgleord (produkt, lokation, type problem) - brug kun fra original tekst
|
|
|
|
|
**hardware_keywords**: Hardware-relaterede termer (modeller, serial numbers)
|
|
|
|
|
**confidence**: 0.5-0.9 hvis du kan udtrække nogen information, 0.0-0.4 kun hvis teksten er uforståelig
|
|
|
|
|
|
|
|
|
|
EKSEMPLER:
|
|
|
|
|
|
|
|
|
|
Input: "Ring til Dennis fra norva24. Haster"
|
|
|
|
|
Output:
|
|
|
|
|
{
|
|
|
|
|
"suggested_title": "Ring til Dennis fra norva24",
|
|
|
|
|
"suggested_description": "Ring til Dennis fra norva24. Haster",
|
|
|
|
|
"priority": "high",
|
|
|
|
|
"customer_hints": ["norva24"],
|
|
|
|
|
"technician_hints": [],
|
|
|
|
|
"tags": ["telefonbesked", "opfølgning"],
|
|
|
|
|
"hardware_keywords": [],
|
|
|
|
|
"confidence": 0.75
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Input: "Kunde 24 ringer. Printer i Hellerup virker ikke. Skal fixes hurtigt."
|
|
|
|
|
Output:
|
|
|
|
|
{
|
|
|
|
|
"suggested_title": "Printer i Hellerup virker ikke",
|
|
|
|
|
"suggested_description": "Kunde 24 ringer. Printer i Hellerup virker ikke. Skal fixes hurtigt.",
|
|
|
|
|
"priority": "high",
|
|
|
|
|
"customer_hints": ["24", "Hellerup"],
|
|
|
|
|
"technician_hints": [],
|
|
|
|
|
"tags": ["printer", "hellerup", "hardware"],
|
|
|
|
|
"hardware_keywords": ["printer"],
|
|
|
|
|
"confidence": 0.85
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Input: "Ny medarbejder starter 1. marts hos Nets. Skal have laptop og adgang til systemer."
|
|
|
|
|
Output:
|
|
|
|
|
{
|
|
|
|
|
"suggested_title": "Ny medarbejder hos Nets starter 1. marts",
|
|
|
|
|
"suggested_description": "Ny medarbejder starter 1. marts hos Nets. Skal have laptop og adgang til systemer.",
|
|
|
|
|
"priority": "normal",
|
|
|
|
|
"customer_hints": ["Nets"],
|
|
|
|
|
"technician_hints": [],
|
|
|
|
|
"tags": ["onboarding", "ny medarbejder", "laptop"],
|
|
|
|
|
"hardware_keywords": ["laptop"],
|
|
|
|
|
"confidence": 0.90
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Input: "Lenovo T14 serial ABC123 kører langsomt. Når I får tid."
|
|
|
|
|
Output:
|
|
|
|
|
{
|
|
|
|
|
"suggested_title": "Lenovo T14 kører langsomt",
|
|
|
|
|
"suggested_description": "Lenovo T14 serial ABC123 kører langsomt. Når I får tid.",
|
|
|
|
|
"priority": "low",
|
|
|
|
|
"customer_hints": [],
|
|
|
|
|
"technician_hints": [],
|
|
|
|
|
"tags": ["performance", "lenovo"],
|
|
|
|
|
"hardware_keywords": ["Lenovo T14", "ABC123"],
|
|
|
|
|
"confidence": 0.75
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Input: "Norva24 ringer - server er nede! Send Christian ud ASAP."
|
|
|
|
|
Output:
|
|
|
|
|
{
|
|
|
|
|
"suggested_title": "Norva24 server er nede",
|
|
|
|
|
"suggested_description": "Norva24 ringer - server er nede! Send Christian ud ASAP.",
|
|
|
|
|
"priority": "urgent",
|
|
|
|
|
"customer_hints": ["Norva24"],
|
|
|
|
|
"technician_hints": ["Christian"],
|
|
|
|
|
"tags": ["server", "onsite", "kritisk"],
|
|
|
|
|
"hardware_keywords": ["server"],
|
|
|
|
|
"confidence": 0.90
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Husk: Returner KUN JSON, ingen forklaring eller tekst udenfor JSON-objektet."""
|
|
|
|
|
|
|
|
|
|
def _build_text_context(self, text: str) -> str:
|
|
|
|
|
"""Build context for AI analysis"""
|
|
|
|
|
# Truncate if too long (avoid token limits)
|
|
|
|
|
if len(text) > 2000:
|
|
|
|
|
text = text[:2000] + "..."
|
|
|
|
|
|
|
|
|
|
return f"""Analyser denne sags-tekst og udtrække struktureret information:
|
|
|
|
|
|
|
|
|
|
{text}
|
|
|
|
|
|
|
|
|
|
Returner JSON med suggested_title, suggested_description, priority, customer_hints, technician_hints, tags, hardware_keywords, og confidence."""
|
|
|
|
|
|
|
|
|
|
async def _call_ollama(self, system_prompt: str, user_message: str) -> Optional[Dict]:
|
|
|
|
|
"""Call Ollama API for analysis"""
|
|
|
|
|
|
|
|
|
|
url = f"{self.ollama_endpoint}/api/chat"
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
"model": self.ollama_model,
|
|
|
|
|
"messages": [
|
|
|
|
|
{"role": "system", "content": system_prompt},
|
|
|
|
|
{"role": "user", "content": user_message}
|
|
|
|
|
],
|
|
|
|
|
"stream": False,
|
|
|
|
|
"options": {
|
|
|
|
|
"temperature": 0.2, # Low for consistent extraction
|
|
|
|
|
"num_predict": 800 # Enough for detailed response
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
start_time = datetime.now()
|
|
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
timeout = aiohttp.ClientTimeout(total=self.ai_timeout)
|
|
|
|
|
async with session.post(url, json=payload, timeout=timeout) as response:
|
|
|
|
|
if response.status != 200:
|
|
|
|
|
error_text = await response.text()
|
|
|
|
|
logger.error(f"❌ Ollama API error: {response.status} - {error_text}")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
data = await response.json()
|
|
|
|
|
message_data = data.get('message', {})
|
|
|
|
|
content = message_data.get('content', '') or message_data.get('thinking', '')
|
|
|
|
|
|
|
|
|
|
processing_time = (datetime.now() - start_time).total_seconds() * 1000
|
|
|
|
|
|
|
|
|
|
if not content:
|
|
|
|
|
logger.error(f"❌ Ollama returned empty response")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Parse JSON response
|
|
|
|
|
logger.info(f"🔍 Raw Ollama response: {content[:500]}")
|
|
|
|
|
result = self._parse_ollama_response(content)
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
result['processing_time_ms'] = int(processing_time)
|
|
|
|
|
logger.info(f"✅ AI analysis: priority={result.get('priority', 'unknown')} (confidence: {result.get('confidence', 0):.2f}, {processing_time:.0f}ms)")
|
|
|
|
|
logger.info(f"🔍 Parsed result: {json.dumps(result, ensure_ascii=False)[:500]}")
|
|
|
|
|
return result
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(f"⚠️ Failed to parse Ollama response: {content[:200]}")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
except aiohttp.ClientError as e:
|
|
|
|
|
logger.warning(f"⚠️ Ollama connection error: {e}")
|
|
|
|
|
return None
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Unexpected error calling Ollama: {e}", exc_info=True)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def _parse_ollama_response(self, content: str) -> Optional[Dict]:
|
|
|
|
|
"""Parse Ollama JSON response"""
|
|
|
|
|
try:
|
|
|
|
|
# Try to extract JSON if wrapped in markdown
|
|
|
|
|
if '```json' in content:
|
|
|
|
|
json_match = re.search(r'```json\s*(\{.*?\})\s*```', content, re.DOTALL)
|
|
|
|
|
if json_match:
|
|
|
|
|
content = json_match.group(1)
|
|
|
|
|
elif '```' in content:
|
|
|
|
|
json_match = re.search(r'```\s*(\{.*?\})\s*```', content, re.DOTALL)
|
|
|
|
|
if json_match:
|
|
|
|
|
content = json_match.group(1)
|
|
|
|
|
|
|
|
|
|
# Try to find JSON object
|
|
|
|
|
json_match = re.search(r'\{.*\}', content, re.DOTALL)
|
|
|
|
|
if json_match:
|
|
|
|
|
content = json_match.group(0)
|
|
|
|
|
|
|
|
|
|
result = json.loads(content)
|
|
|
|
|
return result
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
logger.error(f"❌ JSON parse error: {e} - Content: {content[:500]}")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def _enrich_with_db_matches(self, ai_result: Dict, user_id: int) -> QuickCreateAnalysis:
|
|
|
|
|
"""Enrich AI results with database matches"""
|
|
|
|
|
|
|
|
|
|
# Extract base fields
|
|
|
|
|
title = ai_result.get('suggested_title', '')
|
|
|
|
|
description = ai_result.get('suggested_description', '')
|
|
|
|
|
priority_str = ai_result.get('priority', 'normal')
|
|
|
|
|
confidence = float(ai_result.get('confidence', 0.0))
|
|
|
|
|
|
|
|
|
|
# Map priority to enum
|
|
|
|
|
try:
|
|
|
|
|
priority = SagPriority(priority_str.lower())
|
|
|
|
|
except ValueError:
|
|
|
|
|
priority = SagPriority.NORMAL
|
|
|
|
|
|
|
|
|
|
# Match customer
|
|
|
|
|
customer_hints = ai_result.get('customer_hints', [])
|
|
|
|
|
customer_id, customer_name = await self._match_customer(customer_hints)
|
|
|
|
|
|
|
|
|
|
# Match technician
|
|
|
|
|
technician_hints = ai_result.get('technician_hints', [])
|
|
|
|
|
technician_id, technician_name = await self._match_technician(technician_hints)
|
|
|
|
|
|
|
|
|
|
# Match group (can be enhanced with rules later)
|
|
|
|
|
group_id, group_name = None, None
|
|
|
|
|
|
|
|
|
|
# Extract tags
|
|
|
|
|
tags = ai_result.get('tags', [])
|
|
|
|
|
|
|
|
|
|
# Match hardware
|
|
|
|
|
hardware_keywords = ai_result.get('hardware_keywords', [])
|
|
|
|
|
hardware_refs = await self._match_hardware(hardware_keywords)
|
|
|
|
|
|
|
|
|
|
# Build AI reasoning for low confidence
|
|
|
|
|
ai_reasoning = None
|
|
|
|
|
if confidence < self.confidence_threshold:
|
|
|
|
|
ai_reasoning = f"Lav confidence: {confidence:.2f}. Tjek alle felter grundigt."
|
|
|
|
|
|
|
|
|
|
return QuickCreateAnalysis(
|
|
|
|
|
suggested_title=title,
|
|
|
|
|
suggested_description=description,
|
|
|
|
|
suggested_priority=priority,
|
|
|
|
|
suggested_customer_id=customer_id,
|
|
|
|
|
suggested_customer_name=customer_name,
|
|
|
|
|
suggested_technician_id=technician_id,
|
|
|
|
|
suggested_technician_name=technician_name,
|
|
|
|
|
suggested_group_id=group_id,
|
|
|
|
|
suggested_group_name=group_name,
|
|
|
|
|
suggested_tags=tags,
|
|
|
|
|
hardware_references=hardware_refs,
|
|
|
|
|
confidence=confidence,
|
|
|
|
|
ai_reasoning=ai_reasoning
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _match_customer(self, hints: List[str]) -> tuple[Optional[int], Optional[str]]:
|
|
|
|
|
"""Match customer from hints"""
|
|
|
|
|
if not hints:
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
for hint in hints:
|
|
|
|
|
hint_clean = str(hint).strip()
|
|
|
|
|
if not hint_clean:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Try exact ID match first
|
|
|
|
|
if hint_clean.isdigit():
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
"SELECT id, name FROM customers WHERE id = %s AND deleted_at IS NULL AND is_active = true",
|
|
|
|
|
(int(hint_clean),)
|
|
|
|
|
)
|
|
|
|
|
if row:
|
|
|
|
|
return row['id'], row['name']
|
|
|
|
|
|
|
|
|
|
# Try name or CVR match with intelligent sorting
|
|
|
|
|
# Priority: 1) Exact match, 2) Starts with hint, 3) Contains hint
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
"""SELECT id, name FROM customers
|
|
|
|
|
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
|
|
|
|
|
AND deleted_at IS NULL AND is_active = true
|
|
|
|
|
ORDER BY
|
|
|
|
|
CASE WHEN LOWER(name) = LOWER(%s) THEN 1
|
|
|
|
|
WHEN LOWER(name) LIKE LOWER(%s) THEN 2
|
|
|
|
|
ELSE 3 END,
|
|
|
|
|
name ASC
|
|
|
|
|
LIMIT 1""",
|
|
|
|
|
(f"%{hint_clean}%", f"%{hint_clean}%", hint_clean, f"{hint_clean}%")
|
|
|
|
|
)
|
|
|
|
|
if row:
|
|
|
|
|
logger.info(f"✅ Matched customer: {row['name']} (id={row['id']}) from hint '{hint_clean}'")
|
|
|
|
|
return row['id'], row['name']
|
|
|
|
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
async def _match_technician(self, hints: List[str]) -> tuple[Optional[int], Optional[str]]:
|
|
|
|
|
"""Match technician from hints (BMC Hub employees only)"""
|
|
|
|
|
if not hints:
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
for hint in hints:
|
|
|
|
|
hint_clean = str(hint).strip()
|
|
|
|
|
if not hint_clean:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Match by full_name or username with intelligent sorting
|
|
|
|
|
# Priority: 1) Exact match, 2) Starts with hint, 3) Contains hint
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
"""SELECT user_id as id, full_name, username FROM users
|
|
|
|
|
WHERE (full_name ILIKE %s OR username ILIKE %s)
|
|
|
|
|
AND is_active = true
|
|
|
|
|
ORDER BY
|
|
|
|
|
CASE WHEN LOWER(full_name) = LOWER(%s) THEN 1
|
|
|
|
|
WHEN LOWER(full_name) LIKE LOWER(%s) THEN 2
|
|
|
|
|
WHEN LOWER(username) = LOWER(%s) THEN 1
|
|
|
|
|
WHEN LOWER(username) LIKE LOWER(%s) THEN 2
|
|
|
|
|
ELSE 3 END,
|
|
|
|
|
full_name ASC
|
|
|
|
|
LIMIT 1""",
|
|
|
|
|
(f"%{hint_clean}%", f"%{hint_clean}%", hint_clean, f"{hint_clean}%", hint_clean, f"{hint_clean}%")
|
|
|
|
|
)
|
|
|
|
|
if row:
|
|
|
|
|
logger.info(f"✅ Matched technician: {row['full_name']} (user_id={row['id']}) from hint '{hint_clean}'")
|
|
|
|
|
return row['id'], row['full_name'] or row['username']
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(f"⚠️ No BMC Hub employee found matching '{hint_clean}' - may be external contact")
|
|
|
|
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
async def _match_hardware(self, keywords: List[str]) -> List[Dict]:
|
|
|
|
|
"""Match hardware from keywords"""
|
|
|
|
|
hardware_refs = []
|
|
|
|
|
|
|
|
|
|
for keyword in keywords:
|
|
|
|
|
keyword_clean = str(keyword).strip()
|
|
|
|
|
if not keyword_clean or len(keyword_clean) < 3:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Search hardware assets
|
|
|
|
|
rows = execute_query(
|
|
|
|
|
"""SELECT id, brand, model, serial_number
|
|
|
|
|
FROM hardware_assets
|
|
|
|
|
WHERE (brand ILIKE %s OR model ILIKE %s OR serial_number ILIKE %s)
|
|
|
|
|
AND deleted_at IS NULL
|
|
|
|
|
LIMIT 3""",
|
|
|
|
|
(f"%{keyword_clean}%", f"%{keyword_clean}%", f"%{keyword_clean}%")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
hardware_refs.append({
|
|
|
|
|
'id': row['id'],
|
|
|
|
|
'brand': row['brand'],
|
|
|
|
|
'model': row['model'],
|
|
|
|
|
'serial_number': row['serial_number']
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return hardware_refs
|
|
|
|
|
|
|
|
|
|
async def _apply_business_rules(self, analysis: QuickCreateAnalysis, user_id: int) -> QuickCreateAnalysis:
|
|
|
|
|
"""Apply business rules to enhance analysis (Phase 2 feature)"""
|
|
|
|
|
|
|
|
|
|
# Rule 1: Check customer history for technician preference
|
|
|
|
|
if analysis.suggested_customer_id and not analysis.suggested_technician_id:
|
|
|
|
|
# Find most frequent technician for this customer
|
|
|
|
|
row = execute_query_single(
|
|
|
|
|
"""SELECT ansvarlig_bruger_id, COUNT(*) as cnt
|
|
|
|
|
FROM sag_sager
|
|
|
|
|
WHERE customer_id = %s AND ansvarlig_bruger_id IS NOT NULL
|
|
|
|
|
GROUP BY ansvarlig_bruger_id
|
|
|
|
|
ORDER BY cnt DESC LIMIT 1""",
|
|
|
|
|
(analysis.suggested_customer_id,)
|
|
|
|
|
)
|
|
|
|
|
if row:
|
|
|
|
|
user_row = execute_query_single(
|
|
|
|
|
"SELECT user_id as id, full_name FROM users WHERE user_id = %s AND is_active = true",
|
|
|
|
|
(row['ansvarlig_bruger_id'],)
|
|
|
|
|
)
|
|
|
|
|
if user_row:
|
|
|
|
|
analysis.suggested_technician_id = user_row['id']
|
|
|
|
|
analysis.suggested_technician_name = user_row['full_name']
|
|
|
|
|
logger.info(f"✨ Business rule: Assigned technician {user_row['full_name']} based on history")
|
|
|
|
|
|
|
|
|
|
# Rule 2: Check for VIP customers (future: alert_notes integration)
|
|
|
|
|
# TODO: Add alert_notes check for popup rules
|
|
|
|
|
|
|
|
|
|
# Rule 3: Onboarding detection
|
|
|
|
|
onboarding_keywords = ['onboarding', 'ny medarbejder', 'nyt bruger', 'starter den']
|
|
|
|
|
if any(keyword in analysis.suggested_description.lower() for keyword in onboarding_keywords):
|
|
|
|
|
if 'onboarding' not in analysis.suggested_tags:
|
|
|
|
|
analysis.suggested_tags.append('onboarding')
|
|
|
|
|
logger.info(f"✨ Business rule: Detected onboarding case")
|
|
|
|
|
|
|
|
|
|
return analysis
|
|
|
|
|
|
|
|
|
|
def _empty_analysis(self, text: str) -> QuickCreateAnalysis:
|
|
|
|
|
"""Return empty analysis with original text"""
|
|
|
|
|
return QuickCreateAnalysis(
|
|
|
|
|
suggested_title="",
|
|
|
|
|
suggested_description=text,
|
|
|
|
|
suggested_priority=SagPriority.NORMAL,
|
|
|
|
|
confidence=0.0,
|
|
|
|
|
ai_reasoning="AI unavailable - fill fields manually"
|
|
|
|
|
)
|
2026-03-18 10:29:45 +01:00
|
|
|
|
|
|
|
|
async def _heuristic_fallback_analysis(self, text: str) -> QuickCreateAnalysis:
|
|
|
|
|
"""Local fallback when AI service is unavailable."""
|
|
|
|
|
cleaned_text = (text or "").strip()
|
|
|
|
|
if not cleaned_text:
|
|
|
|
|
return self._empty_analysis(text)
|
|
|
|
|
|
|
|
|
|
lowered = cleaned_text.lower()
|
|
|
|
|
|
|
|
|
|
# Priority heuristics based on urgency wording.
|
|
|
|
|
urgent_terms = ["nede", "kritisk", "asap", "omgående", "straks", "akut", "haster"]
|
|
|
|
|
high_terms = ["hurtigt", "vigtigt", "snarest", "prioriter"]
|
|
|
|
|
low_terms = ["når i får tid", "ikke hastende", "lavprioriteret"]
|
|
|
|
|
|
|
|
|
|
if any(term in lowered for term in urgent_terms):
|
|
|
|
|
priority = SagPriority.URGENT
|
|
|
|
|
elif any(term in lowered for term in high_terms):
|
|
|
|
|
priority = SagPriority.HIGH
|
|
|
|
|
elif any(term in lowered for term in low_terms):
|
|
|
|
|
priority = SagPriority.LOW
|
|
|
|
|
else:
|
|
|
|
|
priority = SagPriority.NORMAL
|
|
|
|
|
|
|
|
|
|
# Basic title heuristic: first non-empty line/sentence, clipped to 80 chars.
|
|
|
|
|
first_line = cleaned_text.splitlines()[0].strip()
|
|
|
|
|
first_sentence = re.split(r"[.!?]", first_line)[0].strip()
|
|
|
|
|
title_source = first_sentence or first_line or cleaned_text
|
|
|
|
|
title = title_source[:80].strip()
|
|
|
|
|
if not title:
|
|
|
|
|
title = "Ny sag"
|
|
|
|
|
|
|
|
|
|
# Lightweight keyword tags.
|
|
|
|
|
keyword_tags = {
|
|
|
|
|
"printer": "printer",
|
|
|
|
|
"mail": "mail",
|
|
|
|
|
"email": "mail",
|
|
|
|
|
"vpn": "vpn",
|
|
|
|
|
"net": "netværk",
|
|
|
|
|
"wifi": "wifi",
|
|
|
|
|
"server": "server",
|
|
|
|
|
"laptop": "laptop",
|
|
|
|
|
"adgang": "adgang",
|
|
|
|
|
"onboarding": "onboarding",
|
|
|
|
|
}
|
|
|
|
|
suggested_tags: List[str] = []
|
|
|
|
|
for key, tag in keyword_tags.items():
|
|
|
|
|
if key in lowered and tag not in suggested_tags:
|
|
|
|
|
suggested_tags.append(tag)
|
|
|
|
|
|
|
|
|
|
# Try simple customer matching from long words in text.
|
|
|
|
|
candidate_hints = []
|
|
|
|
|
for token in re.findall(r"[A-Za-z0-9ÆØÅæøå._-]{3,}", cleaned_text):
|
|
|
|
|
if token.lower() in {"ring", "kunde", "sag", "skal", "have", "virker", "ikke"}:
|
|
|
|
|
continue
|
|
|
|
|
candidate_hints.append(token)
|
|
|
|
|
customer_id, customer_name = await self._match_customer(candidate_hints[:8])
|
|
|
|
|
|
|
|
|
|
return QuickCreateAnalysis(
|
|
|
|
|
suggested_title=title,
|
|
|
|
|
suggested_description=cleaned_text,
|
|
|
|
|
suggested_priority=priority,
|
|
|
|
|
suggested_customer_id=customer_id,
|
|
|
|
|
suggested_customer_name=customer_name,
|
|
|
|
|
suggested_tags=suggested_tags,
|
|
|
|
|
confidence=0.35,
|
|
|
|
|
ai_reasoning="AI service unavailable - using local fallback suggestions"
|
|
|
|
|
)
|
2026-02-20 07:10:06 +01:00
|
|
|
|
|
|
|
|
def _get_cached_analysis(self, text: str) -> Optional[QuickCreateAnalysis]:
|
|
|
|
|
"""Get cached analysis if available and not expired"""
|
|
|
|
|
text_hash = hashlib.md5(text.encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
if text_hash in self._cache:
|
|
|
|
|
result, timestamp = self._cache[text_hash]
|
|
|
|
|
if (datetime.now() - timestamp).total_seconds() < self._cache_ttl:
|
|
|
|
|
return result
|
|
|
|
|
else:
|
|
|
|
|
# Expired
|
|
|
|
|
del self._cache[text_hash]
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def _cache_analysis(self, text: str, analysis: QuickCreateAnalysis):
|
|
|
|
|
"""Cache analysis result"""
|
|
|
|
|
text_hash = hashlib.md5(text.encode()).hexdigest()
|
|
|
|
|
self._cache[text_hash] = (analysis, datetime.now())
|
|
|
|
|
|
|
|
|
|
# Clean old cache entries (simple LRU)
|
|
|
|
|
if len(self._cache) > 100:
|
|
|
|
|
# Remove oldest 20 entries
|
|
|
|
|
sorted_cache = sorted(self._cache.items(), key=lambda x: x[1][1])
|
|
|
|
|
for key, _ in sorted_cache[:20]:
|
|
|
|
|
del self._cache[key]
|