- Created tables for AI benchmark runs and results to facilitate model evaluation. - Added expected answers column to benchmark results. - Introduced tables for internet connection change cases and vTiger archive management, including records, relations, and checkpoints. - Implemented triggers to enforce append-only behavior for vTiger archive records and files. - Enhanced solution management with soft delete capabilities for sag_solutions and knowledge_articles. feat(scripts): add CRM benchmarking script for Ollama models - Developed a Python script to benchmark CRM models exposed through Ollama, including various test cases and scoring mechanisms. test(tests): add comprehensive tests for new features - Implemented tests for internet change case service, vTiger archive functionality, and sag solution knowledge management. - Ensured coverage for edge cases and error handling in the new features.
501 lines
28 KiB
Python
501 lines
28 KiB
Python
import json
|
|
import logging
|
|
import re
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
|
|
from app.core.auth_dependencies import get_current_user, require_any_permission
|
|
from app.core.database import execute_query, execute_query_single
|
|
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
|
|
from app.services.case_analysis_service import CaseAnalysisService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
|
|
VISIBILITIES = {"internal", "general", "customer"}
|
|
APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"}
|
|
RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"}
|
|
TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"}
|
|
|
|
SECRET_PATTERNS = (
|
|
(re.compile(r"(?i)\b(password|passwd|kodeord|api[_ -]?key|secret|token)\s*[:=]\s*([^\s,;]+)"), r"\1: [FJERNET]"),
|
|
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+\-/]+=*"), "Bearer [FJERNET]"),
|
|
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[PRIVAT NØGLE FJERNET]"),
|
|
(re.compile(r"(?i)(?:postgres|mysql|mongodb(?:\+srv)?)://[^\s]+"), "[DATABASEFORBINDELSE FJERNET]"),
|
|
)
|
|
|
|
|
|
def _user_id(current_user: dict) -> Optional[int]:
|
|
value = current_user.get("id") or current_user.get("user_id")
|
|
return int(value) if value is not None else None
|
|
|
|
|
|
def _clean_list(values) -> list[str]:
|
|
cleaned, seen = [], set()
|
|
for value in values or []:
|
|
item = str(value or "").strip()
|
|
key = item.casefold()
|
|
if item and key not in seen:
|
|
seen.add(key)
|
|
cleaned.append(item[:100])
|
|
return cleaned[:30]
|
|
|
|
|
|
def _normalize_payload(data: dict) -> dict:
|
|
if "title" in data:
|
|
data["title"] = str(data.get("title") or "").strip()
|
|
if not data["title"]:
|
|
raise HTTPException(status_code=422, detail="Løsningen skal have en titel")
|
|
if "visibility" in data:
|
|
data["visibility"] = str(data.get("visibility") or "internal").lower()
|
|
if data["visibility"] not in VISIBILITIES:
|
|
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
|
|
if "approval_status" in data:
|
|
data["approval_status"] = str(data.get("approval_status") or "draft").lower()
|
|
if data["approval_status"] not in APPROVAL_STATUSES:
|
|
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
|
|
if data.get("result") is not None:
|
|
raw = str(data["result"]).strip()
|
|
data["result"] = RESULT_ALIASES.get(raw.casefold(), raw)
|
|
if data.get("solution_type") is not None:
|
|
raw = str(data["solution_type"]).strip()
|
|
data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw)
|
|
for field in ("tags", "products"):
|
|
if field in data:
|
|
data[field] = _clean_list(data[field])
|
|
return data
|
|
|
|
|
|
def _redact_sensitive(text: str) -> tuple[str, list[str]]:
|
|
cleaned = str(text or "")
|
|
warnings = []
|
|
for pattern, replacement in SECRET_PATTERNS:
|
|
cleaned, count = pattern.subn(replacement, cleaned)
|
|
if count:
|
|
warnings.append(f"{count} mulig(e) hemmelighed(er) blev fjernet før AI-behandling")
|
|
return cleaned, warnings
|
|
|
|
|
|
def _knowledge_tokens(text: str) -> list[str]:
|
|
ignored = {"eller", "ikke", "med", "den", "det", "der", "som", "for", "fra", "til", "har", "kan", "skal", "sag", "sagen", "test", "viden"}
|
|
tokens, seen = [], set()
|
|
for token in re.findall(r"[A-Za-zÀ-ÿ0-9_.-]{3,}", str(text or "").lower()):
|
|
if token in ignored or token in seen:
|
|
continue
|
|
seen.add(token)
|
|
tokens.append(token)
|
|
return tokens[:24]
|
|
|
|
|
|
def _normalize_source_refs(raw_refs, allowed_refs: set[str], sag_id: int) -> list[str]:
|
|
if isinstance(raw_refs, str):
|
|
raw_refs = re.split(r"[,;\n]+", raw_refs)
|
|
normalized = [f"Sag {sag_id}"]
|
|
for raw in raw_refs or []:
|
|
value = str(raw or "").strip().strip("[]")
|
|
match = re.search(r"(?i)\b(sag|kommentar|mail|tid|artikel)\s*#?\s*(\d+)\b", value)
|
|
if not match:
|
|
continue
|
|
kind = match.group(1).capitalize()
|
|
canonical = f"{kind} {int(match.group(2))}"
|
|
if canonical in allowed_refs and canonical not in normalized:
|
|
normalized.append(canonical)
|
|
return normalized
|
|
|
|
|
|
def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None:
|
|
version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1}
|
|
snapshot = dict(solution)
|
|
for key, value in list(snapshot.items()):
|
|
if hasattr(value, "isoformat"):
|
|
snapshot[key] = value.isoformat()
|
|
execute_query(
|
|
"INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)",
|
|
(solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note),
|
|
)
|
|
|
|
|
|
def _case_knowledge_context(sag_id: int, limit: int = 6) -> tuple[dict, list[dict]]:
|
|
case = execute_query_single(
|
|
"""SELECT s.id,s.titel,s.beskrivelse,s.status,
|
|
(SELECT sk.customer_id FROM sag_kunder sk WHERE sk.sag_id=s.id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1) AS customer_id,
|
|
COALESCE((SELECT string_agg(t.name,' ') FROM entity_tags et JOIN tags t ON t.id=et.tag_id WHERE et.entity_type='case' AND et.entity_id=s.id),'') AS tag_text
|
|
FROM sag_sager s WHERE s.id=%s AND s.deleted_at IS NULL""",
|
|
(sag_id,),
|
|
)
|
|
if not case:
|
|
raise HTTPException(status_code=404, detail="Sagen findes ikke")
|
|
tokens = _knowledge_tokens(" ".join([str(case.get("titel") or ""), str(case.get("beskrivelse") or ""), str(case.get("tag_text") or "")]))
|
|
if not tokens:
|
|
return case, []
|
|
search_query = " OR ".join(tokens)
|
|
articles = execute_query(
|
|
"""SELECT ka.id,ka.title,ka.summary,ka.problem,ka.root_cause,ka.solution,ka.workaround,
|
|
ka.visibility,ka.customer_id,ka.tags,ka.products,ka.sag_id,ka.updated_at,
|
|
ts_rank_cd(ka.search_document,websearch_to_tsquery('simple',%s)) AS relevance
|
|
FROM knowledge_articles ka
|
|
WHERE ka.status='published' AND ka.archived_at IS NULL
|
|
AND (ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))
|
|
AND ka.search_document @@ websearch_to_tsquery('simple',%s)
|
|
ORDER BY relevance DESC,ka.updated_at DESC LIMIT %s""",
|
|
(search_query, case.get("customer_id"), search_query, limit),
|
|
) or []
|
|
if articles:
|
|
top_relevance = float(articles[0].get("relevance") or 0)
|
|
relative_floor = max(0.05, top_relevance * 0.35)
|
|
articles = [row for row in articles if float(row.get("relevance") or 0) >= relative_floor]
|
|
return case, articles
|
|
|
|
|
|
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
|
|
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
|
|
result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
|
|
return result[0] if result else None
|
|
|
|
|
|
@router.get("/sag/{sag_id}/solution/versions")
|
|
async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)):
|
|
solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
|
|
if not solution:
|
|
return {"items": [], "total": 0}
|
|
items = execute_query(
|
|
"""SELECT v.id, v.version_number, v.change_note, v.created_at,
|
|
COALESCE(u.full_name, u.username) AS changed_by
|
|
FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id
|
|
WHERE v.solution_id=%s ORDER BY v.version_number DESC""",
|
|
(solution["id"],),
|
|
) or []
|
|
return {"items": items, "total": len(items)}
|
|
|
|
|
|
@router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
|
|
async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)):
|
|
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
|
|
raise HTTPException(status_code=404, detail="Sagen findes ikke")
|
|
existing = execute_query_single("SELECT id, deleted_at FROM sag_solutions WHERE sag_id=%s", (sag_id,))
|
|
if existing:
|
|
detail = "Sagen har en arkiveret løsning, som skal gendannes" if existing.get("deleted_at") else "Der findes allerede en løsning på sagen"
|
|
raise HTTPException(status_code=409, detail=detail)
|
|
data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"}))
|
|
user_id = _user_id(current_user)
|
|
result = execute_query(
|
|
"""INSERT INTO sag_solutions (
|
|
sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround,
|
|
visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id
|
|
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""",
|
|
(sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id),
|
|
)
|
|
created = result[0]
|
|
_version_solution(created, user_id, "Løsning oprettet")
|
|
return created
|
|
|
|
|
|
@router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
|
|
async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)):
|
|
if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,)):
|
|
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
|
|
data = _normalize_payload(updates.model_dump(exclude_unset=True))
|
|
change_note = data.pop("change_note", None)
|
|
allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"}
|
|
fields, params = [], []
|
|
for key, value in data.items():
|
|
if key not in allowed:
|
|
continue
|
|
fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else ""))
|
|
params.append(json.dumps(value) if key in {"tags", "products"} else value)
|
|
if not fields:
|
|
raise HTTPException(status_code=400, detail="Ingen ændringer at gemme")
|
|
user_id = _user_id(current_user)
|
|
fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"])
|
|
params.extend([user_id, sag_id])
|
|
updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0]
|
|
_version_solution(updated, user_id, change_note or "Løsning redigeret")
|
|
return updated
|
|
|
|
|
|
@router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)])
|
|
async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)):
|
|
action = str((await request.json()).get("action") or "").lower()
|
|
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
|
|
if not solution:
|
|
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
|
|
user_id = _user_id(current_user)
|
|
if action == "submit":
|
|
status = "pending"
|
|
elif action == "approve":
|
|
if not str(solution.get("description") or "").strip():
|
|
raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse")
|
|
status = "approved"
|
|
elif action in {"reject", "outdate"}:
|
|
status = "rejected" if action == "reject" else "outdated"
|
|
else:
|
|
raise HTTPException(status_code=422, detail="Ukendt handling")
|
|
if status == "approved":
|
|
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id))
|
|
else:
|
|
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id))
|
|
_version_solution(updated, user_id, f"Status ændret til {status}")
|
|
return updated
|
|
|
|
|
|
@router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)])
|
|
async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)):
|
|
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
|
|
if not solution:
|
|
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
|
|
if solution.get("approval_status") != "approved":
|
|
raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse")
|
|
publish_text = "\n".join(str(solution.get(field) or "") for field in ("title", "problem", "root_cause", "investigation", "description", "workaround"))
|
|
_clean_publish_text, secret_warnings = _redact_sensitive(publish_text)
|
|
if secret_warnings:
|
|
raise HTTPException(status_code=422, detail="Løsningen indeholder muligvis password, token eller anden hemmelighed. Fjern det før udgivelse.")
|
|
customer_id = None
|
|
if solution.get("visibility") == "customer":
|
|
customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,))
|
|
if not customer:
|
|
raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen")
|
|
customer_id = customer["customer_id"]
|
|
description = str(solution.get("description") or "").strip()
|
|
summary = description[:300] + ("…" if len(description) > 300 else "")
|
|
article = execute_query_single(
|
|
"""INSERT INTO knowledge_articles (
|
|
solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround,
|
|
visibility,status,tags,products,published_by_user_id,reviewed_at
|
|
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW())
|
|
ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title,
|
|
summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause,
|
|
investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround,
|
|
visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published',
|
|
version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id,
|
|
reviewed_at=NOW(),updated_at=NOW(),archived_at=NULL,archived_by_user_id=NULL RETURNING *""",
|
|
(solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)),
|
|
)
|
|
return article
|
|
|
|
|
|
@router.get("/sag/{sag_id}/knowledge-suggestions")
|
|
async def case_knowledge_suggestions(
|
|
sag_id: int,
|
|
limit: int = Query(6, ge=1, le=20),
|
|
_current_user: dict = Depends(get_current_user),
|
|
):
|
|
_case, articles = _case_knowledge_context(sag_id, limit)
|
|
items = []
|
|
for article in articles:
|
|
item = dict(article)
|
|
item["reason"] = "Matcher sagens titel, beskrivelse eller tags"
|
|
item["relevance_percent"] = min(99, max(1, round(float(item.get("relevance") or 0) * 100)))
|
|
items.append(item)
|
|
return {"items": items, "total": len(items), "source": "approved_knowledge_only"}
|
|
|
|
|
|
@router.post("/sag/{sag_id}/solution/ai-draft", dependencies=[Depends(case_edit_access)])
|
|
async def generate_solution_ai_draft(sag_id: int, current_user: dict = Depends(get_current_user)):
|
|
case, articles = _case_knowledge_context(sag_id, 5)
|
|
comments = execute_query(
|
|
"""SELECT id,forfatter,indhold,created_at FROM sag_kommentarer
|
|
WHERE sag_id=%s AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 60""",
|
|
(sag_id,),
|
|
) or []
|
|
emails = execute_query(
|
|
"""SELECT DISTINCT e.id,e.subject,e.sender_email,e.recipient_email,e.body_text,e.received_date
|
|
FROM email_messages e LEFT JOIN sag_emails se ON se.email_id=e.id
|
|
WHERE e.deleted_at IS NULL AND (se.sag_id=%s OR e.linked_case_id=%s)
|
|
ORDER BY e.received_date DESC NULLS LAST LIMIT 25""",
|
|
(sag_id, sag_id),
|
|
) or []
|
|
time_entries = execute_query(
|
|
"""SELECT id,description,original_hours,worked_date FROM tmodule_times
|
|
WHERE sag_id=%s ORDER BY worked_date DESC NULLS LAST LIMIT 30""",
|
|
(sag_id,),
|
|
) or []
|
|
|
|
sections = [f"SAG #{sag_id}\nTitel: {case.get('titel') or ''}\nBeskrivelse: {case.get('beskrivelse') or ''}"]
|
|
if comments:
|
|
sections.append("KOMMENTARER:\n" + "\n".join(f"[Kommentar {row['id']}] {row.get('forfatter') or 'Ukendt'}: {row.get('indhold') or ''}" for row in reversed(comments)))
|
|
if emails:
|
|
sections.append("MAILS:\n" + "\n".join(f"[Mail {row['id']}] {row.get('subject') or '(uden emne)'}: {row.get('body_text') or ''}" for row in reversed(emails)))
|
|
if time_entries:
|
|
sections.append("TIDSNOTER:\n" + "\n".join(f"[Tid {row['id']}] {row.get('description') or ''}" for row in time_entries))
|
|
if articles:
|
|
sections.append("GODKENDT VIDEN:\n" + "\n".join(f"[Artikel {row['id']}] {row['title']}\nProblem: {row.get('problem') or ''}\nLøsning: {row.get('solution') or ''}" for row in articles))
|
|
context, warnings = _redact_sensitive("\n\n".join(sections))
|
|
context = context[:24000]
|
|
prompt = """Du laver et UDKAST til dokumentation af en IT-supportløsning.
|
|
Returner KUN gyldig JSON med felterne title, problem, root_cause, investigation, description, workaround, solution_type, result, tags, products, confidence, uncertainties og source_refs.
|
|
Regler:
|
|
- Brug kun fakta fra den vedlagte sag og de nummererede kilder.
|
|
- Opfind aldrig en årsag eller handling. Sæt feltet tomt og beskriv manglen i uncertainties.
|
|
- description er den endelige løsning. Hvis sagen endnu ikke dokumenterer en løsning, skal description være tom.
|
|
- source_refs skal indeholde de præcise kilder, fx "Kommentar 12" eller "Artikel 4".
|
|
- Medtag aldrig passwords, tokens, API-nøgler eller andre hemmeligheder.
|
|
- Skriv kort, teknisk og professionelt på dansk.
|
|
- confidence er et tal mellem 0 og 1.
|
|
"""
|
|
service = CaseAnalysisService()
|
|
# A full case history is materially larger than QuickCreate input. Keep the
|
|
# global QuickCreate timeout unchanged, but allow the local model time to
|
|
# produce a source-grounded solution draft.
|
|
service.ai_timeout = max(service.ai_timeout, 60)
|
|
result = await service._call_ollama(prompt, context)
|
|
if not result:
|
|
warnings.append("Den lokale AI-tjeneste er utilgængelig; der vises en sikker grundkladde uden AI-genererede konklusioner")
|
|
result = {
|
|
"title": case.get("titel") or "",
|
|
"problem": case.get("beskrivelse") or "",
|
|
"root_cause": "",
|
|
"investigation": "",
|
|
"description": "",
|
|
"workaround": "",
|
|
"solution_type": "Support",
|
|
"result": "Ej løst",
|
|
"tags": _knowledge_tokens(case.get("tag_text") or "")[:10],
|
|
"products": [],
|
|
"confidence": 0.2,
|
|
"uncertainties": ["Årsag og endelig løsning skal udfyldes af en medarbejder"],
|
|
"source_refs": [f"Sag {sag_id}"],
|
|
}
|
|
allowed_refs = {f"Kommentar {row['id']}" for row in comments} | {f"Mail {row['id']}" for row in emails} | {f"Tid {row['id']}" for row in time_entries} | {f"Artikel {row['id']}" for row in articles} | {f"Sag {sag_id}"}
|
|
source_refs = _normalize_source_refs(result.get("source_refs", []), allowed_refs, sag_id)
|
|
draft = {
|
|
"title": str(result.get("title") or case.get("titel") or "")[:255],
|
|
"problem": str(result.get("problem") or ""),
|
|
"root_cause": str(result.get("root_cause") or ""),
|
|
"investigation": str(result.get("investigation") or ""),
|
|
"description": str(result.get("description") or ""),
|
|
"workaround": str(result.get("workaround") or ""),
|
|
"solution_type": TYPE_ALIASES.get(str(result.get("solution_type") or "Support").casefold(), "Support"),
|
|
"result": RESULT_ALIASES.get(str(result.get("result") or "Ej løst").casefold(), "Ej løst"),
|
|
"tags": _clean_list(result.get("tags")),
|
|
"products": _clean_list(result.get("products")),
|
|
"confidence": max(0.0, min(1.0, float(result.get("confidence") or 0))),
|
|
"uncertainties": _clean_list(result.get("uncertainties")),
|
|
"source_refs": source_refs,
|
|
}
|
|
if not draft["description"]:
|
|
warnings.append("Sagen indeholder ikke en sikkert dokumenteret endelig løsning")
|
|
if not source_refs:
|
|
warnings.append("AI-udkastet indeholdt ingen gyldige kildehenvisninger")
|
|
return {"draft": draft, "warnings": warnings, "model": service.ollama_model, "review_required": True}
|
|
|
|
|
|
@router.get("/solutions")
|
|
async def list_solutions(
|
|
q: str = Query("", max_length=200),
|
|
approval_status: Optional[str] = None,
|
|
visibility: Optional[str] = None,
|
|
include_archived: bool = False,
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
_current_user: dict = Depends(get_current_user),
|
|
):
|
|
where = ["s.deleted_at IS NOT NULL" if include_archived else "s.deleted_at IS NULL"]
|
|
params: list = []
|
|
if approval_status:
|
|
if approval_status not in APPROVAL_STATUSES:
|
|
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
|
|
where.append("s.approval_status=%s")
|
|
params.append(approval_status)
|
|
if visibility:
|
|
if visibility not in VISIBILITIES:
|
|
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
|
|
where.append("s.visibility=%s")
|
|
params.append(visibility)
|
|
term = q.strip()
|
|
if term:
|
|
where.append("(s.title ILIKE %s OR s.description ILIKE %s OR s.problem ILIKE %s OR sg.titel ILIKE %s OR c.name ILIKE %s OR s.tags::text ILIKE %s OR s.products::text ILIKE %s)")
|
|
like = f"%{term}%"
|
|
params.extend([like] * 7)
|
|
where_sql = " AND ".join(where)
|
|
count = execute_query_single(
|
|
f"""SELECT COUNT(*) AS total FROM sag_solutions s
|
|
JOIN sag_sager sg ON sg.id=s.sag_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
|
|
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
|
|
) c ON TRUE WHERE {where_sql}""",
|
|
tuple(params),
|
|
) or {"total": 0}
|
|
items = execute_query(
|
|
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name,
|
|
COALESCE(creator.full_name,creator.username) AS created_by,
|
|
COALESCE(updater.full_name,updater.username) AS updated_by,
|
|
ka.id AS article_id, ka.status AS article_status
|
|
FROM sag_solutions s JOIN sag_sager sg ON sg.id=s.sag_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
|
|
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
|
|
) c ON TRUE
|
|
LEFT JOIN users creator ON creator.user_id=s.created_by_user_id
|
|
LEFT JOIN users updater ON updater.user_id=s.updated_by_user_id
|
|
LEFT JOIN knowledge_articles ka ON ka.solution_id=s.id
|
|
WHERE {where_sql} ORDER BY s.updated_at DESC,s.id DESC LIMIT %s OFFSET %s""",
|
|
tuple(params + [limit, offset]),
|
|
) or []
|
|
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset, "archived": include_archived}
|
|
|
|
|
|
@router.delete("/solutions/{solution_id}", dependencies=[Depends(case_edit_access)])
|
|
async def archive_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
|
|
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NULL", (solution_id,))
|
|
if not solution:
|
|
raise HTTPException(status_code=404, detail="Løsningen findes ikke eller er allerede arkiveret")
|
|
user_id = _user_id(current_user)
|
|
execute_query("UPDATE knowledge_articles SET status='archived',archived_at=NOW(),archived_by_user_id=%s,updated_at=NOW() WHERE solution_id=%s", (user_id, solution_id))
|
|
archived = execute_query_single("UPDATE sag_solutions SET deleted_at=NOW(),deleted_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
|
|
_version_solution(archived, user_id, "Løsning arkiveret")
|
|
return {"status": "archived", "id": solution_id, "sag_id": solution["sag_id"]}
|
|
|
|
|
|
@router.post("/solutions/{solution_id}/restore", dependencies=[Depends(case_edit_access)])
|
|
async def restore_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
|
|
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NOT NULL", (solution_id,))
|
|
if not solution:
|
|
raise HTTPException(status_code=404, detail="Den arkiverede løsning findes ikke")
|
|
user_id = _user_id(current_user)
|
|
restored = execute_query_single("UPDATE sag_solutions SET deleted_at=NULL,deleted_by_user_id=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
|
|
_version_solution(restored, user_id, "Løsning gendannet")
|
|
return {"status": "restored", "id": solution_id, "sag_id": solution["sag_id"]}
|
|
|
|
|
|
@router.get("/knowledge/articles")
|
|
async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)):
|
|
term = q.strip()
|
|
scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')"
|
|
params: list = [customer_id] if customer_id else []
|
|
search = ""
|
|
if term:
|
|
search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)"
|
|
params.extend([term, f"%{term}%"])
|
|
count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0}
|
|
item_params = []
|
|
rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0"
|
|
if term:
|
|
item_params.append(term)
|
|
item_params.extend(params)
|
|
item_params.extend([limit, offset])
|
|
items = execute_query(
|
|
f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
|
|
ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name
|
|
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
|
|
WHERE ka.status='published' AND {scope}{search}
|
|
ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""",
|
|
tuple(item_params),
|
|
) or []
|
|
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset}
|
|
|
|
|
|
@router.get("/knowledge/articles/{article_id}")
|
|
async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)):
|
|
article = execute_query_single(
|
|
"""SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
|
|
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
|
|
LEFT JOIN users u ON u.user_id=ka.published_by_user_id
|
|
WHERE ka.id=%s AND ka.status='published'""",
|
|
(article_id,),
|
|
)
|
|
if not article:
|
|
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
|
|
return article
|