feat(migrations): add AI benchmark and vTiger archive tables

- 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.
This commit is contained in:
Christian 2026-08-31 13:01:35 +02:00
parent adc4fb5876
commit 1cfe5aee76
43 changed files with 4250 additions and 140 deletions

1
app/admin/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Restricted administrative reporting modules."""

254
app/admin/archive_bundle.py Normal file
View File

@ -0,0 +1,254 @@
"""Portable, checksummed Project CT archive bundles. Never contacts vTiger."""
from __future__ import annotations
import hashlib
import json
import os
import socket
import tempfile
import zipfile
from datetime import datetime, timezone
from typing import Any, Iterable
from psycopg2.extras import RealDictCursor
from app.core.database import get_db_connection, release_db_connection
BUNDLE_SCHEMA_VERSION = 1
ENTRY_NAMES = ("versions.jsonl", "records.jsonl", "relations.jsonl", "checkpoints.jsonl", "files.jsonl")
def file_sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _json_line(value: Any) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + "\n").encode("utf-8")
def _write_cursor_entry(archive: zipfile.ZipFile, name: str, cursor, batch_size: int = 500) -> tuple[int, str]:
digest = hashlib.sha256()
count = 0
with archive.open(name, "w") as target:
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
raw = _json_line(dict(row))
target.write(raw)
digest.update(raw)
count += 1
return count, digest.hexdigest()
def create_archive_bundle(through_version_id: int, initiated_by: int | None = None) -> tuple[str, dict]:
fd, path = tempfile.mkstemp(prefix="project-ct-", suffix=".zip")
os.close(fd)
conn = get_db_connection()
manifest = {
"schema_version": BUNDLE_SCHEMA_VERSION,
"created_at": datetime.now(timezone.utc).isoformat(),
"source_instance": socket.gethostname(),
"through_version_id": through_version_id,
"entries": {},
"files": {},
"contains_files": True,
"document_policy": "metadata_and_original_bytes",
}
try:
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
queries = {
"versions.jsonl": ("SELECT * FROM vtiger_archive_versions WHERE id<=%s ORDER BY id", (through_version_id,)),
"records.jsonl": ("SELECT * FROM vtiger_archive_records WHERE version_id<=%s ORDER BY module,vtiger_id,revision_no", (through_version_id,)),
"relations.jsonl": ("SELECT * FROM vtiger_archive_relations WHERE version_id<=%s ORDER BY id", (through_version_id,)),
"checkpoints.jsonl": (
"""SELECT module,MAX(source_modified_at) AS last_modified_at,MAX(vtiger_id) AS last_vtiger_id,
%s::bigint AS last_successful_version_id,NOW() AS updated_at
FROM vtiger_archive_records WHERE version_id<=%s GROUP BY module ORDER BY module""",
(through_version_id, through_version_id),
),
"files.jsonl": (
"""SELECT id,version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,
size_bytes,content_sha256,archived_at
FROM vtiger_archive_files WHERE version_id<=%s ORDER BY id""",
(through_version_id,),
),
}
for index, (name, (sql, params)) in enumerate(queries.items()):
cursor = conn.cursor(name=f"project_ct_export_{index}", cursor_factory=RealDictCursor)
cursor.itersize = 500
cursor.execute(sql, params)
count, digest = _write_cursor_entry(archive, name, cursor)
cursor.close()
manifest["entries"][name] = {"count": count, "sha256": digest}
file_rows = conn.cursor(cursor_factory=RealDictCursor)
file_rows.execute(
"""SELECT DISTINCT ON (content_sha256) content_sha256,content,size_bytes
FROM vtiger_archive_files WHERE version_id<=%s ORDER BY content_sha256,id""",
(through_version_id,),
)
for row in file_rows:
content = bytes(row["content"])
digest = hashlib.sha256(content).hexdigest()
if digest != row["content_sha256"]:
raise ValueError(f"Lokal filchecksum er ugyldig: {row['content_sha256']}")
entry_name = f"files/{digest}"
archive.writestr(entry_name, content)
manifest["files"][digest] = {"entry": entry_name, "size": len(content), "sha256": digest}
file_rows.close()
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2, default=str))
manifest["bundle_sha256"] = file_sha256(path)
return path, manifest
except Exception:
if os.path.exists(path):
os.unlink(path)
raise
finally:
release_db_connection(conn)
def _verify_entry(archive: zipfile.ZipFile, manifest: dict, name: str) -> None:
expected = ((manifest.get("entries") or {}).get(name) or {}).get("sha256")
if not expected:
raise ValueError(f"Manifest mangler checksum for {name}")
digest = hashlib.sha256()
with archive.open(name) as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest() != expected:
raise ValueError(f"Checksumfejl i {name}")
def _rows(archive: zipfile.ZipFile, name: str) -> Iterable[dict]:
with archive.open(name) as source:
for line_number, raw in enumerate(source, 1):
if not raw.strip():
continue
try:
yield json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Ugyldig JSON i {name}, linje {line_number}") from exc
def import_archive_bundle(path: str, initiated_by: int | None = None) -> dict:
bundle_digest = file_sha256(path)
conn = get_db_connection()
counts = {"versions": 0, "records": 0, "records_skipped": 0, "relations": 0, "checkpoints": 0}
try:
with zipfile.ZipFile(path) as archive:
try:
manifest = json.loads(archive.read("manifest.json"))
except (KeyError, json.JSONDecodeError) as exc:
raise ValueError("Filen er ikke en gyldig Project CT-arkivpakke") from exc
if manifest.get("schema_version") != BUNDLE_SCHEMA_VERSION:
raise ValueError(f"Ikke-understøttet arkivformat: {manifest.get('schema_version')}")
if manifest.get("contains_files") is not True:
raise ValueError("Pakken mangler originale vTiger-dokumentfiler")
for name in ENTRY_NAMES:
_verify_entry(archive, manifest, name)
for digest, info in (manifest.get("files") or {}).items():
entry_name = info.get("entry")
if not entry_name or entry_name not in archive.namelist():
raise ValueError(f"Pakken mangler dokumentfil {digest}")
actual = hashlib.sha256(archive.read(entry_name)).hexdigest()
if actual != digest or actual != info.get("sha256"):
raise ValueError(f"Checksumfejl i dokumentfil {digest}")
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
version_map: dict[int, int] = {}
for row in _rows(archive, "versions.jsonl"):
cursor.execute(
"""INSERT INTO vtiger_archive_versions
(sync_kind,status,started_at,completed_at,source_cutoff,module_counts,warnings,
critical_errors,control_report,control_approved_at,raw_export_sha256)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,%s::jsonb,%s,%s) RETURNING id""",
(row["sync_kind"], row["status"], row["started_at"], row.get("completed_at"), row["source_cutoff"],
json.dumps(row.get("module_counts") or {}), json.dumps(row.get("warnings") or []),
json.dumps(row.get("critical_errors") or []), json.dumps(row.get("control_report") or {}),
row.get("control_approved_at"), row.get("raw_export_sha256")),
)
version_map[int(row["id"])] = int(cursor.fetchone()["id"])
counts["versions"] += 1
for row in _rows(archive, "records.jsonl"):
cursor.execute(
"SELECT id FROM vtiger_archive_records WHERE module=%s AND vtiger_id=%s AND payload_sha256=%s",
(row["module"], row["vtiger_id"], row["payload_sha256"]),
)
if cursor.fetchone():
counts["records_skipped"] += 1
continue
cursor.execute(
"SELECT COALESCE(MAX(revision_no),0)+1 AS revision FROM vtiger_archive_records WHERE module=%s AND vtiger_id=%s",
(row["module"], row["vtiger_id"]),
)
revision = int(cursor.fetchone()["revision"])
cursor.execute(
"""INSERT INTO vtiger_archive_records
(version_id,module,vtiger_id,revision_no,source_created_at,source_modified_at,is_deleted,payload,payload_sha256,archived_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s)""",
(version_map[int(row["version_id"])], row["module"], row["vtiger_id"], revision,
row.get("source_created_at"), row.get("source_modified_at"), row.get("is_deleted", False),
json.dumps(row["payload"], ensure_ascii=False), row["payload_sha256"], row.get("archived_at")),
)
counts["records"] += 1
counts["files"] = 0
counts["files_skipped"] = 0
for row in _rows(archive, "files.jsonl"):
cursor.execute(
"""SELECT id FROM vtiger_archive_files
WHERE source_module=%s AND document_vtiger_id=%s AND resource_vtiger_id=%s AND content_sha256=%s""",
(row.get("source_module") or "Documents", row["document_vtiger_id"], row["resource_vtiger_id"], row["content_sha256"]),
)
if cursor.fetchone():
counts["files_skipped"] += 1
continue
info = manifest["files"][row["content_sha256"]]
content = archive.read(info["entry"])
cursor.execute(
"""INSERT INTO vtiger_archive_files
(version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,size_bytes,
content_sha256,content,archived_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(version_map[int(row["version_id"])], row.get("source_module") or "Documents", row["document_vtiger_id"], row["resource_vtiger_id"],
row["filename"], row.get("content_type"), row["size_bytes"], row["content_sha256"], content,
row.get("archived_at")),
)
counts["files"] += 1
for row in _rows(archive, "relations.jsonl"):
cursor.execute(
"""INSERT INTO vtiger_archive_relations
(version_id,source_module,source_vtiger_id,field_name,target_vtiger_id,target_module)
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_map[int(row["version_id"])], row["source_module"], row["source_vtiger_id"],
row["field_name"], row["target_vtiger_id"], row.get("target_module")),
)
counts["relations"] += cursor.rowcount
for row in _rows(archive, "checkpoints.jsonl"):
mapped_version = version_map.get(int(row["last_successful_version_id"])) if row.get("last_successful_version_id") else None
cursor.execute(
"""INSERT INTO vtiger_archive_checkpoints
(module,last_modified_at,last_vtiger_id,last_successful_version_id,updated_at)
VALUES (%s,%s,%s,%s,%s) ON CONFLICT(module) DO UPDATE SET
last_modified_at=GREATEST(vtiger_archive_checkpoints.last_modified_at,EXCLUDED.last_modified_at),
last_vtiger_id=EXCLUDED.last_vtiger_id,last_successful_version_id=EXCLUDED.last_successful_version_id,
updated_at=NOW()""",
(row["module"], row.get("last_modified_at"), row.get("last_vtiger_id"), mapped_version, row.get("updated_at")),
)
counts["checkpoints"] += 1
conn.commit()
return {"bundle_sha256": bundle_digest, "manifest": manifest, "counts": counts, "contacted_vtiger": False}
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)

60
app/admin/hub_impact.html Normal file
View File

@ -0,0 +1,60 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Project CT · Hub Impact{% endblock %}
{% block extra_css %}
<style>
.ct-shell{max-width:1500px;margin:auto}.ct-hero{border-radius:24px;padding:2rem;background:linear-gradient(120deg,#082f49,#0f4c75 55%,#2563eb);color:#fff;box-shadow:0 22px 55px rgba(8,47,73,.22)}
.ct-panel{background:var(--bs-body-bg);border:1px solid rgba(15,76,117,.14);border-radius:18px;box-shadow:0 10px 28px rgba(15,76,117,.06)}
.ct-kpi{border-radius:16px;padding:1.1rem;background:linear-gradient(145deg,rgba(15,76,117,.08),rgba(37,99,235,.03));height:100%}.ct-kpi strong{font-size:1.7rem}.ct-delta.positive{color:#16803c}.ct-delta.negative{color:#dc3545}
.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:#dcfce7;color:#166534}.ct-status.blocked{background:#fee2e2;color:#991b1b}
.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#64748b}.ct-empty{padding:3rem;text-align:center;color:#64748b}
</style>
{% endblock %}
{% block content %}
<main class="container-fluid px-4 py-4 ct-shell">
<section class="ct-hero mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="text-uppercase small fw-bold opacity-75 mb-2">Fortrolig · kun superadmin</div><h1 class="h2 mb-2">Project CT · Hub Impact</h1><p class="mb-0 opacity-75">Dokumentér effekten af Hub mod et permanent vTiger-arkiv.</p></div>
<div class="text-end"><div id="readinessBadge" class="ct-status blocked">Indlæser arkivstatus…</div><div class="small opacity-75 mt-2" id="readinessReasons"></div></div>
</section>
<section class="ct-panel p-3 p-lg-4 mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"><div><h2 class="h5 mb-1">Ny reproducerbar sammenligning</h2><div class="small text-muted">Perioderne skal have præcis samme antal kalenderdage.</div></div><div class="d-flex flex-wrap gap-2"><button class="btn btn-outline-secondary" id="syncFull">Kør fuld sync</button><button class="btn btn-outline-primary" id="syncIncremental">Kør inkrementel sync</button><button class="btn btn-outline-danger" id="syncFinal">Kør final sync</button><button class="btn btn-outline-success" id="approveFinal">Godkend final kontrol</button><a class="btn btn-dark" id="bundleExport">Download pakke til prod</a><button class="btn btn-outline-dark" id="bundleImportBtn">Importér arkivpakke</button><input type="file" id="bundleImportFile" accept=".zip" hidden></div></div>
<div class="row g-3 align-items-end">
<div class="col-lg-3"><label class="form-label fw-semibold">Arkivversion</label><select id="archiveVersion" class="form-select"></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger fra</label><input id="vtFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger til</label><input id="vtTo" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub fra</label><input id="hubFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub til</label><input id="hubTo" type="date" class="form-control"></div>
<div class="col-lg-1 d-grid gap-2"><button id="runReport" class="btn btn-primary">Kør</button><a id="rawExport" class="btn btn-sm btn-outline-secondary">Rådata</a></div>
</div><div id="feedback" class="small mt-3 text-muted"></div>
</section>
<section id="resultArea" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h4 mb-0">Resultat</h2><a id="excelExport" class="btn btn-success">Eksportér Excel</a></div>
<div class="row g-3 mb-4" id="kpis"></div>
<div class="ct-panel p-4 mb-4"><div class="d-flex align-items-center gap-2 mb-3"><i class="bi bi-speedometer2 text-primary fs-4"></i><h3 class="h5 mb-0">Hvad Hub effektiviserer og optimerer</h3></div><div id="impactEvidence" class="row g-3"></div><div class="small text-muted mt-3">Positive og negative ændringer vises ensartet. Tallene sammenligner lige lange perioder og er dokumentation—ikke en skjult vægtet score.</div></div>
<div class="ct-panel p-3 p-lg-4 mb-4"><h3 class="h5 mb-3">Pr. medarbejder</h3><div class="table-responsive"><table class="table align-middle ct-table"><thead><tr><th>Kilde</th><th>Medarbejder</th><th>E-mail</th><th>Timer</th><th>Registreringer</th><th>Sager</th><th>Ordrer</th><th>Timer/dag</th></tr></thead><tbody id="employeeRows"></tbody></table></div></div>
<div class="row g-4"><div class="col-lg-7"><div class="ct-panel p-4 h-100"><h3 class="h5">Afvigelser udeladt fra KPI</h3><div id="anomalies"></div></div></div><div class="col-lg-5"><div class="ct-panel p-4 h-100"><h3 class="h5">Datakvalitet</h3><div id="quality"></div></div></div></div>
</section>
<section class="ct-panel p-4 mt-4"><h2 class="h5 mb-3">Gemte rapporter</h2><div id="savedReports" class="list-group list-group-flush"></div></section>
<section class="ct-panel p-4 mt-4"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="h5 mb-1">Flytning mellem test og produktion</h2><div class="small text-muted">Checksummede arkivpakker. Import læser kun ZIP-filen og kontakter aldrig vTiger.</div><div class="small fw-semibold mt-1" id="fileArchiveStatus"></div></div><button class="btn btn-sm btn-outline-secondary" id="refreshTransfers">Opdatér</button></div><div id="transferRows" class="table-responsive ct-empty">Ingen overførsler endnu.</div></section>
</main>
{% endblock %}
{% block extra_js %}
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const iso=d=>d.toISOString().slice(0,10); let optionsData={};
function defaults(){const now=new Date(),hTo=new Date(now),hFrom=new Date(now),vTo=new Date(now),vFrom=new Date(now);hFrom.setDate(hFrom.getDate()-29);vTo.setDate(hFrom.getDate()-1);vFrom.setDate(vTo.getDate()-29);hubTo.value=iso(hTo);hubFrom.value=iso(hFrom);vtTo.value=iso(vTo);vtFrom.value=iso(vFrom)}
async function api(url,opt={}){const r=await fetch(url,{credentials:'include',...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});if(!r.ok){let e={};try{e=await r.json()}catch{}throw new Error(e.detail||`HTTP ${r.status}`)}return r.json()}
async function loadOptions(){optionsData=await api('/api/v1/admin/hub-impact/options');archiveVersion.innerHTML=optionsData.versions.map(v=>`<option value="${v.id}">Version ${v.id} · ${esc(v.sync_kind)} · ${new Date(v.completed_at).toLocaleString('da-DK')}${v.control_approved_at?' · godkendt':''}</option>`).join('');setRawLink();const ready=optionsData.readiness.ready;readinessBadge.className=`ct-status ${ready?'ready':'blocked'}`;readinessBadge.textContent=ready?'Klar til opsigelse':'Ikke klar til opsigelse';readinessReasons.textContent=(optionsData.readiness.reasons||[]).join(' · ');savedReports.innerHTML=optionsData.reports.length?optionsData.reports.map(r=>`<button class="list-group-item list-group-item-action d-flex justify-content-between" onclick="openReport(${r.id})"><span>Rapport #${r.id} · arkiv v${r.archive_version_id}</span><small>${esc(r.vtiger_from)}${esc(r.vtiger_to)} / ${esc(r.hub_from)}${esc(r.hub_to)}</small></button>`).join(''):'<div class="text-muted">Ingen gemte rapporter endnu.</div>'}
async function loadTransfers(){const data=await api('/api/v1/admin/vtiger-archive/status');const files=data.file_archive||[];fileArchiveStatus.textContent=files.length?`Originale filer i arkivet: ${files.map(x=>`${x.source_module} ${x.files} (${(Number(x.size_bytes)/1048576).toFixed(1)} MB)`).join(' · ')}`:'Ingen originale filer arkiveret endnu.';const rows=data.transfers||[];transferRows.className='table-responsive';transferRows.innerHTML=rows.length?`<table class="table align-middle ct-table mb-0"><thead><tr><th>Tid</th><th>Retning</th><th>Status</th><th>Til version</th><th>Checksum</th><th>Indhold</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${new Date(x.completed_at||x.started_at).toLocaleString('da-DK')}</td><td>${x.direction==='export'?'Test → prod':'Pakke → denne server'}</td><td><span class="badge ${x.status==='completed'?'bg-success':x.status==='failed'?'bg-danger':'bg-warning text-dark'}">${esc(x.status)}</span></td><td>${x.through_version_id||'—'}</td><td><code>${esc((x.bundle_sha256||'').slice(0,12))}${x.bundle_sha256?'…':'—'}</code></td><td>${Object.entries(x.counts||{}).map(([k,v])=>`${esc(k)}: ${v}`).join(' · ')||esc(x.error_message||'—')}</td></tr>`).join('')}</tbody></table>`:'<div class="ct-empty">Ingen overførsler endnu.</div>'}
function setRawLink(){rawExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/versions/${archiveVersion.value}/raw-export`:'#';bundleExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/bundle.zip?through_version_id=${archiveVersion.value}`:'#'}archiveVersion.onchange=setRawLink;
function render(id,r){resultArea.classList.remove('d-none');excelExport.href=`/api/v1/admin/hub-impact/reports/${id}/export.xlsx`;const labels={hours:'Timer',time_entries:'Tidsregistreringer',cases:'Sager',orders:'Ordrer'};kpis.innerHTML=Object.entries(labels).map(([k,l])=>{const d=r.comparison[k],pct=r.improvement?.percent?.[k],cls=d>0?'positive':d<0?'negative':'';return `<div class="col-sm-6 col-xl-3"><div class="ct-kpi"><div class="text-muted small fw-bold">${l}</div><div class="d-flex justify-content-between mt-2"><span>vTiger <strong>${r.vtiger.totals[k]}</strong></span><span>Hub <strong>${r.hub.totals[k]}</strong></span></div><div class="ct-delta ${cls} mt-2 fw-bold">Forskel ${d>0?'+':''}${d}${pct==null?'':` · ${pct>0?'+':''}${pct}%`}</div></div></div>`}).join('');impactEvidence.innerHTML=(r.improvement?.evidence||[]).map((text,i)=>`<div class="col-md-6"><div class="ct-kpi d-flex gap-3 align-items-center"><span class="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style="width:34px;height:34px;flex:0 0 34px">${i+1}</span><strong class="fs-6">${esc(text)}</strong></div></div>`).join('')||'<div class="text-muted">Ikke nok vTiger-baseline til at beregne procentvis effekt.</div>';employeeRows.innerHTML=['vtiger','hub'].flatMap(src=>r[src].employees.map(x=>`<tr><td><span class="badge ${src==='hub'?'bg-primary':'bg-secondary'}">${src}</span></td><td>${esc(x.name)}</td><td>${esc(x.email||'—')}</td><td>${x.hours}</td><td>${x.time_entries}</td><td>${x.cases}</td><td>${x.orders}</td><td>${x.productivity.hours_per_workday}</td></tr>`)).join('');const aa=[...r.vtiger.anomalies,...r.hub.anomalies];anomalies.innerHTML=aa.length?aa.map(a=>`<div class="border-bottom py-2"><strong>${esc(a.source)}</strong> · ${esc(a.type)} · #${esc(a.id)} · ${a.hours} timer</div>`).join(''):'<div class="text-success">Ingen afvigelser i perioderne.</div>';quality.innerHTML=['vtiger','hub'].map(src=>`<div class="mb-3"><strong>${src}</strong><div>Manglende datoer: ${r[src].data_quality.missing_dates}</div><div>Ikke matchede medarbejdere: ${r[src].data_quality.unmatched_employees.length}</div></div>`).join('')}
async function openReport(id){feedback.textContent='Indlæser rapport…';try{const row=await api(`/api/v1/admin/hub-impact/reports/${id}`);render(id,row.result);feedback.textContent=`Rapport #${id} indlæst.`}catch(e){feedback.textContent=e.message}}
runReport.onclick=async()=>{runReport.disabled=true;feedback.textContent='Beregner og gemmer rapport…';try{const x=await api('/api/v1/admin/hub-impact/reports',{method:'POST',body:JSON.stringify({archive_version_id:Number(archiveVersion.value),vtiger_from:vtFrom.value,vtiger_to:vtTo.value,hub_from:hubFrom.value,hub_to:hubTo.value})});render(x.id,x.result);feedback.textContent=`Rapport #${x.id} er gemt reproducerbart.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{runReport.disabled=false}};
async function sync(kind,button){if(kind==='final'&&!confirm('Final sync bruges som grundlag for opsigelsesgodkendelsen. Fortsæt?'))return;button.disabled=true;feedback.textContent=`Kører ${kind} sync…`;try{const r=await api(`/api/v1/admin/vtiger-archive/sync/${kind}`,{method:'POST'});feedback.textContent=`Arkivversion ${r.version_id}: ${r.status}`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{button.disabled=false}}
approveFinal.onclick=async()=>{const final=optionsData.versions.find(v=>v.sync_kind==='final');if(!final){feedback.textContent='Der findes endnu ingen fuldført final sync.';return}approveFinal.disabled=true;try{await api(`/api/v1/admin/vtiger-archive/versions/${final.id}/approve-control`,{method:'POST'});feedback.textContent=`Kontrolrapport for final version ${final.id} er godkendt.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{approveFinal.disabled=false}};
bundleImportBtn.onclick=()=>bundleImportFile.click();bundleImportFile.onchange=async()=>{const file=bundleImportFile.files[0];if(!file)return;if(!confirm(`Importér ${file.name}? Importen bruger kun filen og kontakter aldrig vTiger.`))return;bundleImportBtn.disabled=true;feedback.textContent='Validerer checksums og importerer hele arkivpakken…';try{const form=new FormData();form.append('file',file);const response=await fetch('/api/v1/admin/vtiger-archive/import-bundle',{method:'POST',body:form,credentials:'include'});const data=await response.json();if(!response.ok)throw new Error(data.detail||`HTTP ${response.status}`);feedback.textContent=data.already_imported?'Denne præcise pakke var allerede importeret; intet blev duplikeret.':`Import færdig: ${data.counts.records} revisioner, ${data.counts.relations} relationer. vTiger kontaktet: nej.`;await Promise.all([loadOptions(),loadTransfers()])}catch(e){feedback.textContent=e.message}finally{bundleImportBtn.disabled=false;bundleImportFile.value=''}};
refreshTransfers.onclick=()=>loadTransfers().catch(e=>feedback.textContent=e.message);syncFull.onclick=()=>sync('full',syncFull);syncIncremental.onclick=()=>sync('incremental',syncIncremental);syncFinal.onclick=()=>sync('final',syncFinal);defaults();Promise.all([loadOptions(),loadTransfers()]).catch(e=>feedback.textContent=e.message);
</script>
{% endblock %}

235
app/admin/hub_impact.py Normal file
View File

@ -0,0 +1,235 @@
"""Reproducible vTiger-versus-Hub impact metrics for Project CT."""
from __future__ import annotations
import hashlib
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from app.core.database import execute_insert, execute_query, execute_query_single
from app.services.vtiger_service import VTigerService
DATE_FIELDS = ("worked_date", "date", "createdtime", "created_at", "modifiedtime")
EMAIL_FIELDS = ("email1", "email", "email_address", "user_email")
USER_REF_FIELDS = ("assigned_user_id", "creator", "created_by", "userid", "user_id")
def _date(value: Any) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
try:
return datetime.fromisoformat(str(value or "").strip().replace("Z", "+00:00")).date()
except (ValueError, TypeError):
return None
def _first(payload: dict, fields: tuple[str, ...]):
return next((payload.get(field) for field in fields if payload.get(field) not in (None, "")), None)
def _hours(payload: dict) -> Decimal:
return VTigerService._extract_timelog_hours(payload)
def _archive_snapshot(version_id: int) -> list[dict[str, Any]]:
return execute_query(
"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,payload,is_deleted
FROM vtiger_archive_records WHERE version_id <= %s
ORDER BY module,vtiger_id,revision_no DESC""", (version_id,),
) or []
def _hub_users() -> tuple[dict[int, dict], dict[str, dict]]:
rows = execute_query("SELECT user_id,email,full_name,username FROM users") or []
by_id = {int(row["user_id"]): dict(row) for row in rows}
by_email = {str(row.get("email") or "").strip().lower(): dict(row) for row in rows if row.get("email")}
return by_id, by_email
def _employee_bucket(store: dict, key: str, name: str, email: str | None):
if key not in store:
store[key] = {"key": key, "name": name, "email": email, "hours": Decimal("0"),
"time_entries": 0, "cases": 0, "orders": 0}
return store[key]
def _finalize(metrics: dict[str, Any], start: date, end: date) -> dict[str, Any]:
days = (end - start).days + 1
workdays = max(sum(1 for offset in range(days) if (start + timedelta(days=offset)).weekday() < 5), 1)
employees = []
for item in metrics["employees"].values():
normalized = dict(item)
normalized["hours"] = round(float(item["hours"]), 2)
normalized["productivity"] = {
"hours_per_workday": round(normalized["hours"] / workdays, 2),
"registrations_per_workday": round(normalized["time_entries"] / workdays, 2),
"cases_per_workday": round(normalized["cases"] / workdays, 2),
"orders_per_workday": round(normalized["orders"] / workdays, 2),
"deliveries_per_workday": round((normalized["time_entries"] + normalized["cases"] + normalized["orders"]) / workdays, 2),
}
employees.append(normalized)
employees.sort(key=lambda row: (row.get("name") or "").lower())
totals = {key: sum(float(row[key]) for row in employees) for key in ("hours", "time_entries", "cases", "orders")}
totals["hours"] = round(totals["hours"], 2)
totals["time_entries"] = int(totals["time_entries"])
totals["cases"] = int(totals["cases"])
totals["orders"] = int(totals["orders"])
totals["productivity"] = {
"hours_per_workday": round(totals["hours"] / workdays, 2),
"registrations_per_workday": round(totals["time_entries"] / workdays, 2),
"cases_per_workday": round(totals["cases"] / workdays, 2),
"orders_per_workday": round(totals["orders"] / workdays, 2),
"deliveries_per_workday": round((totals["time_entries"] + totals["cases"] + totals["orders"]) / workdays, 2),
"hours_per_case_or_order": round(totals["hours"] / max(totals["cases"] + totals["orders"], 1), 2),
}
return {"totals": totals, "employees": employees, "anomalies": metrics["anomalies"],
"data_quality": metrics["data_quality"], "workdays": workdays}
def _vtiger_metrics(records: list[dict], start: date, end: date, hub_email_users: dict[str, dict]) -> dict:
active = [row for row in records if not row.get("is_deleted")]
vt_users = {}
for row in active:
if row["module"] != "Users":
continue
payload = row["payload"] or {}
email = str(_first(payload, EMAIL_FIELDS) or "").strip().lower()
vt_users[row["vtiger_id"]] = {
"email": email or None,
"name": str(payload.get("first_name") or "") + " " + str(payload.get("last_name") or payload.get("user_name") or row["vtiger_id"]),
}
result = {"employees": {}, "anomalies": [], "data_quality": {"unmatched_employees": [], "missing_dates": 0}}
module_metric = {"Timelog": "time_entries", "Cases": "cases", "SalesOrder": "orders"}
for row in active:
metric = module_metric.get(row["module"])
if not metric:
continue
payload = row["payload"] or {}
occurred = _date(_first(payload, DATE_FIELDS))
if not occurred:
result["data_quality"]["missing_dates"] += 1
continue
if occurred < start or occurred > end:
continue
user_ref = str(_first(payload, USER_REF_FIELDS) or "")
vt_user = vt_users.get(user_ref, {"email": None, "name": user_ref or "Ukendt vTiger-bruger"})
email = vt_user.get("email")
matched = hub_email_users.get(email) if email else None
key = f"hub:{matched['user_id']}" if matched else f"vtiger:{user_ref or 'unknown'}"
name = str(matched.get("full_name") or matched.get("username")) if matched else str(vt_user.get("name") or key).strip()
bucket = _employee_bucket(result["employees"], key, name, email)
if not matched and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
if metric == "time_entries":
hours = _hours(payload)
if hours > 16:
result["anomalies"].append({"source": "vtiger", "type": "over_16_hours", "id": row["vtiger_id"], "hours": float(hours)})
continue
bucket["hours"] += hours
bucket[metric] += 1
return result
def _hub_metrics(start: date, end: date, hub_users: dict[int, dict]) -> dict:
result = {"employees": {}, "anomalies": [], "data_quality": {"unmatched_employees": [], "missing_dates": 0}}
times = execute_query(
"""SELECT id,medarbejder_id,worked_date,start_tid,created_at,aktiv_timer,
COALESCE(faktisk_tid_min::numeric/60,approved_hours,original_hours,0) AS hours
FROM tmodule_times WHERE vtiger_id IS NULL
AND COALESCE(worked_date,start_tid::date,created_at::date) BETWEEN %s AND %s""", (start, end),
) or []
for row in times:
user = hub_users.get(int(row["medarbejder_id"])) if row.get("medarbejder_id") else None
key = f"hub:{user['user_id']}" if user else "hub:unknown"
bucket = _employee_bucket(result["employees"], key,
str((user or {}).get("full_name") or (user or {}).get("username") or "Ukendt Hub-bruger"),
(user or {}).get("email"))
if not user and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
hours = Decimal(str(row.get("hours") or 0))
if row.get("aktiv_timer") or hours > 16:
result["anomalies"].append({"source": "hub", "type": "active_timer" if row.get("aktiv_timer") else "over_16_hours",
"id": row["id"], "hours": float(hours)})
continue
bucket["hours"] += hours
bucket["time_entries"] += 1
cases = execute_query(
"""SELECT id,created_by_user_id,created_at FROM sag_sager
WHERE deleted_at IS NULL AND created_at::date BETWEEN %s AND %s""", (start, end),
) or []
orders = execute_query(
"SELECT id,created_by,order_date FROM tmodule_orders WHERE order_date BETWEEN %s AND %s", (start, end),
) or []
for rows, metric, user_field in ((cases, "cases", "created_by_user_id"), (orders, "orders", "created_by")):
for row in rows:
user = hub_users.get(int(row[user_field])) if row.get(user_field) else None
key = f"hub:{user['user_id']}" if user else "hub:unknown"
bucket = _employee_bucket(result["employees"], key,
str((user or {}).get("full_name") or (user or {}).get("username") or "Ukendt Hub-bruger"),
(user or {}).get("email"))
if not user and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
bucket[metric] += 1
return result
def build_impact_report(version_id: int, vtiger_from: date, vtiger_to: date, hub_from: date, hub_to: date) -> dict:
vt_days = (vtiger_to - vtiger_from).days + 1
hub_days = (hub_to - hub_from).days + 1
if vt_days <= 0 or hub_days <= 0 or vt_days != hub_days:
raise ValueError("vTiger- og Hub-perioderne skal være gyldige og lige lange")
version = execute_query_single(
"SELECT id,status,source_cutoff FROM vtiger_archive_versions WHERE id=%s", (version_id,),
)
if not version or version.get("status") != "completed":
raise ValueError("Vælg en fuldført arkivversion")
by_id, by_email = _hub_users()
vtiger = _finalize(_vtiger_metrics(_archive_snapshot(version_id), vtiger_from, vtiger_to, by_email), vtiger_from, vtiger_to)
hub = _finalize(_hub_metrics(hub_from, hub_to, by_id), hub_from, hub_to)
keys = ("hours", "time_entries", "cases", "orders")
delta = {key: round(float(hub["totals"][key]) - float(vtiger["totals"][key]), 2) for key in keys}
percent = {
key: (round(delta[key] / float(vtiger["totals"][key]) * 100, 1) if float(vtiger["totals"][key]) else None)
for key in keys
}
productivity_keys = ("hours_per_workday", "registrations_per_workday", "cases_per_workday",
"orders_per_workday", "deliveries_per_workday", "hours_per_case_or_order")
productivity_delta = {}
for key in productivity_keys:
old, new = float(vtiger["totals"]["productivity"][key]), float(hub["totals"]["productivity"][key])
productivity_delta[key] = {"delta": round(new - old, 2), "percent": round((new - old) / old * 100, 1) if old else None}
evidence = []
for key, label in (("deliveries_per_workday", "samlet registreret output pr. arbejdsdag"),
("cases_per_workday", "sager pr. arbejdsdag"),
("orders_per_workday", "ordrer pr. arbejdsdag"),
("registrations_per_workday", "tidsregistreringer pr. arbejdsdag")):
change = productivity_delta[key]
if change["percent"] is not None:
direction = "mere" if change["percent"] >= 0 else "mindre"
evidence.append(f"Hub registrerer {abs(change['percent']):.1f}% {direction} {label}.")
return {
"archive_version": {"id": version_id, "source_cutoff": version.get("source_cutoff")},
"periods": {"vtiger": {"from": vtiger_from, "to": vtiger_to, "days": vt_days},
"hub": {"from": hub_from, "to": hub_to, "days": hub_days}},
"vtiger": vtiger, "hub": hub,
"comparison": delta,
"improvement": {"percent": percent, "productivity": productivity_delta, "evidence": evidence},
}
def save_impact_report(result: dict, generated_by: int) -> int:
canonical = json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
periods = result["periods"]
return int(execute_insert(
"""INSERT INTO hub_impact_reports
(archive_version_id,vtiger_from,vtiger_to,hub_from,hub_to,result,result_sha256,generated_by)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s) RETURNING id""",
(result["archive_version"]["id"], periods["vtiger"]["from"], periods["vtiger"]["to"],
periods["hub"]["from"], periods["hub"]["to"], canonical,
hashlib.sha256(canonical.encode()).hexdigest(), generated_by),
))

344
app/admin/router.py Normal file
View File

@ -0,0 +1,344 @@
"""Superadmin-only Project CT archive endpoints."""
from __future__ import annotations
import hashlib
import io
import json
import os
import tempfile
from datetime import date
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse, Response
from starlette.background import BackgroundTask
from pydantic import BaseModel
from app.admin.vtiger_archive import ARCHIVE_MODULES, run_archive_sync, termination_readiness
from app.admin.hub_impact import build_impact_report, save_impact_report
from app.admin.archive_bundle import create_archive_bundle, file_sha256, import_archive_bundle
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single
router = APIRouter()
class ImpactReportRequest(BaseModel):
archive_version_id: int
vtiger_from: date
vtiger_to: date
hub_from: date
hub_to: date
def require_hidden_superadmin(current_user: dict = Depends(get_current_user)) -> dict:
# This feature must not disclose its existence to ordinary users.
if not current_user.get("is_superadmin"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return current_user
@router.get("/admin/vtiger-archive/status")
async def archive_status(current_user: dict = Depends(require_hidden_superadmin)):
versions = execute_query(
"""SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts,
critical_errors,control_report,control_approved_at,raw_export_sha256
FROM vtiger_archive_versions ORDER BY id DESC LIMIT 25"""
) or []
checkpoints = execute_query(
"SELECT * FROM vtiger_archive_checkpoints ORDER BY module"
) or []
transfers = execute_query(
"""SELECT id,direction,bundle_sha256,through_version_id,source_instance,status,counts,
error_message,started_at,completed_at
FROM vtiger_archive_transfers ORDER BY id DESC LIMIT 25"""
) or []
file_archive = execute_query(
"""SELECT source_module,COUNT(*)::integer AS files,SUM(size_bytes)::bigint AS size_bytes
FROM vtiger_archive_files GROUP BY source_module ORDER BY source_module"""
) or []
return {"versions": versions, "checkpoints": checkpoints, "transfers": transfers,
"file_archive": file_archive, "readiness": termination_readiness()}
@router.post("/admin/vtiger-archive/sync/{sync_kind}")
async def archive_sync(sync_kind: str, current_user: dict = Depends(require_hidden_superadmin)):
return await run_archive_sync(sync_kind, int(current_user["id"]))
@router.post("/admin/vtiger-archive/versions/{version_id}/approve-control")
async def approve_control(version_id: int, current_user: dict = Depends(require_hidden_superadmin)):
version = execute_query_single(
"SELECT * FROM vtiger_archive_versions WHERE id=%s", (version_id,),
)
if not version:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
if version.get("status") != "completed" or version.get("critical_errors"):
raise HTTPException(status_code=409, detail="En fejlet eller ukomplet kontrolrapport kan ikke godkendes")
counts = version.get("module_counts") or {}
missing = [module for module in ARCHIVE_MODULES if module not in counts]
if missing:
raise HTTPException(status_code=409, detail=f"Kontrolrapport mangler moduler: {', '.join(missing)}")
execute_query(
"""UPDATE vtiger_archive_versions SET control_approved_at=NOW(),control_approved_by=%s
WHERE id=%s""", (current_user["id"], version_id), fetch=False,
)
return {"approved": True, "version_id": version_id, "readiness": termination_readiness()}
@router.get("/admin/vtiger-archive/versions/{version_id}/raw-export")
async def raw_export(
version_id: int,
module: str | None = Query(None),
current_user: dict = Depends(require_hidden_superadmin),
):
version = execute_query_single("SELECT id FROM vtiger_archive_versions WHERE id=%s", (version_id,))
if not version:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
params: list[object] = [version_id]
module_filter = ""
if module:
module_filter = "AND module=%s"
params.append(module)
rows = execute_query(
f"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,revision_no,
source_created_at,source_modified_at,is_deleted,payload,payload_sha256
FROM vtiger_archive_records WHERE version_id <= %s {module_filter}
ORDER BY module,vtiger_id,revision_no DESC""", tuple(params),
) or []
relations = execute_query(
"""SELECT source_module,source_vtiger_id,field_name,target_vtiger_id,target_module
FROM vtiger_archive_relations WHERE version_id <= %s
ORDER BY source_module,source_vtiger_id,field_name,target_vtiger_id""", (version_id,),
) or []
version_meta = execute_query_single(
"""SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts,
warnings,critical_errors,control_report,control_approved_at
FROM vtiger_archive_versions WHERE id=%s""", (version_id,),
) or {}
export_lines = [{"record_type": "archive_version", "data": dict(version_meta)}]
export_lines.extend({"record_type": "entity", "data": dict(row)} for row in rows)
export_lines.extend({"record_type": "relation", "data": dict(row)} for row in relations)
body = "\n".join(json.dumps(item, ensure_ascii=False, default=str, sort_keys=True) for item in export_lines) + "\n"
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
if not module:
execute_query(
"UPDATE vtiger_archive_versions SET raw_export_sha256=%s WHERE id=%s",
(digest, version_id), fetch=False,
)
filename = f"project-ct-vtiger-v{version_id}{'-' + module if module else ''}.jsonl"
return Response(
content=body,
media_type="application/x-ndjson",
headers={"Content-Disposition": f'attachment; filename="{filename}"', "X-Content-SHA256": digest},
)
@router.get("/admin/vtiger-archive/readiness")
async def readiness(current_user: dict = Depends(require_hidden_superadmin)):
return termination_readiness()
@router.get("/admin/vtiger-archive/bundle.zip")
async def export_archive_bundle(
through_version_id: int | None = Query(None),
current_user: dict = Depends(require_hidden_superadmin),
):
if through_version_id is None:
latest = execute_query_single(
"SELECT id FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC LIMIT 1"
)
if not latest:
raise HTTPException(status_code=409, detail="Der findes ingen fuldført arkivversion")
through_version_id = int(latest["id"])
selected = execute_query_single(
"SELECT id,status FROM vtiger_archive_versions WHERE id=%s", (through_version_id,),
)
if not selected:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
if selected.get("status") != "completed":
raise HTTPException(status_code=409, detail="Kun en fuldført arkivversion kan eksporteres til produktion")
transfer_id = execute_query_single(
"""INSERT INTO vtiger_archive_transfers(direction,through_version_id,status,initiated_by)
VALUES ('export',%s,'running',%s) RETURNING id""", (through_version_id, current_user["id"]),
)["id"]
try:
path, manifest = create_archive_bundle(through_version_id, int(current_user["id"]))
counts = {name: info["count"] for name, info in manifest["entries"].items()}
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),
bundle_sha256=%s,source_instance=%s,counts=%s::jsonb WHERE id=%s""",
(manifest["bundle_sha256"], manifest["source_instance"], json.dumps(counts), transfer_id), fetch=False,
)
return FileResponse(
path, media_type="application/zip",
filename=f"project-ct-vtiger-archive-v{through_version_id}.zip",
background=BackgroundTask(lambda: os.path.exists(path) and os.unlink(path)),
headers={"X-Archive-SHA256": manifest["bundle_sha256"], "X-vTiger-Contacted": "false"},
)
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke oprettes: {exc}") from exc
@router.post("/admin/vtiger-archive/import-bundle")
async def import_bundle(
file: UploadFile = File(...),
current_user: dict = Depends(require_hidden_superadmin),
):
if not str(file.filename or "").lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Vælg en Project CT .zip-arkivpakke")
fd, path = tempfile.mkstemp(prefix="project-ct-upload-", suffix=".zip")
os.close(fd)
transfer_id = execute_query_single(
"""INSERT INTO vtiger_archive_transfers(direction,status,initiated_by)
VALUES ('import','running',%s) RETURNING id""", (current_user["id"],),
)["id"]
try:
size = 0
with open(path, "wb") as target:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > 20 * 1024 * 1024 * 1024:
raise ValueError("Arkivpakken må højst fylde 20 GB")
target.write(chunk)
uploaded_sha256 = file_sha256(path)
previous = execute_query_single(
"""SELECT id,through_version_id,counts FROM vtiger_archive_transfers
WHERE direction='import' AND status='completed' AND bundle_sha256=%s
ORDER BY id DESC LIMIT 1""", (uploaded_sha256,),
)
if previous:
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s,
through_version_id=%s,counts=%s::jsonb WHERE id=%s""",
(uploaded_sha256, previous.get("through_version_id"), json.dumps(previous.get("counts") or {}), transfer_id),
fetch=False,
)
return {
"bundle_sha256": uploaded_sha256,
"counts": previous.get("counts") or {},
"contacted_vtiger": False,
"already_imported": True,
"original_transfer_id": previous["id"],
"transfer_id": transfer_id,
}
result = import_archive_bundle(path, int(current_user["id"]))
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s,
source_instance=%s,through_version_id=%s,counts=%s::jsonb WHERE id=%s""",
(result["bundle_sha256"], result["manifest"].get("source_instance"),
result["manifest"].get("through_version_id"), json.dumps(result["counts"]), transfer_id), fetch=False,
)
return {**result, "transfer_id": transfer_id}
except ValueError as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke importeres: {exc}") from exc
finally:
if os.path.exists(path):
os.unlink(path)
@router.get("/admin/hub-impact/options")
async def impact_options(current_user: dict = Depends(require_hidden_superadmin)):
versions = execute_query(
"""SELECT id,sync_kind,completed_at,source_cutoff,module_counts,control_report,control_approved_at
FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC"""
) or []
reports = execute_query(
"""SELECT id,archive_version_id,vtiger_from,vtiger_to,hub_from,hub_to,result_sha256,generated_at
FROM hub_impact_reports ORDER BY id DESC LIMIT 30"""
) or []
return {"versions": versions, "reports": reports, "readiness": termination_readiness()}
@router.post("/admin/hub-impact/reports")
async def create_impact_report(payload: ImpactReportRequest, current_user: dict = Depends(require_hidden_superadmin)):
try:
result = build_impact_report(payload.archive_version_id, payload.vtiger_from, payload.vtiger_to,
payload.hub_from, payload.hub_to)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
report_id = save_impact_report(result, int(current_user["id"]))
return {"id": report_id, "result": result}
@router.get("/admin/hub-impact/reports/{report_id}")
async def get_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)):
row = execute_query_single("SELECT * FROM hub_impact_reports WHERE id=%s", (report_id,))
if not row:
raise HTTPException(status_code=404, detail="Rapporten findes ikke")
return row
def _impact_workbook(report: dict) -> bytes:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
wb = Workbook()
overview = wb.active
overview.title = "Overblik"
overview.append(["Project CT · Hub Impact", "vTiger", "Hub", "Forskel", "% ændring"])
for cell in overview[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="0F4C75")
labels = {"hours": "Timer", "time_entries": "Tidsregistreringer", "cases": "Sager", "orders": "Ordrer"}
for key, label in labels.items():
overview.append([label, report["vtiger"]["totals"][key], report["hub"]["totals"][key], report["comparison"][key], (report.get("improvement") or {}).get("percent", {}).get(key)])
overview.append([])
overview.append(["Arkivversion", report["archive_version"]["id"]])
overview.append(["vTiger-periode", f"{report['periods']['vtiger']['from']} {report['periods']['vtiger']['to']}"])
overview.append(["Hub-periode", f"{report['periods']['hub']['from']} {report['periods']['hub']['to']}"])
overview.freeze_panes = "A2"
overview.column_dimensions["A"].width = 28
for col in "BCDE": overview.column_dimensions[col].width = 18
evidence = wb.create_sheet("Effekt")
evidence.append(["Dokumenteret ændring"])
for line in (report.get("improvement") or {}).get("evidence", []):
evidence.append([line])
evidence.column_dimensions["A"].width = 90
employees = wb.create_sheet("Pr medarbejder")
employees.append(["Kilde", "Medarbejder", "E-mail", "Timer", "Registreringer", "Sager", "Ordrer", "Timer/arbejdsdag"])
for source in ("vtiger", "hub"):
for row in report[source]["employees"]:
employees.append([source, row["name"], row.get("email"), row["hours"], row["time_entries"], row["cases"], row["orders"], row["productivity"]["hours_per_workday"]])
for cell in employees[1]: cell.font = Font(bold=True)
employees.freeze_panes = "A2"
employees.auto_filter.ref = employees.dimensions
anomalies = wb.create_sheet("Afvigelser")
anomalies.append(["Kilde", "Type", "ID", "Timer"])
for source in ("vtiger", "hub"):
for row in report[source]["anomalies"]:
anomalies.append([row.get("source"), row.get("type"), row.get("id"), row.get("hours")])
quality = wb.create_sheet("Datakvalitet")
quality.append(["Kilde", "Manglende datoer", "Ikke matchede medarbejdere"])
for source in ("vtiger", "hub"):
data = report[source]["data_quality"]
quality.append([source, data["missing_dates"], ", ".join(data["unmatched_employees"])])
output = io.BytesIO()
wb.save(output)
return output.getvalue()
@router.get("/admin/hub-impact/reports/{report_id}/export.xlsx")
async def export_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)):
row = execute_query_single("SELECT result FROM hub_impact_reports WHERE id=%s", (report_id,))
if not row:
raise HTTPException(status_code=404, detail="Rapporten findes ikke")
return Response(
content=_impact_workbook(row["result"]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="project-ct-impact-{report_id}.xlsx"'},
)

13
app/admin/views.py Normal file
View File

@ -0,0 +1,13 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.admin.router import require_hidden_superadmin
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/admin/hub-impact", response_class=HTMLResponse)
async def hub_impact_page(request: Request, current_user: dict = Depends(require_hidden_superadmin)):
return templates.TemplateResponse("admin/hub_impact.html", {"request": request, "current_user": current_user})

378
app/admin/vtiger_archive.py Normal file
View File

@ -0,0 +1,378 @@
"""Permanent append-only vTiger archive used by Project CT."""
from __future__ import annotations
import hashlib
import asyncio
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Iterable
from app.core.database import execute_insert, execute_query, execute_query_single
from app.services.vtiger_service import VTigerService, get_vtiger_service
logger = logging.getLogger(__name__)
# Document metadata and original file bytes are both archived before vTiger is retired.
ARCHIVE_MODULES = (
"Users", "Groups", "Roles", "Currency", "Accounts", "Contacts", "Leads", "Potentials", "Cases", "Project",
"ProjectMilestone", "ModComments", "Timelog", "Calendar", "Events", "Emails",
"SalesOrder", "Quotes", "Invoice", "PurchaseOrder", "Vendors", "ServiceContracts",
"Subscription", "Products", "Services", "Assets", "Documents",
)
RELATION_FIELD_RE = re.compile(r"(?:^|_)(?:id|ids|account|contact|parent|related|assigned_user)$", re.I)
MODULE_BATCH_SIZES = {"Emails": 20, "Invoice": 50, "SalesOrder": 50, "Subscription": 50}
def _transient_vtiger_failure(service) -> bool:
status = service.last_query_status
error_text = json.dumps(service.last_query_error or {}).upper()
return status is None or status == 429 or (isinstance(status, int) and status >= 500) or any(
token in error_text for token in ("TOO_MANY_REQUESTS", "TIMEOUT", "TEMPORAR")
)
def _canonical_payload(record: dict[str, Any]) -> tuple[str, str]:
raw = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
return raw, hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _parse_source_time(value: Any):
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def _relation_values(payload: dict[str, Any]) -> Iterable[tuple[str, str]]:
for field, value in payload.items():
if not value or not RELATION_FIELD_RE.search(str(field)):
continue
values = value if isinstance(value, list) else [value]
for candidate in values:
candidate = str(candidate or "").strip()
if re.fullmatch(r"\d+x\d+", candidate):
yield str(field), candidate
def _target_module(vtiger_id: str) -> str | None:
prefix = str(vtiger_id).split("x", 1)[0]
row = execute_query_single(
"""SELECT module FROM vtiger_archive_records
WHERE split_part(vtiger_id,'x',1)=%s ORDER BY id DESC LIMIT 1""", (prefix,),
)
return str(row["module"]) if row else None
def archive_record(version_id: int, module: str, record: dict[str, Any]) -> bool:
vtiger_id = str(record.get("id") or "").strip()
if not vtiger_id:
raise ValueError(f"{module}-post mangler id")
raw, digest = _canonical_payload(record)
previous = execute_query_single(
"""SELECT revision_no, payload_sha256 FROM vtiger_archive_records
WHERE module=%s AND vtiger_id=%s ORDER BY revision_no DESC LIMIT 1""",
(module, vtiger_id),
)
if previous and previous.get("payload_sha256") == digest:
return False
revision = int((previous or {}).get("revision_no") or 0) + 1
execute_query(
"""INSERT INTO vtiger_archive_records
(version_id,module,vtiger_id,revision_no,source_created_at,source_modified_at,is_deleted,payload,payload_sha256)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s)""",
(version_id, module, vtiger_id, revision, _parse_source_time(record.get("createdtime")),
_parse_source_time(record.get("modifiedtime")), bool(record.get("deleted")), raw, digest),
fetch=False,
)
for field, target in _relation_values(record):
execute_query(
"""INSERT INTO vtiger_archive_relations
(version_id,source_module,source_vtiger_id,field_name,target_vtiger_id,target_module)
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_id, module, vtiger_id, field, target, _target_module(target)), fetch=False,
)
return True
async def archive_document_files(version_id: int) -> dict[str, int]:
"""Archive immutable Document and Email bytes via vTiger's files_retrieve API."""
documents = execute_query(
"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,payload
FROM vtiger_archive_records
WHERE module IN ('Documents','Emails') AND version_id<=%s AND is_deleted=false
ORDER BY module,vtiger_id,revision_no DESC""", (version_id,),
) or []
stats = {"entities": len(documents), "resources": 0, "archived": 0, "unchanged": 0, "errors": 0}
# A small hard ceiling keeps this one-time preservation fast without flooding vTiger.
semaphore = asyncio.Semaphore(6)
jobs = []
async def archive_resource(document: dict, payload: dict, resource_id: str) -> None:
async with semaphore:
previous_resource = execute_query_single(
"""SELECT id FROM vtiger_archive_files
WHERE source_module=%s AND document_vtiger_id=%s AND resource_vtiger_id=%s
ORDER BY id DESC LIMIT 1""",
(document["module"], document["vtiger_id"], resource_id),
)
if previous_resource:
stats["unchanged"] += 1
return
service = VTigerService()
result = None
for attempt in range(8):
result = await service.retrieve_file(resource_id)
if result or not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not result:
stats["errors"] += 1
return
content = result.get("content") or b""
digest = hashlib.sha256(content).hexdigest()
filename = str(result.get("filename") or payload.get("filename") or resource_id)
execute_query(
"""INSERT INTO vtiger_archive_files
(version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,size_bytes,content_sha256,content)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_id, document["module"], document["vtiger_id"], resource_id, filename,
result.get("filetype") or payload.get("filetype"), len(content), digest, content),
fetch=False,
)
stats["archived"] += 1
await asyncio.sleep(0.35)
for document in documents:
payload = document.get("payload") or {}
resource_ids = re.findall(r"\d+x\d+", str(payload.get("imageattachmentids") or ""))
for resource_id in resource_ids:
stats["resources"] += 1
jobs.append(archive_resource(document, payload, resource_id))
if jobs:
await asyncio.gather(*jobs)
return stats
async def _fetch_module(module: str, modified_after=None) -> list[dict[str, Any]]:
service = get_vtiger_service()
records: list[dict[str, Any]] = []
seen_ids: set[str] = set()
offset = 0
batch_size = MODULE_BATCH_SIZES.get(module, 100)
while True:
clauses = []
if modified_after:
clauses.append(f"modifiedtime >= '{modified_after.isoformat()}'")
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
# This vTiger deployment paginates reliably with LIMIT offset,count.
# ID-watermarks skip records in modules whose entity IDs are compared as text.
query = f"SELECT * FROM {module}{where} ORDER BY id ASC LIMIT {offset}, {batch_size};"
batch = []
for attempt in range(8):
batch = await service.query(query)
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not batch:
if service.last_query_error or (service.last_query_status not in (None, 200)):
raise RuntimeError(f"vTiger kunne ikke hente {module}: {service.last_query_error or service.last_query_status}")
break
fresh = [row for row in batch if str(row.get("id") or "") not in seen_ids]
if not fresh:
break
records.extend(fresh)
seen_ids.update(str(row.get("id") or "") for row in fresh)
# Payload-heavy modules may return fewer than the requested 100 records
# due to a response-size cap even though later offsets still exist.
offset += len(batch)
await asyncio.sleep(0.35)
return records
async def _source_module_count(module: str) -> int:
service = get_vtiger_service()
rows = []
for attempt in range(8):
rows = await service.query(f"SELECT count(*) FROM {module};")
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not rows or rows[0].get("count") is None:
raise RuntimeError(
f"vTiger kunne ikke kontrollere antal poster i {module}: "
f"{service.last_query_error or service.last_query_status}"
)
return int(rows[0]["count"])
async def _close_relation_targets(version_id: int) -> dict[str, int]:
"""Fetch referenced records omitted from normal module lists (notably inactive users)."""
unresolved = execute_query(
"""SELECT DISTINCT r.target_vtiger_id
FROM vtiger_archive_relations r
WHERE r.version_id <= %s AND NOT EXISTS (
SELECT 1 FROM vtiger_archive_records t
WHERE t.vtiger_id=r.target_vtiger_id AND t.version_id <= %s
) ORDER BY r.target_vtiger_id""", (version_id, version_id),
) or []
prefix_rows = execute_query(
"""SELECT split_part(vtiger_id,'x',1) AS prefix,MIN(module) AS module
FROM vtiger_archive_records GROUP BY 1"""
) or []
module_by_prefix = {str(row["prefix"]): str(row["module"]) for row in prefix_rows}
module_by_prefix.update({"19": "Users", "21": "Currency", "53": "Roles"})
service = get_vtiger_service()
stats = {"requested": 0, "archived": 0, "not_found": 0, "errors": 0}
for item in unresolved:
target_id = str(item.get("target_vtiger_id") or "")
if not re.fullmatch(r"\d+x\d+", target_id):
continue
module = module_by_prefix.get(target_id.split("x", 1)[0])
if not module:
continue
stats["requested"] += 1
rows = []
for attempt in range(8):
rows = await service.query(f"SELECT * FROM {module} WHERE id='{target_id}' LIMIT 1;")
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if rows:
stats["archived"] += int(archive_record(version_id, module, rows[0]))
elif _transient_vtiger_failure(service):
stats["errors"] += 1
else:
stats["not_found"] += 1
await asyncio.sleep(0.35)
return stats
async def run_archive_sync(sync_kind: str, initiated_by: int | None = None) -> dict[str, Any]:
if sync_kind not in {"full", "incremental", "final"}:
raise ValueError("sync_kind skal være full, incremental eller final")
running = execute_query_single(
"""SELECT id,started_at FROM vtiger_archive_versions
WHERE status='running' ORDER BY id DESC LIMIT 1"""
)
if running:
age = datetime.now(timezone.utc) - running["started_at"]
if age.total_seconds() < 12 * 3600:
raise RuntimeError(f"Arkivsync version {running['id']} kører allerede")
execute_query(
"""UPDATE vtiger_archive_versions SET status='failed',completed_at=NOW(),
critical_errors='[{"error":"Stale sync blev lukket automatisk"}]'::jsonb WHERE id=%s""",
(running["id"],), fetch=False,
)
version_id = int(execute_insert(
"""INSERT INTO vtiger_archive_versions(sync_kind,initiated_by,source_cutoff)
VALUES (%s,%s,NOW()) RETURNING id""", (sync_kind, initiated_by),
))
counts: dict[str, Any] = {}
errors: list[dict[str, str]] = []
try:
for module in ARCHIVE_MODULES:
await asyncio.sleep(0.45)
checkpoint = execute_query_single(
"SELECT last_modified_at FROM vtiger_archive_checkpoints WHERE module=%s", (module,),
)
watermark = checkpoint.get("last_modified_at") if checkpoint and sync_kind == "incremental" else None
try:
source_total = await _source_module_count(module) if sync_kind in {"full", "final"} else None
records = await _fetch_module(module, watermark)
inserted = sum(1 for record in records if archive_record(version_id, module, record))
counts[module] = {"fetched": len(records), "new_revisions": inserted}
if source_total is not None:
counts[module]["source_total"] = source_total
if len(records) != source_total:
raise RuntimeError(f"Paritetsfejl: vTiger={source_total}, hentet={len(records)}")
source_times = [parsed for parsed in (_parse_source_time(r.get("modifiedtime")) for r in records) if parsed]
newest = max(source_times, default=None)
execute_query(
"""INSERT INTO vtiger_archive_checkpoints(module,last_modified_at,last_vtiger_id,last_successful_version_id)
VALUES (%s,%s,%s,%s) ON CONFLICT(module) DO UPDATE SET
last_modified_at=COALESCE(EXCLUDED.last_modified_at,vtiger_archive_checkpoints.last_modified_at),
last_vtiger_id=EXCLUDED.last_vtiger_id,last_successful_version_id=EXCLUDED.last_successful_version_id,
updated_at=NOW()""",
(module, newest, str(records[-1].get("id")) if records else None, version_id), fetch=False,
)
except Exception as exc:
logger.exception("Project CT sync failed for %s", module)
errors.append({"module": module, "error": str(exc)})
document_files = await archive_document_files(version_id) if sync_kind in {"full", "final"} else {
"entities": 0, "resources": 0, "archived": 0, "unchanged": 0, "errors": 0,
}
if document_files.get("errors"):
errors.append({
"module": "DocumentFiles",
"error": f"{document_files['errors']} dokumentfiler kunne ikke arkiveres",
})
relation_backfill = await _close_relation_targets(version_id)
if relation_backfill.get("errors"):
errors.append({
"module": "relations",
"error": f"{relation_backfill['errors']} relationer kunne ikke kontrolleres mod vTiger",
})
archived_counts = execute_query(
"""SELECT module,COUNT(*) AS records FROM (
SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,is_deleted
FROM vtiger_archive_records WHERE version_id <= %s
ORDER BY module,vtiger_id,revision_no DESC
) snapshot WHERE is_deleted=false GROUP BY module ORDER BY module""", (version_id,),
) or []
unresolved = execute_query_single(
"""SELECT COUNT(*)::integer AS count FROM vtiger_archive_relations r
WHERE r.version_id <= %s AND NOT EXISTS (
SELECT 1 FROM vtiger_archive_records t
WHERE t.vtiger_id=r.target_vtiger_id AND t.version_id <= %s
)""", (version_id, version_id),
) or {"count": 0}
document_quality = execute_query_single(
"""SELECT COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE COALESCE(payload->>'filename',payload->>'notes_title','')='')::integer AS missing_name
FROM (SELECT DISTINCT ON (vtiger_id) vtiger_id,payload,is_deleted
FROM vtiger_archive_records WHERE module='Documents' AND version_id <= %s
ORDER BY vtiger_id,revision_no DESC) d WHERE is_deleted=false""", (version_id,),
) or {"total": 0, "missing_name": 0}
report = {
"modules_expected": len(ARCHIVE_MODULES), "modules_completed": len(counts),
"missing_modules": [module for module in ARCHIVE_MODULES if module not in counts],
"module_sync": counts, "archived_snapshot_counts": archived_counts,
"relations": {"unresolved": int(unresolved.get("count") or 0)},
"relation_backfill": relation_backfill,
"documents": {**document_quality, "files": document_files}, "critical_errors": errors,
}
status = "failed" if errors else "completed"
execute_query(
"""UPDATE vtiger_archive_versions SET status=%s,completed_at=NOW(),module_counts=%s::jsonb,
critical_errors=%s::jsonb,control_report=%s::jsonb WHERE id=%s""",
(status, json.dumps(counts), json.dumps(errors), json.dumps(report), version_id), fetch=False,
)
return {"version_id": version_id, "status": status, "counts": counts, "critical_errors": errors}
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_versions SET status='failed',completed_at=NOW(),critical_errors=%s::jsonb WHERE id=%s",
(json.dumps([{"error": str(exc)}]), version_id), fetch=False,
)
raise
def termination_readiness() -> dict[str, Any]:
final = execute_query_single(
"""SELECT id,status,completed_at,critical_errors,control_approved_at
FROM vtiger_archive_versions WHERE sync_kind='final' ORDER BY id DESC LIMIT 1"""
)
reasons = []
if not final or final.get("status") != "completed":
reasons.append("Final sync mangler eller er ikke fuldført")
if final and final.get("critical_errors"):
reasons.append("Final sync har kritiske fejl")
if not final or not final.get("control_approved_at"):
reasons.append("Kontrolrapporten er ikke godkendt")
return {"ready": not reasons, "reasons": reasons, "final_version": final}

View File

@ -16,6 +16,7 @@ from app.services.economic_service import get_economic_service
from app.services.ollama_service import ollama_service from app.services.ollama_service import ollama_service
from app.services.template_service import template_service from app.services.template_service import template_service
from app.services.invoice2data_service import get_invoice2data_service from app.services.invoice2data_service import get_invoice2data_service
from app.modules.internet_connections.backend.change_case_service import ensure_external_change_case
import logging import logging
import os import os
import re import re
@ -26,15 +27,11 @@ router = APIRouter()
_PURCHASE_CASE_TYPE = "indkøb" _PURCHASE_CASE_TYPE = "indkøb"
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = { _INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
"address", "address", "service_address", "monthly_cost", "sales_price", "technology",
"service_address", "connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
"monthly_cost", "speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
"technology", "sla_price", "sla_status", "cidr", "contract_number", "range_added", "range_removed",
"connection_type", "range_monthly_cost", "range_sales_price",
"circuit_number",
"speed_mbps",
"download_mbps",
"upload_mbps",
} }
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist") SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
@ -267,90 +264,19 @@ def _ensure_internet_change_case(
owner_customer_id: Optional[int], owner_customer_id: Optional[int],
changes: Dict[str, Dict[str, object]], changes: Dict[str, Dict[str, object]],
) -> Optional[int]: ) -> Optional[int]:
# Customer ownership, initial activation and internal classification are outcome = ensure_external_change_case(
# bookkeeping outcomes of a successful import, not operational incidents. connection_id=connection_id,
# Only create cases for changes that can affect delivery or billing. source_type="globalconnect_invoice",
relevant_changes = { source_key=str(invoice_number),
field: change source_label=f"GlobalConnect faktura {invoice_number}",
for field, change in (changes or {}).items() source_url="/billing/supplier-invoices",
if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS reference=reference,
} connection_name=connection_name,
if not relevant_changes: provider="GlobalConnect",
return None owner_customer_id=owner_customer_id,
changes=changes,
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
existing = execute_query_single(
"""
SELECT id
FROM sag_sager
WHERE deleted_at IS NULL
AND titel = %s
ORDER BY id DESC
LIMIT 1
""",
(title,),
) )
if existing: return outcome.get("case_id")
return int(existing["id"])
assigned_group_id = _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economic"])
try:
case_customer_id = owner_customer_id or _resolve_procurement_customer_id()
except Exception as exc:
logger.warning(
"Skipping automatic internet change case for connection %s on invoice %s: could not resolve case customer (%s)",
connection_id,
invoice_number,
exc,
)
return None
change_lines = "\n".join(
f"- {field}: {change.get('from')} -> {change.get('to')}"
for field, change in relevant_changes.items()
)
description = (
"Automatisk oprettet ved import af internetfaktura.\n"
f"Forbindelse: {connection_name}\n"
f"Reference: {reference or '-'}\n"
f"Faktura: {invoice_number}\n"
"Registrerede ændringer:\n"
f"{change_lines}"
)
try:
try:
row = execute_query_single(
"""
INSERT INTO sag_sager (
titel, beskrivelse, type, status, customer_id, assigned_group_id, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(title, description, _PURCHASE_CASE_TYPE, "åben", case_customer_id, assigned_group_id, 1),
)
except Exception as insert_error:
if 'column "type"' not in str(insert_error):
raise
row = execute_query_single(
"""
INSERT INTO sag_sager (
titel, beskrivelse, status, customer_id, assigned_group_id, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(title, description, "åben", case_customer_id, assigned_group_id, 1),
)
except Exception as exc:
logger.warning(
"Skipping automatic internet change case for connection %s on invoice %s: %s",
connection_id,
invoice_number,
exc,
)
return None
return int(row["id"]) if row and row.get("id") else None
def _ensure_case_for_supplier_invoice( def _ensure_case_for_supplier_invoice(
@ -1573,12 +1499,16 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
{"cidr": cidr, "changes": changes}, {"cidr": cidr, "changes": changes},
) )
reference = display_reference or cidr reference = display_reference or cidr
connection_owner = execute_query_single(
"SELECT customer_id FROM internet_connections_connections WHERE id=%s",
(connection_id,),
) or {}
_ensure_internet_change_case( _ensure_internet_change_case(
connection_id=connection_id, connection_id=connection_id,
invoice_number=invoice_number, invoice_number=invoice_number,
reference=reference, reference=reference,
connection_name=f"IP-range {cidr}", connection_name=f"IP-range {cidr}",
owner_customer_id=matched_customer["id"] if matched_customer else None, owner_customer_id=connection_owner.get("customer_id"),
changes=changes, changes=changes,
) )
return range_id return range_id
@ -1623,6 +1553,28 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
f"IP-range {cidr} oprettet fra faktura {invoice_number}", f"IP-range {cidr} oprettet fra faktura {invoice_number}",
{"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]}, {"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]},
) )
# A range added to an existing connection is a commercial change. When the
# connection itself was created by this invoice, it is merely initial data.
created_with_invoice = execute_query_single(
"""SELECT 1 FROM internet_connections_history
WHERE connection_id=%s AND event_type='connection_created_from_supplier_invoice'
AND details->>'invoice_number'=%s LIMIT 1""",
(connection_id, invoice_number),
)
if not created_with_invoice:
connection = execute_query_single(
"""SELECT name, circuit_number, customer_id, provider
FROM internet_connections_connections WHERE id=%s""",
(connection_id,),
) or {}
_ensure_internet_change_case(
connection_id=connection_id,
invoice_number=invoice_number,
reference=str(connection.get("circuit_number") or display_reference or cidr),
connection_name=str(connection.get("name") or f"IP-range {cidr}"),
owner_customer_id=connection.get("customer_id"),
changes={"range_added": {"from": None, "to": cidr}},
)
return range_id return range_id
@ -1998,6 +1950,22 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
connection_groups=len(grouped_connections), connection_groups=len(grouped_connections),
ip_range_candidates=len(ip_range_lines), ip_range_candidates=len(ip_range_lines),
) )
case_creation_errors = []
if not simulate:
try:
case_creation_errors = execute_query(
"""SELECT connection_id, last_error AS error
FROM internet_connection_change_cases
WHERE source_type='globalconnect_invoice' AND source_key=%s
AND last_error IS NOT NULL
ORDER BY connection_id""",
(str(invoice_number),),
) or []
except Exception as exc:
# The invoice still completes during staggered migration rollout.
logger.warning("Could not load internet change-case control report: %s", exc)
verification["change_case_errors"] = case_creation_errors
verification["requires_manual_review"] = bool(case_creation_errors) or bool(verification.get("requires_manual_review"))
return { return {
"skipped": False, "skipped": False,
@ -2012,6 +1980,7 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
"line_audit": line_audit, "line_audit": line_audit,
"skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"], "skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"],
"verification": verification, "verification": verification,
"change_case_errors": case_creation_errors,
} }

View File

@ -225,6 +225,8 @@ class Settings(BaseSettings):
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30 ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000 ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
PROJECT_CT_ARCHIVE_SYNC_ENABLED: bool = True
PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES: int = 360
# Backup System Configuration # Backup System Configuration
BACKUP_ENABLED: bool = True BACKUP_ENABLED: bool = True

View File

@ -0,0 +1,20 @@
"""Scheduled permanent Project CT vTiger archive sync."""
import logging
from app.admin.vtiger_archive import run_archive_sync
logger = logging.getLogger(__name__)
async def run_project_ct_archive_sync() -> None:
try:
result = await run_archive_sync("incremental")
logger.info("Project CT incremental archive sync completed: %s", result)
except RuntimeError as exc:
if "kører allerede" in str(exc):
logger.info("Project CT scheduled sync skipped: %s", exc)
return
logger.exception("Project CT scheduled archive sync failed")
except Exception:
logger.exception("Project CT scheduled archive sync failed")

View File

@ -3,7 +3,7 @@ Pydantic Models and Schemas
""" """
from enum import Enum from enum import Enum
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime, date from datetime import datetime, date

View File

@ -0,0 +1,168 @@
"""Create deduplicated procurement cases for externally detected internet changes."""
from __future__ import annotations
import json
import logging
from typing import Any, Mapping, Optional
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
RELEVANT_FIELDS = {
"address", "service_address", "monthly_cost", "sales_price", "technology",
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
"sla_price", "sla_status", "ip_range", "cidr", "contract_number", "range_added",
"range_removed", "range_monthly_cost", "range_sales_price",
}
FIELD_LABELS = {
"address": "Adresse", "service_address": "Serviceadresse", "monthly_cost": "Indkøbspris",
"sales_price": "Salgspris", "technology": "Teknologi", "connection_type": "Forbindelsestype",
"circuit_number": "Kredsløbsnummer", "provider_reference": "Leverandørreference",
"provider": "Leverandør", "vendor_id": "Leverandør", "speed_mbps": "Hastighed",
"download_mbps": "Download", "upload_mbps": "Upload", "status": "Status",
"sla_subscription_id": "SLA-aftale", "sla_price": "SLA-pris", "sla_status": "SLA-status",
"ip_range": "IP-range", "cidr": "IP-range", "contract_number": "Kontraktnummer",
"range_added": "Nyt IP-range", "range_removed": "Fjernet IP-range",
"range_monthly_cost": "IP-range indkøbspris", "range_sales_price": "IP-range salgspris",
}
def filter_relevant_changes(changes: Mapping[str, Any] | None) -> dict[str, dict[str, Any]]:
filtered: dict[str, dict[str, Any]] = {}
for field, raw in (changes or {}).items():
if field not in RELEVANT_FIELDS:
continue
change = raw if isinstance(raw, Mapping) else {"from": None, "to": raw}
before, after = change.get("from"), change.get("to")
if str(before or "").strip() == str(after or "").strip():
continue
filtered[field] = {"from": before, "to": after}
return filtered
def _procurement_customer_id() -> int:
configured = getattr(settings, "PROCUREMENT_CASE_CUSTOMER_ID", None)
if configured:
row = execute_query_single("SELECT id FROM customers WHERE id=%s AND is_active=true", (configured,))
if row:
return int(row["id"])
row = execute_query_single(
"""SELECT id FROM customers WHERE is_active=true AND LOWER(name) LIKE %s
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id LIMIT 1""",
("%bmc%", "%bmc networks%"),
)
if not row:
raise ValueError("BMC's interne indkøbskunde blev ikke fundet")
return int(row["id"])
def _economy_group_id() -> Optional[int]:
row = execute_query_single(
"""SELECT id FROM groups WHERE LOWER(name) LIKE ANY(%s)
ORDER BY id LIMIT 1""", (["%økonomi%", "%okonomi%", "%economic%"],),
)
return int(row["id"]) if row else None
def _render_description(
*, connection_id: int, connection_name: str, reference: str, provider: str,
source_label: str, source_url: Optional[str], changes: Mapping[str, Mapping[str, Any]],
) -> str:
lines = [
"Automatisk oprettet efter en ekstern ændring af en internetforbindelse.", "",
f"Forbindelse: {connection_name}", f"Reference: {reference or '-'}",
f"Leverandør: {provider or '-'}", f"Kilde: {source_label}",
f"Link til forbindelse: /economy/internet-connections/{connection_id}",
]
if source_url:
lines.append(f"Link til kilde: {source_url}")
lines.extend(["", "Registrerede ændringer:"])
for field, change in changes.items():
lines.append(f"- {FIELD_LABELS.get(field, field)}: {change.get('from')}{change.get('to')}")
return "\n".join(lines)
def ensure_external_change_case(
*, connection_id: int, source_type: str, source_key: str, source_label: str,
changes: Mapping[str, Any], connection_name: str = "Internetforbindelse",
reference: str = "", provider: str = "", owner_customer_id: Optional[int] = None,
source_url: Optional[str] = None,
) -> dict[str, Any]:
relevant = filter_relevant_changes(changes)
if not relevant:
return {"case_id": None, "created": False, "changes": {}, "error": None}
source_type = str(source_type or "external").strip().lower()
source_key = str(source_key or "").strip()
if not source_key:
return {"case_id": None, "created": False, "changes": relevant, "error": "Kilden mangler en stabil nøgle"}
try:
existing = execute_query_single(
"""SELECT id, sag_id, changes FROM internet_connection_change_cases
WHERE connection_id=%s AND source_type=%s AND source_key=%s""",
(connection_id, source_type, source_key),
)
merged = dict((existing or {}).get("changes") or {})
merged.update(relevant)
case_customer_id = int(owner_customer_id) if owner_customer_id else _procurement_customer_id()
title = f"Internetændring {reference or connection_name} · {source_label}"[:255]
description = _render_description(
connection_id=connection_id, connection_name=connection_name, reference=reference,
provider=provider, source_label=source_label, source_url=source_url, changes=merged,
)
case_id = int(existing["sag_id"]) if existing and existing.get("sag_id") else None
created = False
if case_id:
execute_query(
"UPDATE sag_sager SET beskrivelse=%s, updated_at=NOW() WHERE id=%s AND deleted_at IS NULL",
(description, case_id), fetch=False,
)
else:
row = execute_query_single(
"""INSERT INTO sag_sager
(titel, beskrivelse, type, status, customer_id, assigned_group_id, created_by_user_id)
VALUES (%s,%s,'indkøb','åben',%s,%s,1) RETURNING id""",
(title, description, case_customer_id, _economy_group_id()),
)
case_id = int(row["id"])
created = True
if existing:
execute_query(
"""UPDATE internet_connection_change_cases SET sag_id=%s, source_label=%s,
source_url=%s, changes=%s::jsonb, last_error=NULL, updated_at=NOW() WHERE id=%s""",
(case_id, source_label, source_url, json.dumps(merged, ensure_ascii=False), existing["id"]), fetch=False,
)
else:
execute_query(
"""INSERT INTO internet_connection_change_cases
(connection_id,source_type,source_key,source_label,source_url,sag_id,changes)
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""",
(connection_id, source_type, source_key, source_label, source_url, case_id,
json.dumps(merged, ensure_ascii=False)), fetch=False,
)
return {"case_id": case_id, "created": created, "changes": merged, "error": None}
except Exception as exc:
logger.warning("Could not create internet change case for connection %s: %s", connection_id, exc)
# Keep the import operational, but persist a visible control item whenever
# the audit table itself is available.
try:
execute_query(
"""INSERT INTO internet_connection_change_cases
(connection_id,source_type,source_key,source_label,source_url,changes,last_error)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s)
ON CONFLICT (connection_id,source_type,source_key) DO UPDATE
SET changes=internet_connection_change_cases.changes || EXCLUDED.changes,
last_error=EXCLUDED.last_error, updated_at=NOW()""",
(connection_id, source_type, source_key, source_label, source_url,
json.dumps(relevant, ensure_ascii=False), str(exc)),
fetch=False,
)
except Exception:
logger.exception("Could not persist failed internet change-case audit")
return {"case_id": None, "created": False, "changes": relevant, "error": str(exc)}

View File

@ -1,4 +1,5 @@
import ipaddress import ipaddress
import hashlib
import io import io
import logging import logging
import re import re
@ -26,6 +27,7 @@ from app.modules.internet_connections.backend.provisioning_utils import (
build_network_product_profile, build_network_product_profile,
summarize_subscription_network_requirements, summarize_subscription_network_requirements,
) )
from app.modules.internet_connections.backend.change_case_service import ensure_external_change_case
from app.services.ollama_service import ollama_service from app.services.ollama_service import ollama_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1862,15 +1864,21 @@ async def import_ip_nordic_connections(file: UploadFile = File(...), commit: boo
filename = str(file.filename or "") filename = str(file.filename or "")
if not filename.lower().endswith(".xlsx"): if not filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Vælg en .xlsx-fil fra IP Nordic") raise HTTPException(status_code=400, detail="Vælg en .xlsx-fil fra IP Nordic")
items = _parse_ip_nordic_xlsx(await file.read()) content = await file.read()
import_key = hashlib.sha256(content).hexdigest()
items = _parse_ip_nordic_xlsx(content)
created_count = 0 created_count = 0
updated_count = 0
skipped_count = 0 skipped_count = 0
change_case_ids: set[int] = set()
case_errors: List[Dict[str, Any]] = []
preview_items: List[Dict[str, Any]] = [] preview_items: List[Dict[str, Any]] = []
for item in items: for item in items:
existing = execute_query_single( existing = execute_query_single(
""" """
SELECT id, name, address SELECT id, name, address, customer_id, monthly_cost, sales_price,
provider, status, technology, connection_type, circuit_number
FROM internet_connections_connections FROM internet_connections_connections
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND LOWER(COALESCE(provider, '')) = LOWER(%s) AND LOWER(COALESCE(provider, '')) = LOWER(%s)
@ -1882,10 +1890,51 @@ async def import_ip_nordic_connections(file: UploadFile = File(...), commit: boo
""", """,
("IP Nordic", _normalize_service_location(item["address"])), ("IP Nordic", _normalize_service_location(item["address"])),
) )
action = "skip" if existing else "create" changes: Dict[str, Dict[str, Any]] = {}
if existing:
desired = {
"monthly_cost": item["monthly_cost"],
"sales_price": item["sales_price"],
}
for field, after in desired.items():
before = existing.get(field)
if Decimal(str(before or 0)) != Decimal(str(after or 0)):
changes[field] = {"from": before, "to": after}
action = "update" if changes else ("skip" if existing else "create")
connection_id = int(existing["id"]) if existing else None connection_id = int(existing["id"]) if existing else None
if commit and not existing: if commit and existing and changes:
execute_query(
"""UPDATE internet_connections_connections
SET monthly_cost=%s, sales_price=%s, updated_at=CURRENT_TIMESTAMP
WHERE id=%s""",
(item["monthly_cost"], item["sales_price"], connection_id),
fetch=False,
)
_create_history_entry(
connection_id,
"ip_nordic_import_changed",
f"Opdateret fra IP Nordic-filen {filename}",
{"source_file": filename, "import_key": import_key, "changes": changes},
)
outcome = ensure_external_change_case(
connection_id=connection_id,
source_type="ip_nordic_spreadsheet",
source_key=import_key,
source_label=f"IP Nordic import {filename}",
source_url="/economy/internet-connections",
changes=changes,
connection_name=str(existing.get("name") or f"IP Nordic · {item['address']}"),
reference=str(existing.get("circuit_number") or item["address"]),
provider="IP Nordic",
owner_customer_id=existing.get("customer_id"),
)
if outcome.get("case_id"):
change_case_ids.add(int(outcome["case_id"]))
if outcome.get("error"):
case_errors.append({"connection_id": connection_id, "address": item["address"], "error": outcome["error"]})
updated_count += 1
elif commit and not existing:
notes = ( notes = (
f"Importeret fra {filename}. Leverandørens firmanr.: {item['company_number']}. " f"Importeret fra {filename}. Leverandørens firmanr.: {item['company_number']}. "
f"Rapporteret firma: {item['reported_company']}. {item['line_count']} regnearkslinje(r) samlet. " f"Rapporteret firma: {item['reported_company']}. {item['line_count']} regnearkslinje(r) samlet. "
@ -1930,6 +1979,7 @@ async def import_ip_nordic_connections(file: UploadFile = File(...), commit: boo
"sales_price": float(item["sales_price"]), "sales_price": float(item["sales_price"]),
"monthly_cost": float(item["monthly_cost"]), "monthly_cost": float(item["monthly_cost"]),
"line_count": item["line_count"], "line_count": item["line_count"],
"changes": changes,
}) })
return { return {
@ -1938,9 +1988,14 @@ async def import_ip_nordic_connections(file: UploadFile = File(...), commit: boo
"items": preview_items, "items": preview_items,
"total": len(preview_items), "total": len(preview_items),
"create_count": sum(1 for item in preview_items if item["action"] == "create"), "create_count": sum(1 for item in preview_items if item["action"] == "create"),
"existing_count": sum(1 for item in preview_items if item["action"] == "skip"), "existing_count": sum(1 for item in preview_items if item["action"] in {"skip", "update"}),
"created_count": created_count, "created_count": created_count,
"updated_count": updated_count,
"skipped_count": skipped_count, "skipped_count": skipped_count,
"change_case_ids": sorted(change_case_ids),
"case_errors": case_errors,
"requires_manual_review": bool(case_errors),
"import_key": import_key,
"customer_auto_assignment": False, "customer_auto_assignment": False,
} }
@ -2182,6 +2237,27 @@ async def get_connection(connection_id: int):
return connection return connection
@router.get("/internet-connections/{connection_id:int}/cases", response_model=List[dict])
async def list_connection_cases(connection_id: int):
"""Direct case history only; this never infers or changes the connection's customer."""
if not execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,),
):
raise HTTPException(status_code=404, detail="Connection not found")
return execute_query(
"""SELECT s.id,s.titel,s.status,s.template_key,s.customer_id,s.updated_at,s.created_at,
c.name AS customer_name,
COALESCE(NULLIF(TRIM(u.full_name),''),u.username) AS responsible_name,
link.created_at AS linked_at
FROM sag_internet_connections link
JOIN sag_sager s ON s.id=link.sag_id AND s.deleted_at IS NULL
LEFT JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE link.connection_id=%s
ORDER BY s.updated_at DESC NULLS LAST,s.id DESC""", (connection_id,),
) or []
@router.get("/internet-connections/{connection_id:int}/cross-field-ports") @router.get("/internet-connections/{connection_id:int}/cross-field-ports")
async def get_connection_cross_field_ports(connection_id: int): async def get_connection_cross_field_ports(connection_id: int):
"""Find cross-field ports related by customer or service-location address.""" """Find cross-field ports related by customer or service-location address."""

View File

@ -325,12 +325,16 @@
<button class="btn btn-primary d-none" id="saveCoreDetailsBtn" type="button" onclick="saveCoreDetails()"> <button class="btn btn-primary d-none" id="saveCoreDetailsBtn" type="button" onclick="saveCoreDetails()">
<i class="bi bi-save me-1"></i>Gem ændringer <i class="bi bi-save me-1"></i>Gem ændringer
</button> </button>
<a class="btn btn-outline-primary" id="createCaseForConnectionBtn" href="/sag/new">
<i class="bi bi-plus-circle me-1"></i>Opret sag
</a>
</div> </div>
</div> </div>
<nav class="detail-section-nav" aria-label="Sektioner"> <nav class="detail-section-nav" aria-label="Sektioner">
<a href="#connection-core"><i class="bi bi-info-circle"></i>Grunddata</a> <a href="#connection-core"><i class="bi bi-info-circle"></i>Grunddata</a>
<a href="#connection-ip"><i class="bi bi-hdd-network"></i>IP-ranges og adresser</a> <a href="#connection-ip"><i class="bi bi-hdd-network"></i>IP-ranges og adresser</a>
<a href="#connection-pricing"><i class="bi bi-cash-coin"></i>Pris og kontrakt</a> <a href="#connection-pricing"><i class="bi bi-cash-coin"></i>Pris og kontrakt</a>
<a href="#connection-cases"><i class="bi bi-kanban"></i>Sager</a>
<a href="#connection-history"><i class="bi bi-clock-history"></i>Historik</a> <a href="#connection-history"><i class="bi bi-clock-history"></i>Historik</a>
</nav> </nav>
</div> </div>
@ -674,6 +678,14 @@
</div> </div>
</div> </div>
<div class="detail-panel p-4 mb-4" id="connection-cases">
<div class="d-flex justify-content-between align-items-center gap-2 mb-3">
<div><h5 class="mb-0">Sager på forbindelsen</h5><div class="small text-muted">Kun direkte tilknyttede sager.</div></div>
<a class="btn btn-sm btn-primary" id="createCaseForConnectionPanelBtn" href="/sag/new"><i class="bi bi-plus-lg me-1"></i>Opret sag</a>
</div>
<div id="connectionCasesList"></div>
</div>
<div class="detail-panel p-4" id="connection-history"> <div class="detail-panel p-4" id="connection-history">
<h5 class="mb-3">Historik</h5> <h5 class="mb-3">Historik</h5>
<div id="historyList"></div> <div id="historyList"></div>
@ -1531,6 +1543,7 @@
pricingHistoryResponse, pricingHistoryResponse,
contractsResponse, contractsResponse,
historyResponse, historyResponse,
casesResponse,
crossFieldPortsResponse, crossFieldPortsResponse,
] = await Promise.all([ ] = await Promise.all([
fetch(`/api/v1/internet-connections/${connectionId}`), fetch(`/api/v1/internet-connections/${connectionId}`),
@ -1541,6 +1554,7 @@
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`), fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
fetch('/api/v1/internet-connections/contracts'), fetch('/api/v1/internet-connections/contracts'),
fetch(`/api/v1/internet-connections/${connectionId}/history`), fetch(`/api/v1/internet-connections/${connectionId}/history`),
fetch(`/api/v1/internet-connections/${connectionId}/cases`),
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`), fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
]); ]);
@ -1558,12 +1572,14 @@
const pricingHistory = await safeJson(pricingHistoryResponse, []); const pricingHistory = await safeJson(pricingHistoryResponse, []);
const contracts = await safeJson(contractsResponse, []); const contracts = await safeJson(contractsResponse, []);
const history = await safeJson(historyResponse, []); const history = await safeJson(historyResponse, []);
const cases = await safeJson(casesResponse, []);
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} }); const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
const failedSections = [ const failedSections = [
[rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'], [rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'],
[pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'], [pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'],
[historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'], [historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'],
[casesResponse, 'sager'],
].filter(([response]) => !response.ok).map(([, label]) => label); ].filter(([response]) => !response.ok).map(([, label]) => label);
if (failedSections.length) { if (failedSections.length) {
const feedback = document.getElementById('detailSaveFeedback'); const feedback = document.getElementById('detailSaveFeedback');
@ -1585,10 +1601,27 @@
renderAddresses(currentAddresses); renderAddresses(currentAddresses);
renderPricing(pricing, pricingHistory); renderPricing(pricing, pricingHistory);
renderHistory(history); renderHistory(history);
renderConnectionCases(cases);
renderRelationGrid(connection); renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren); renderBmcnetChildren(connection, currentBmcnetChildren);
renderContractsOverview(contracts); renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts); renderCrossFieldPorts(crossFieldPorts);
const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`;
document.getElementById('createCaseForConnectionBtn').href = createCaseUrl;
document.getElementById('createCaseForConnectionPanelBtn').href = createCaseUrl;
}
function renderConnectionCases(cases) {
const list = document.getElementById('connectionCasesList');
if (!Array.isArray(cases) || !cases.length) {
list.innerHTML = '<div class="text-muted">Ingen sager er knyttet direkte til forbindelsen endnu.</div>';
return;
}
list.innerHTML = cases.map(item => `
<a class="d-flex justify-content-between align-items-center gap-3 border rounded-3 p-3 mb-2 text-decoration-none text-reset" href="/sag/${item.id}/v3">
<div><div class="fw-semibold">SAG-${item.id} · ${escapeHtml(item.titel || 'Uden titel')}</div><div class="small text-muted">${escapeHtml(item.customer_name || 'Ingen kunde')} · ${escapeHtml(item.responsible_name || 'Ikke tildelt')} · ændret ${formatDateTime(item.updated_at)}</div></div>
<span class="badge text-bg-light border">${escapeHtml(item.status || '-')}</span>
</a>`).join('');
} }
function renderCrossFieldPorts(payload) { function renderCrossFieldPorts(payload) {

View File

@ -1030,6 +1030,14 @@ async def create_sag(request: Request, data: dict):
if contact_id and contact_id not in contact_ids: if contact_id and contact_id not in contact_ids:
contact_ids.append(contact_id) contact_ids.append(contact_id)
telefoni_opkald_id = _coerce_optional_int(data.get("telefoni_opkald_id"), "telefoni_opkald_id") telefoni_opkald_id = _coerce_optional_int(data.get("telefoni_opkald_id"), "telefoni_opkald_id")
raw_connection_ids = data.get("internet_connection_ids") or []
if not isinstance(raw_connection_ids, list):
raise HTTPException(status_code=400, detail="internet_connection_ids skal være en liste")
internet_connection_ids = []
for raw_connection_id in raw_connection_ids:
connection_id = _coerce_optional_int(raw_connection_id, "internet_connection_id")
if connection_id and connection_id not in internet_connection_ids:
internet_connection_ids.append(connection_id)
if pipeline is not None and not isinstance(pipeline, dict): if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt") raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list): if not isinstance(order_items, list):
@ -1134,6 +1142,23 @@ async def create_sag(request: Request, data: dict):
(contact_id, data.get("customer_id")), (contact_id, data.get("customer_id")),
) )
if internet_connection_ids:
cursor.execute(
"""SELECT id FROM internet_connections_connections
WHERE id = ANY(%s) AND deleted_at IS NULL""",
(internet_connection_ids,),
)
existing_connection_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_connection_ids = [item for item in internet_connection_ids if item not in existing_connection_ids]
if missing_connection_ids:
raise HTTPException(status_code=400, detail=f"Internetforbindelsen findes ikke: {missing_connection_ids[0]}")
for connection_id in internet_connection_ids:
cursor.execute(
"""INSERT INTO sag_internet_connections (sag_id, connection_id, linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(result["id"], connection_id, current_user_id),
)
if telefoni_opkald_id: if telefoni_opkald_id:
cursor.execute( cursor.execute(
"""UPDATE telefoni_opkald """UPDATE telefoni_opkald
@ -1176,6 +1201,45 @@ async def create_sag(request: Request, data: dict):
logger.error("❌ Error creating case: %s", e) logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
@router.get("/sag/{sag_id}/internet-connections")
async def list_sag_internet_connections(sag_id: int):
return execute_query(
"""SELECT link.connection_id,link.created_at,ic.name,ic.circuit_number,ic.provider_reference,
ic.address,ic.customer_id
FROM sag_internet_connections link
JOIN internet_connections_connections ic ON ic.id=link.connection_id AND ic.deleted_at IS NULL
WHERE link.sag_id=%s ORDER BY link.created_at DESC""", (sag_id,),
) or []
@router.post("/sag/{sag_id}/internet-connections", dependencies=[Depends(case_edit_access)])
async def link_sag_internet_connection(sag_id: int, request: Request, data: dict):
connection_id = _coerce_optional_int(data.get("connection_id"), "connection_id")
if not connection_id:
raise HTTPException(status_code=400, detail="connection_id er påkrævet")
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")
if not execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,),
):
raise HTTPException(status_code=404, detail="Internetforbindelsen findes ikke")
execute_query(
"""INSERT INTO sag_internet_connections (sag_id,connection_id,linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(sag_id, connection_id, _get_user_id_from_request(request)), fetch=False,
)
return {"linked": True, "sag_id": sag_id, "connection_id": connection_id}
@router.delete("/sag/{sag_id}/internet-connections/{connection_id}", dependencies=[Depends(case_edit_access)])
async def unlink_sag_internet_connection(sag_id: int, connection_id: int):
execute_query(
"DELETE FROM sag_internet_connections WHERE sag_id=%s AND connection_id=%s",
(sag_id, connection_id), fetch=False,
)
return {"unlinked": True}
@router.get("/sag/{sag_id:int}") @router.get("/sag/{sag_id:int}")
async def get_sag(sag_id: int): async def get_sag(sag_id: int):
"""Get a specific case.""" """Get a specific case."""

View File

@ -1,5 +1,6 @@
import json import json
import logging import logging
import re
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
@ -7,6 +8,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.core.auth_dependencies import get_current_user, require_any_permission from app.core.auth_dependencies import get_current_user, require_any_permission
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
from app.services.case_analysis_service import CaseAnalysisService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -16,6 +18,13 @@ 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"} 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"} 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]: def _user_id(current_user: dict) -> Optional[int]:
value = current_user.get("id") or current_user.get("user_id") value = current_user.get("id") or current_user.get("user_id")
@ -58,6 +67,43 @@ def _normalize_payload(data: dict) -> dict:
return data 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: 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} 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) snapshot = dict(solution)
@ -70,15 +116,47 @@ def _version_solution(solution: dict, user_id: Optional[int], change_note: Optio
) )
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]) @router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)): 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", (sag_id,)) 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 return result[0] if result else None
@router.get("/sag/{sag_id}/solution/versions") @router.get("/sag/{sag_id}/solution/versions")
async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)): 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", (sag_id,)) solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
if not solution: if not solution:
return {"items": [], "total": 0} return {"items": [], "total": 0}
items = execute_query( items = execute_query(
@ -95,8 +173,10 @@ async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_c
async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)): 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,)): 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") raise HTTPException(status_code=404, detail="Sagen findes ikke")
if execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s", (sag_id,)): existing = execute_query_single("SELECT id, deleted_at FROM sag_solutions WHERE sag_id=%s", (sag_id,))
raise HTTPException(status_code=409, detail="Der findes allerede en løsning på sagen") 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"})) data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"}))
user_id = _user_id(current_user) user_id = _user_id(current_user)
result = execute_query( result = execute_query(
@ -113,7 +193,7 @@ async def create_solution(sag_id: int, solution: SolutionCreate, current_user: d
@router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)]) @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)): 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", (sag_id,)): 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") raise HTTPException(status_code=404, detail="Løsningen findes ikke")
data = _normalize_payload(updates.model_dump(exclude_unset=True)) data = _normalize_payload(updates.model_dump(exclude_unset=True))
change_note = data.pop("change_note", None) change_note = data.pop("change_note", None)
@ -137,7 +217,7 @@ async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: di
@router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)]) @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)): 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() action = str((await request.json()).get("action") or "").lower()
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s", (sag_id,)) solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
if not solution: if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke") raise HTTPException(status_code=404, detail="Løsningen findes ikke")
user_id = _user_id(current_user) user_id = _user_id(current_user)
@ -161,11 +241,15 @@ async def solution_workflow(sag_id: int, request: Request, current_user: dict =
@router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)]) @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)): 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", (sag_id,)) solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
if not solution: if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke") raise HTTPException(status_code=404, detail="Løsningen findes ikke")
if solution.get("approval_status") != "approved": if solution.get("approval_status") != "approved":
raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse") 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 customer_id = None
if solution.get("visibility") == "customer": 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,)) 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,))
@ -184,12 +268,197 @@ async def publish_solution(sag_id: int, current_user: dict = Depends(get_current
investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround, investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround,
visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published', 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, version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id,
reviewed_at=NOW(),updated_at=NOW() RETURNING *""", 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)), (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 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 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") @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)): 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() term = q.strip()

View File

@ -19,6 +19,12 @@ async def knowledge_index(request: Request):
return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request}) return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request})
@router.get("/solutions", response_class=HTMLResponse)
async def solutions_management(request: Request):
"""Central management surface for case solutions and their publication flow."""
return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request})
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse) @router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
async def knowledge_detail(request: Request, article_id: int): async def knowledge_detail(request: Request, article_id: int):
article = execute_query( article = execute_query(
@ -857,7 +863,7 @@ async def sag_detaljer(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,)) comments = execute_query(comments_query, (sag_id,))
# Fetch Solution # Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s" solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_res = execute_query(solution_query, (sag_id,)) solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None solution = solution_res[0] if solution_res else None
@ -1218,7 +1224,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,)) comments = execute_query(comments_query, (sag_id,))
# Fetch Solution # Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s" solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_res = execute_query(solution_query, (sag_id,)) solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None solution = solution_res[0] if solution_res else None

View File

@ -199,6 +199,7 @@
<input type="hidden" id="customer_id" name="customer_id"> <input type="hidden" id="customer_id" name="customer_id">
</div> </div>
</div> </div>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
@ -386,7 +387,7 @@
let customerContactsLoadToken = 0; let customerContactsLoadToken = 0;
let successAlertTimeout; let successAlertTimeout;
let orderLineCounter = 0; let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null }; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null, internetConnectionId: null };
let topAlertLoadToken = 0; let topAlertLoadToken = 0;
function escapeTopAlertHtml(value) { function escapeTopAlertHtml(value) {
@ -843,6 +844,7 @@
const callIdRaw = params.get('telefoni_opkald_id'); const callIdRaw = params.get('telefoni_opkald_id');
const customerIdRaw = params.get('customer_id'); const customerIdRaw = params.get('customer_id');
const descriptionRaw = params.get('description'); const descriptionRaw = params.get('description');
const internetConnectionIdRaw = params.get('internet_connection_id');
const contactId = contactIdRaw ? parseInt(contactIdRaw) : null; const contactId = contactIdRaw ? parseInt(contactIdRaw) : null;
const customerId = customerIdRaw ? parseInt(customerIdRaw) : null; const customerId = customerIdRaw ? parseInt(customerIdRaw) : null;
@ -851,6 +853,8 @@
telefoniPrefill.title = titleRaw ? String(titleRaw) : null; telefoniPrefill.title = titleRaw ? String(titleRaw) : null;
telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null; telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null;
telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null; telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null;
const internetConnectionId = internetConnectionIdRaw ? parseInt(internetConnectionIdRaw) : null;
telefoniPrefill.internetConnectionId = Number.isFinite(internetConnectionId) ? internetConnectionId : null;
} }
async function applyTelefoniPrefill() { async function applyTelefoniPrefill() {
@ -898,6 +902,23 @@
console.error('Telefoni prefill failed', e); console.error('Telefoni prefill failed', e);
} }
} }
if (telefoniPrefill.internetConnectionId) {
const panel = document.getElementById('internetConnectionPrefill');
try {
const res = await fetch(`/api/v1/internet-connections/${telefoniPrefill.internetConnectionId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Forbindelsen findes ikke');
const connection = await res.json();
const reference = connection.circuit_number || connection.provider_reference || `#${telefoniPrefill.internetConnectionId}`;
panel.innerHTML = `<i class="bi bi-router me-2"></i><strong>Internetforbindelse knyttes til sagen:</strong> ${escapeTopAlertHtml(connection.name || 'Internetforbindelse')} · ${escapeTopAlertHtml(reference)}${connection.address ? ` · ${escapeTopAlertHtml(connection.address)}` : ''}. <a href="/economy/internet-connections/${telefoniPrefill.internetConnectionId}" class="alert-link">Åbn forbindelse</a><div class="small mt-1">Kunden vælges stadig manuelt og ændres ikke på forbindelsen.</div>`;
panel.classList.remove('d-none');
const titleInput = document.getElementById('titel');
if (titleInput && !titleInput.value.trim()) titleInput.value = `Vedr. forbindelse ${reference}`;
} catch (error) {
panel.textContent = `Internetforbindelse #${telefoniPrefill.internetConnectionId} kunne ikke hentes.`;
panel.className = 'alert alert-warning mb-4';
}
}
} }
function removeContact(id) { function removeContact(id) {
@ -1346,6 +1367,9 @@
contact_ids: Object.keys(selectedContacts).map(id => parseInt(id)).filter(Number.isFinite), contact_ids: Object.keys(selectedContacts).map(id => parseInt(id)).filter(Number.isFinite),
telefoni_opkald_id: telefoniPrefill.callId ? parseInt(telefoniPrefill.callId) : null telefoni_opkald_id: telefoniPrefill.callId ? parseInt(telefoniPrefill.callId) : null
}; };
if (telefoniPrefill.internetConnectionId) {
data.internet_connection_ids = [telefoniPrefill.internetConnectionId];
}
if (data.type === 'pipeline') { if (data.type === 'pipeline') {
data.pipeline = { data.pipeline = {

View File

@ -8362,6 +8362,13 @@
<!-- Solution Tab --> <!-- Solution Tab -->
<div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}"> <div class="tab-pane fade" id="solution" role="tabpanel" tabindex="0" data-module="solution" data-has-content="{{ 'true' if solution or is_nextcloud else 'false' }}">
<div class="card mb-3 border-primary-subtle">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div><h6 class="mb-0 text-primary"><i class="bi bi-stars me-2"></i>AI og relateret viden</h6><div class="small text-muted">Forslag bygger kun på godkendte vidensartikler og gemmes aldrig automatisk.</div></div>
<button class="btn btn-sm btn-primary" id="solutionAiDraftBtn" onclick="generateSolutionAiDraft()"><i class="bi bi-magic me-1"></i>Lav løsningsudkast</button>
</div>
<div class="card-body"><div id="solutionKnowledgeSuggestions" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Finder relateret viden…</div></div>
</div>
<!-- Nextcloud Integration Box --> <!-- Nextcloud Integration Box -->
{% if is_nextcloud %} {% if is_nextcloud %}
<div class="card mb-3"> <div class="card mb-3">
@ -8420,6 +8427,7 @@
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-lightbulb me-2"></i>Løsning</h6> <h6 class="mb-0 text-primary"><i class="bi bi-lightbulb me-2"></i>Løsning</h6>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-outline-secondary" href="/solutions"><i class="bi bi-sliders me-1"></i>Alle løsninger</a>
<a class="btn btn-sm btn-outline-secondary" href="/knowledge"><i class="bi bi-journal-richtext me-1"></i>Vidensdatabase</a> <a class="btn btn-sm btn-outline-secondary" href="/knowledge"><i class="bi bi-journal-richtext me-1"></i>Vidensdatabase</a>
{% if solution %}<button class="btn btn-sm btn-outline-primary" onclick="showCreateSolutionModal(true)"><i class="bi bi-pencil me-1"></i>Rediger</button>{% endif %} {% if solution %}<button class="btn btn-sm btn-outline-primary" onclick="showCreateSolutionModal(true)"><i class="bi bi-pencil me-1"></i>Rediger</button>{% endif %}
</div> </div>
@ -10568,17 +10576,20 @@
async function registerSolutionFromComment(content) { async function registerSolutionFromComment(content) {
const firstLine = content.split('\n').find((line) => line.trim()) || ''; const firstLine = content.split('\n').find((line) => line.trim()) || '';
const title = firstLine.slice(0, 120) || 'Løsning fra kommentar'; const title = firstLine.slice(0, 120) || 'Løsning fra kommentar';
const hasExistingSolution = Boolean(typeof existingCaseSolution !== 'undefined' && existingCaseSolution);
const existingDescription = hasExistingSolution ? String(existingCaseSolution.description || '').trim() : '';
const response = await fetch('/api/v1/sag/{{ case.id }}/solution', { const response = await fetch('/api/v1/sag/{{ case.id }}/solution', {
method: 'POST', method: hasExistingSolution ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
sag_id: {{ case.id }}, sag_id: {{ case.id }},
title, title: hasExistingSolution ? existingCaseSolution.title : title,
solution_type: 'Support', solution_type: 'Support',
result: 'Løst', result: 'Løst',
description: content, description: existingDescription ? `${existingDescription}\n\nSupplerende løsning:\n${content}` : content,
created_by_user_id: 1 approval_status: 'draft',
change_note: hasExistingSolution ? 'Suppleret fra kommentar' : 'Oprettet fra kommentar'
}) })
}); });
@ -13752,6 +13763,7 @@
<div class="modal-body"> <div class="modal-body">
<form id="solutionForm"> <form id="solutionForm">
<input type="hidden" id="sol_sag_id" value="{{ case.id }}"> <input type="hidden" id="sol_sag_id" value="{{ case.id }}">
<div class="alert alert-info d-none" id="solutionAiReview"><div class="fw-semibold mb-1"><i class="bi bi-stars me-1"></i>AI-udkast kræver kontrol</div><div id="solutionAiReviewText" class="small"></div></div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Titel *</label> <label class="form-label">Titel *</label>
<input type="text" class="form-control" id="sol_title" required> <input type="text" class="form-control" id="sol_title" required>
@ -13796,6 +13808,11 @@
</div> </div>
<div class="mb-3"><label class="form-label">Ændringsnote</label><input class="form-control" id="sol_change_note" placeholder="Hvad er ændret? (vises i historikken)"></div> <div class="mb-3"><label class="form-label">Ændringsnote</label><input class="form-control" id="sol_change_note" placeholder="Hvad er ændret? (vises i historikken)"></div>
<div class="border-top pt-3"> <div class="border-top pt-3">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="sol_close_case">
<label class="form-check-label fw-semibold" for="sol_close_case"><i class="bi bi-check-circle me-1 text-success"></i>Luk sagen, når løsningen gemmes</label>
<div class="form-text">Supportsager uden registreret tid bruger den normale bekræftelsesadvarsel.</div>
</div>
<div class="form-check mb-3"> <div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="sol_add_time"> <input class="form-check-input" type="checkbox" id="sol_add_time">
<label class="form-check-label" for="sol_add_time"> <label class="form-check-label" for="sol_add_time">
@ -14214,6 +14231,7 @@
function showCreateSolutionModal(editExisting = false) { function showCreateSolutionModal(editExisting = false) {
editingCaseSolution = Boolean(editExisting && existingCaseSolution); editingCaseSolution = Boolean(editExisting && existingCaseSolution);
document.getElementById('solutionAiReview')?.classList.add('d-none');
document.getElementById('solutionModalTitle').textContent = editingCaseSolution ? 'Rediger løsning' : 'Opret løsning'; document.getElementById('solutionModalTitle').textContent = editingCaseSolution ? 'Rediger løsning' : 'Opret løsning';
const source = editingCaseSolution ? existingCaseSolution : {}; const source = editingCaseSolution ? existingCaseSolution : {};
document.getElementById('sol_title').value = source.title || ''; document.getElementById('sol_title').value = source.title || '';
@ -14228,6 +14246,7 @@
document.getElementById('sol_tags').value = solutionCsv(source.tags); document.getElementById('sol_tags').value = solutionCsv(source.tags);
document.getElementById('sol_products').value = solutionCsv(source.products); document.getElementById('sol_products').value = solutionCsv(source.products);
document.getElementById('sol_change_note').value = ''; document.getElementById('sol_change_note').value = '';
document.getElementById('sol_close_case').checked = false;
const addTimeCheckbox = document.getElementById('sol_add_time'); const addTimeCheckbox = document.getElementById('sol_add_time');
const timeFields = document.getElementById('sol_time_fields'); const timeFields = document.getElementById('sol_time_fields');
if (addTimeCheckbox && timeFields) { if (addTimeCheckbox && timeFields) {
@ -14254,6 +14273,57 @@
new bootstrap.Modal(document.getElementById('createSolutionModal')).show(); new bootstrap.Modal(document.getElementById('createSolutionModal')).show();
} }
async function loadSolutionKnowledgeSuggestions() {
const box = document.getElementById('solutionKnowledgeSuggestions');
if (!box) return;
try {
const res = await fetch('/api/v1/sag/{{ case.id }}/knowledge-suggestions', {credentials:'include'});
if (!res.ok) throw new Error('Kunne ikke hente forslag');
const data = await res.json();
if (!data.items.length) {
box.innerHTML = '<div class="text-center py-3"><i class="bi bi-journal-x d-block fs-3 opacity-50 mb-1"></i>Ingen godkendte vidensartikler matcher sagen endnu.</div>';
return;
}
box.innerHTML = `<div class="row g-2">${data.items.map(item => `
<div class="col-lg-6"><a href="/knowledge/${item.id}" class="d-block p-3 rounded-3 border text-decoration-none h-100" style="color:inherit">
<div class="d-flex justify-content-between gap-2"><strong>${escapeHtml(item.title)}</strong><span class="badge bg-primary-subtle text-primary-emphasis">${item.relevance_percent}%</span></div>
<div class="text-muted mt-1">${escapeHtml(item.summary || item.problem || 'Ingen kort beskrivelse')}</div>
<div class="mt-2"><span class="badge bg-light text-dark border">${item.visibility === 'customer' ? 'Samme kunde' : item.visibility === 'general' ? 'Generel' : 'Intern'}</span> <span class="text-muted">Kilde: sag ${item.sag_id}</span></div>
</a></div>`).join('')}</div>`;
} catch (error) {
box.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${escapeHtml(error.message)}</span>`;
}
}
async function generateSolutionAiDraft() {
const button = document.getElementById('solutionAiDraftBtn');
const oldHtml = button?.innerHTML;
if (button) { button.disabled = true; button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyserer sagen…'; }
try {
const res = await fetch('/api/v1/sag/{{ case.id }}/solution/ai-draft', {method:'POST', credentials:'include'});
if (!res.ok) { const e=await res.json().catch(()=>({})); throw new Error(typeof e.detail==='string'?e.detail:'AI-udkastet kunne ikke oprettes'); }
const data = await res.json(), draft = data.draft || {};
showCreateSolutionModal(Boolean(existingCaseSolution));
document.getElementById('sol_title').value = draft.title || '';
document.getElementById('sol_type').value = draft.solution_type || 'Support';
document.getElementById('sol_result').value = draft.result || 'Ej løst';
document.getElementById('sol_problem').value = draft.problem || '';
document.getElementById('sol_root_cause').value = draft.root_cause || '';
document.getElementById('sol_investigation').value = draft.investigation || '';
document.getElementById('sol_desc').value = draft.description || '';
document.getElementById('sol_workaround').value = draft.workaround || '';
document.getElementById('sol_tags').value = (draft.tags || []).join(', ');
document.getElementById('sol_products').value = (draft.products || []).join(', ');
document.getElementById('sol_change_note').value = 'AI-udkast gennemset og gemt';
const review=document.getElementById('solutionAiReview'), reviewText=document.getElementById('solutionAiReviewText');
const confidence=Math.round(Number(draft.confidence||0)*100), refs=(draft.source_refs||[]).map(escapeHtml).join(', ') || 'Ingen gyldige kilder';
const warnings=[...(data.warnings||[]),...(draft.uncertainties||[])];
reviewText.innerHTML=`Sikkerhed: <strong>${confidence}%</strong> · Kilder: ${refs}${warnings.length?`<div class="text-warning-emphasis mt-1"><strong>Kontrollér:</strong> ${warnings.map(escapeHtml).join(' · ')}</div>`:''}`;
review.classList.remove('d-none');
} catch (error) { showCaseFeedback(error.message, 'danger'); }
finally { if (button) { button.disabled=false; button.innerHTML=oldHtml; } }
}
function updateSolutionTimeTotal() { function updateSolutionTimeTotal() {
const h = parseInt(document.getElementById('sol_time_hours').value) || 0; const h = parseInt(document.getElementById('sol_time_hours').value) || 0;
const m = parseInt(document.getElementById('sol_time_minutes').value) || 0; const m = parseInt(document.getElementById('sol_time_minutes').value) || 0;
@ -14285,9 +14355,15 @@
change_note: document.getElementById('sol_change_note').value.trim() || undefined, change_note: document.getElementById('sol_change_note').value.trim() || undefined,
}; };
const addTime = document.getElementById('sol_add_time')?.checked; const addTime = document.getElementById('sol_add_time')?.checked;
const closeCase = document.getElementById('sol_close_case')?.checked;
const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0; const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0;
const timeMinutes = parseInt(document.getElementById('sol_time_minutes').value) || 0; const timeMinutes = parseInt(document.getElementById('sol_time_minutes').value) || 0;
const timeTotal = timeHours + (timeMinutes / 60); const timeTotal = timeHours + (timeMinutes / 60);
let closeWithoutTimeConfirmed = false;
if (closeCase && !caseHasRegisteredTime && !(addTime && timeTotal > 0)) {
closeWithoutTimeConfirmed = await confirmCaseClosureIfNeeded();
if (!closeWithoutTimeConfirmed) return;
}
try { try {
const res = await fetch(`/api/v1/sag/${data.sag_id}/solution`, { const res = await fetch(`/api/v1/sag/${data.sag_id}/solution`, {
@ -14296,6 +14372,7 @@
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
if (res.ok) { if (res.ok) {
let timeWasRegistered = false;
if (!editingCaseSolution && addTime && timeTotal > 0) { if (!editingCaseSolution && addTime && timeTotal > 0) {
const solution = await res.json(); const solution = await res.json();
const solPresetSelect = document.getElementById('sol_time_multiplier_preset'); const solPresetSelect = document.getElementById('sol_time_multiplier_preset');
@ -14322,6 +14399,22 @@
}); });
if (!timeRes.ok) { if (!timeRes.ok) {
showCaseFeedback('Løsning oprettet, men tid kunne ikke registreres', 'warning'); showCaseFeedback('Løsning oprettet, men tid kunne ikke registreres', 'warning');
} else {
timeWasRegistered = true;
}
}
if (closeCase) {
if (addTime && timeTotal > 0 && !editingCaseSolution && !timeWasRegistered) {
showCaseFeedback('Løsningen er gemt, men sagen blev ikke lukket, fordi tiden ikke kunne registreres', 'warning');
return;
}
const closeRes = await fetch(`/api/v1/sag/${data.sag_id}`, {
method: 'PATCH', headers: {'Content-Type':'application/json'},
body: JSON.stringify({status:'lukket', confirm_close_without_time: closeWithoutTimeConfirmed})
});
if (!closeRes.ok) {
showCaseFeedback('Løsningen er gemt, men sagen kunne ikke lukkes', 'warning');
return;
} }
} }
reloadCasePreservingContext(); reloadCasePreservingContext();
@ -14401,6 +14494,7 @@
renderQuickTimeMultiplierPresetOptions(); renderQuickTimeMultiplierPresetOptions();
renderSolutionTimeMultiplierPresetOptions(); renderSolutionTimeMultiplierPresetOptions();
}); });
loadSolutionKnowledgeSuggestions();
}); });
function bindTimeModalCalculations() { function bindTimeModalCalculations() {

View File

@ -14,7 +14,7 @@
<section class="kb-hero mb-4"> <section class="kb-hero mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 align-items-start"> <div class="d-flex flex-wrap justify-content-between gap-3 align-items-start">
<div><div class="small text-uppercase opacity-75 fw-semibold">BMC Viden</div><h1 class="h2 fw-bold mb-2">Find en gennemprøvet løsning</h1><p class="mb-0 opacity-75">Kun godkendte og udgivne løsninger vises her.</p></div> <div><div class="small text-uppercase opacity-75 fw-semibold">BMC Viden</div><h1 class="h2 fw-bold mb-2">Find en gennemprøvet løsning</h1><p class="mb-0 opacity-75">Kun godkendte og udgivne løsninger vises her.</p></div>
<a href="/sag" class="btn btn-light"><i class="bi bi-arrow-left me-1"></i>Sager</a> <div class="d-flex gap-2"><a href="/solutions" class="btn btn-light"><i class="bi bi-sliders me-1"></i>Administrer løsninger</a><a href="/sag" class="btn btn-outline-light"><i class="bi bi-arrow-left me-1"></i>Sager</a></div>
</div> </div>
<div class="position-relative mt-4"><i class="bi bi-search position-absolute top-50 translate-middle-y ms-3 fs-5 text-secondary"></i><input id="kbSearch" class="form-control kb-search" type="search" placeholder="Søg efter problem, fejlbesked, produkt eller løsning…" autocomplete="off"></div> <div class="position-relative mt-4"><i class="bi bi-search position-absolute top-50 translate-middle-y ms-3 fs-5 text-secondary"></i><input id="kbSearch" class="form-control kb-search" type="search" placeholder="Søg efter problem, fejlbesked, produkt eller løsning…" autocomplete="off"></div>
</section> </section>

View File

@ -0,0 +1,58 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Løsninger - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.sol-hero{background:linear-gradient(125deg,#12263f,#0f4c75 55%,#2563eb);color:#fff;border-radius:22px;padding:1.6rem 1.8rem;box-shadow:0 16px 40px rgba(15,76,117,.2)}
.sol-toolbar,.sol-card{background:var(--bg-card,#fff);border:1px solid rgba(15,76,117,.12);border-radius:16px}
.sol-card{transition:.16s ease}.sol-card:hover{border-color:rgba(37,99,235,.3);box-shadow:0 10px 24px rgba(15,76,117,.09)}
.sol-summary{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.sol-filter.active{background:#0f4c75;color:#fff;border-color:#0f4c75}.sol-chip{border-radius:999px;padding:.25rem .65rem;font-size:.75rem;font-weight:600}
.offcanvas.sol-editor{width:min(760px,100vw)}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<section class="sol-hero mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="small text-uppercase fw-semibold opacity-75">Sager · Viden</div><h1 class="h2 fw-bold mb-1">Løsninger</h1><p class="mb-0 opacity-75">Rediger, kvalitetssikr og udgiv sagernes løsninger.</p></div>
<div class="d-flex gap-2"><a href="/knowledge" class="btn btn-light"><i class="bi bi-journal-richtext me-1"></i>Vidensdatabase</a><a href="/sag" class="btn btn-outline-light"><i class="bi bi-folder2 me-1"></i>Sager</a></div>
</section>
<section class="sol-toolbar p-3 mb-4">
<div class="row g-3 align-items-center"><div class="col-xl-5"><div class="input-group"><span class="input-group-text bg-transparent"><i class="bi bi-search"></i></span><input id="solSearch" class="form-control" type="search" placeholder="Søg i titel, problem, løsning, sag eller kunde…"></div></div>
<div class="col-xl-7 d-flex flex-wrap gap-2 justify-content-xl-end"><button class="btn btn-sm btn-outline-secondary sol-filter active" data-status="">Alle</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="draft">Kladder</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="pending">Afventer</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="approved">Godkendte</button><button id="archivedToggle" class="btn btn-sm btn-outline-danger"><i class="bi bi-archive me-1"></i>Arkiverede</button></div></div>
</section>
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h5 mb-0" id="solHeading">Aktive løsninger</h2><span class="badge rounded-pill text-bg-light border" id="solCount">Henter…</span></div>
<div id="solState" class="text-center text-muted py-5"><div class="spinner-border spinner-border-sm me-2"></div>Henter løsninger…</div><div id="solGrid" class="row g-3"></div>
</div>
<div class="offcanvas offcanvas-end sol-editor" tabindex="-1" id="solutionEditor"><div class="offcanvas-header border-bottom"><div><div class="small text-uppercase text-primary fw-semibold">Løsning</div><h2 class="offcanvas-title h4 mb-0" id="editorHeading">Rediger løsning</h2></div><button class="btn-close" data-bs-dismiss="offcanvas"></button></div><div class="offcanvas-body">
<input type="hidden" id="editSagId"><div class="mb-3"><label class="form-label">Titel *</label><input class="form-control" id="editTitle"></div>
<div class="row g-3 mb-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select" id="editType"><option>Support</option><option>Drift</option><option>Konsulent</option><option>Infrastruktur</option><option>Ekstern</option></select></div><div class="col-md-4"><label class="form-label">Resultat</label><select class="form-select" id="editResult"><option>Løst</option><option>Delvist</option><option>Workaround</option><option>Ej løst</option></select></div><div class="col-md-4"><label class="form-label">Synlighed</label><select class="form-select" id="editVisibility"><option value="internal">Kun internt</option><option value="general">Generel viden</option><option value="customer">Kun denne kunde</option></select></div></div>
<div class="mb-3"><label class="form-label">Problem og symptomer</label><textarea class="form-control" id="editProblem" rows="3"></textarea></div>
<div class="row g-3 mb-3"><div class="col-md-6"><label class="form-label">Årsag</label><textarea class="form-control" id="editCause" rows="3"></textarea></div><div class="col-md-6"><label class="form-label">Undersøgelse</label><textarea class="form-control" id="editInvestigation" rows="3"></textarea></div></div>
<div class="mb-3"><label class="form-label">Endelig løsning *</label><textarea class="form-control" id="editDescription" rows="5"></textarea></div><div class="mb-3"><label class="form-label">Workaround</label><textarea class="form-control" id="editWorkaround" rows="2"></textarea></div>
<div class="row g-3 mb-3"><div class="col-md-6"><label class="form-label">Tags</label><input class="form-control" id="editTags"></div><div class="col-md-6"><label class="form-label">Produkter/systemer</label><input class="form-control" id="editProducts"></div></div><div class="mb-4"><label class="form-label">Ændringsnote</label><input class="form-control" id="editNote" placeholder="Hvad er ændret?"></div>
<div class="d-flex justify-content-between gap-2 border-top pt-3"><a id="editorCaseLink" class="btn btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag</a><button class="btn btn-primary" onclick="saveManagedSolution()"><i class="bi bi-check2 me-1"></i>Gem ændringer</button></div>
</div></div>
<div class="modal fade" id="archiveSolutionModal" tabindex="-1" data-bs-backdrop="static"><div class="modal-dialog modal-dialog-centered"><div class="modal-content border-0 shadow"><div class="modal-body p-4"><div class="d-flex gap-3"><div class="fs-2 text-danger"><i class="bi bi-archive"></i></div><div><h2 class="h4">Arkivér løsningen?</h2><p class="text-muted mb-2">Løsningen fjernes fra sagen og en udgivet vidensartikel skjules. Historik og data bevares.</p><div class="fw-semibold" id="archiveSolutionName"></div></div></div></div><div class="modal-footer border-0 pt-0"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-danger" id="archiveConfirmBtn">Ja, arkivér</button></div></div></div></div>
<script>
(() => {
const state={items:new Map(),status:'',archived:false,timer:null,archiveId:null}; const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const labels={draft:'Kladde',pending:'Afventer godkendelse',approved:'Godkendt',outdated:'Forældet',rejected:'Afvist'};
async function api(url,options={}){const r=await fetch(url,{credentials:'include',...options});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(typeof e.detail==='string'?e.detail:'Handlingen mislykkedes');}return r.json();}
async function load(){const box=document.getElementById('solState'),grid=document.getElementById('solGrid'),q=document.getElementById('solSearch').value.trim();box.classList.remove('d-none');box.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Henter…';grid.innerHTML='';
try{const d=await api(`/api/v1/solutions?q=${encodeURIComponent(q)}&approval_status=${encodeURIComponent(state.status)}&include_archived=${state.archived}`);state.items=new Map(d.items.map(x=>[x.id,x]));document.getElementById('solCount').textContent=`${d.total} løsninger`;document.getElementById('solHeading').textContent=state.archived?'Arkiverede løsninger':'Aktive løsninger';box.classList.toggle('d-none',d.items.length>0);if(!d.items.length)box.innerHTML='<i class="bi bi-lightbulb fs-1 d-block mb-2"></i>Ingen løsninger matcher.';
grid.innerHTML=d.items.map(s=>`<div class="col-xxl-4 col-lg-6"><article class="sol-card h-100 p-4"><div class="d-flex justify-content-between gap-2 mb-2"><div class="d-flex gap-1"><span class="sol-chip ${s.approval_status==='approved'?'bg-success-subtle text-success-emphasis':s.approval_status==='pending'?'bg-warning-subtle text-warning-emphasis':'bg-secondary-subtle text-secondary-emphasis'}">${labels[s.approval_status]||esc(s.approval_status)}</span><span class="sol-chip bg-light border">${s.visibility==='general'?'Generel':s.visibility==='customer'?'Kunde':'Intern'}</span></div><span class="small text-muted">#${s.sag_id}</span></div><h3 class="h5 fw-bold">${esc(s.title)}</h3><p class="sol-summary text-muted">${esc(s.problem||s.description||'Ingen beskrivelse')}</p><div class="small text-muted mb-3">${esc(s.customer_name||'Ingen kunde')} · ${esc(s.updated_by||s.created_by||'Ukendt')}</div><div class="d-flex flex-wrap gap-2"><a class="btn btn-sm btn-outline-secondary" href="/sag/${s.sag_id}/v3#solution">Sag</a>${state.archived?`<button class="btn btn-sm btn-success" onclick="restoreManagedSolution(${s.id})"><i class="bi bi-arrow-counterclockwise me-1"></i>Gendan</button>`:`<button class="btn btn-sm btn-outline-primary" onclick="openManagedSolution(${s.id})"><i class="bi bi-pencil me-1"></i>Rediger</button>${s.approval_status==='pending'?`<button class="btn btn-sm btn-success" onclick="solutionAction(${s.sag_id},'approve')">Godkend</button>`:''}${s.approval_status==='approved'?`<button class="btn btn-sm btn-primary" onclick="publishManagedSolution(${s.sag_id})">Udgiv</button>`:''}<button class="btn btn-sm btn-outline-danger ms-auto" onclick="askArchiveSolution(${s.id})" title="Arkivér"><i class="bi bi-archive"></i></button>`}</div></article></div>`).join('');
}catch(e){box.classList.remove('d-none');box.innerHTML=`<i class="bi bi-exclamation-triangle text-danger d-block fs-2"></i>${esc(e.message)}`;}}
window.openManagedSolution=id=>{const s=state.items.get(id);if(!s)return;document.getElementById('editSagId').value=s.sag_id;document.getElementById('editorHeading').textContent=s.title;document.getElementById('editTitle').value=s.title||'';document.getElementById('editType').value=s.solution_type||'Support';document.getElementById('editResult').value=s.result||'Løst';document.getElementById('editVisibility').value=s.visibility||'internal';document.getElementById('editProblem').value=s.problem||'';document.getElementById('editCause').value=s.root_cause||'';document.getElementById('editInvestigation').value=s.investigation||'';document.getElementById('editDescription').value=s.description||'';document.getElementById('editWorkaround').value=s.workaround||'';document.getElementById('editTags').value=(s.tags||[]).join(', ');document.getElementById('editProducts').value=(s.products||[]).join(', ');document.getElementById('editNote').value='';document.getElementById('editorCaseLink').href=`/sag/${s.sag_id}/v3#solution`;bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('solutionEditor')).show();};
window.saveManagedSolution=async()=>{const id=document.getElementById('editSagId').value,payload={title:document.getElementById('editTitle').value.trim(),solution_type:document.getElementById('editType').value,result:document.getElementById('editResult').value,visibility:document.getElementById('editVisibility').value,problem:document.getElementById('editProblem').value.trim(),root_cause:document.getElementById('editCause').value.trim(),investigation:document.getElementById('editInvestigation').value.trim(),description:document.getElementById('editDescription').value.trim(),workaround:document.getElementById('editWorkaround').value.trim(),tags:document.getElementById('editTags').value.split(',').map(x=>x.trim()).filter(Boolean),products:document.getElementById('editProducts').value.split(',').map(x=>x.trim()).filter(Boolean),change_note:document.getElementById('editNote').value.trim()||'Redigeret fra Løsninger'};if(!payload.title||!payload.description){alert('Titel og endelig løsning skal udfyldes');return;}try{await api(`/api/v1/sag/${id}/solution`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});bootstrap.Offcanvas.getInstance(document.getElementById('solutionEditor'))?.hide();load();}catch(e){alert(e.message);}};
window.solutionAction=async(sag,action)=>{try{await api(`/api/v1/sag/${sag}/solution/workflow`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});load();}catch(e){alert(e.message);}};
window.publishManagedSolution=async sag=>{try{const a=await api(`/api/v1/sag/${sag}/solution/publish`,{method:'POST'});location.href=`/knowledge/${a.id}`;}catch(e){alert(e.message);}};
window.askArchiveSolution=id=>{state.archiveId=id;document.getElementById('archiveSolutionName').textContent=state.items.get(id)?.title||'';bootstrap.Modal.getOrCreateInstance(document.getElementById('archiveSolutionModal')).show();};
document.getElementById('archiveConfirmBtn').addEventListener('click',async()=>{try{await api(`/api/v1/solutions/${state.archiveId}`,{method:'DELETE'});bootstrap.Modal.getInstance(document.getElementById('archiveSolutionModal'))?.hide();load();}catch(e){alert(e.message);}});
window.restoreManagedSolution=async id=>{try{await api(`/api/v1/solutions/${id}/restore`,{method:'POST'});load();}catch(e){alert(e.message);}};
document.querySelectorAll('.sol-filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.sol-filter').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.status=b.dataset.status;load();}));document.getElementById('archivedToggle').addEventListener('click',e=>{state.archived=!state.archived;e.currentTarget.classList.toggle('btn-danger',state.archived);e.currentTarget.classList.toggle('btn-outline-danger',!state.archived);load();});document.getElementById('solSearch').addEventListener('input',()=>{clearTimeout(state.timer);state.timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -7,6 +7,7 @@ import json
import re import re
import html as html_lib import html as html_lib
import aiohttp import aiohttp
import base64
from decimal import Decimal from decimal import Decimal
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any
from app.core.config import settings from app.core.config import settings
@ -165,6 +166,40 @@ class VTigerService:
logger.error(f"❌ vTiger query error: {e}") logger.error(f"❌ vTiger query error: {e}")
return [] return []
async def retrieve_file(self, resource_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve the original bytes for a vTiger file resource."""
safe_id = self._sanitize_vtiger_id(resource_id)
if not safe_id or not self.rest_endpoint:
raise ValueError("Ugyldigt vTiger fil-id eller manglende VTIGER_URL")
self.last_query_status = None
self.last_query_error = None
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/files_retrieve",
params={"id": safe_id},
auth=self._get_auth(),
) as response:
self.last_query_status = response.status
data = await response.json(content_type=None)
if response.status != 200 or not data.get("success"):
self.last_query_error = data.get("error") or {"message": f"HTTP {response.status}"}
return None
result = data.get("result") or {}
if isinstance(result, list):
result = result[0] if result else {}
encoded = result.get("filecontents") or ""
try:
content = base64.b64decode(encoded, validate=True)
except Exception as exc:
self.last_query_error = {"message": "Ugyldigt base64-filindhold"}
raise ValueError("vTiger returnerede ugyldigt filindhold") from exc
return {**result, "content": content}
except Exception as exc:
if not self.last_query_error:
self.last_query_error = {"message": str(exc)}
return None
async def get_account_by_id(self, account_id: str) -> Optional[Dict]: async def get_account_by_id(self, account_id: str) -> Optional[Dict]:
""" """
Fetch a single account by ID from vTiger Fetch a single account by ID from vTiger

View File

@ -0,0 +1,278 @@
"""Persistent Ollama model benchmark used by the Settings UI."""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any, Callable
import httpx
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
Check = tuple[str, Callable[[dict], bool]]
def _digits(value: Any) -> str:
return re.sub(r"\D", "", str(value or ""))
def _text(result: dict, key: str) -> str:
return str(result.get(key) or "")
BENCHMARK_TESTS: list[dict] = [
{
"key": "contact_signature", "name": "Mailsignatur → kontakt", "category": "CRM",
"description": "Finder navn, korrekt titel, firma, mobil og e-mail uden at bruge hilsenen som titel.",
"expected": {"name": "Ida Gundersen", "title": "Technical Advisor & Co-owner", "company": "Createx", "mobile": "+45 42 25 59 08", "email": "ida@createx-onstage.com"},
"prompt": """Udtræk kontaktdata fra mailen som JSON med præcis nøglerne name, title, company, mobile, email. Brug null hvis feltet mangler. 'Kind regards' er en hilsen og ikke en titel.\n\nKind regards\nIda Gundersen\nTechnical Advisor & Co-owner\nMobile: +45 42 25 59 08 DK: +45 55 86 05 00\nEmail: ida@createx-onstage.com\nWeb: createx-onstage.com\nCreatex\nStoregade 4C | 4780 Stege, DK""",
"checks": [
("Navn", lambda r: "ida gundersen" in _text(r, "name").lower()),
("Titel", lambda r: "technical advisor" in _text(r, "title").lower() and "kind regards" not in _text(r, "title").lower()),
("Firma", lambda r: "createx" in _text(r, "company").lower()),
("Mobil", lambda r: "42255908" in _digits(r.get("mobile"))),
("E-mail", lambda r: _text(r, "email").lower() == "ida@createx-onstage.com"),
],
},
{
"key": "internet_invoice", "name": "Internetfaktura", "category": "Økonomi",
"description": "Skelner fakturanummer, kredsløbsreference, adresse, IP-ranges og indkøbspris.",
"expected": {"reference": "NKA-027964", "address": "Firskovvej 36", "postal_code": "2800", "city": "Kongens Lyngby", "ip_ranges": ["217.74.219.56/30", "152.115.61.32/27"], "purchase_price_dkk": 2495},
"prompt": """Udtræk linjen som JSON med præcis nøglerne reference, address, postal_code, city, ip_ranges, purchase_price_dkk. ip_ranges er en liste og purchase_price_dkk et tal.\n\nGlobalConnect faktura 3018657\nNKA-027964 | Firskovvej 36 | 2800 Kongens Lyngby | Internet 1 Gbit\nIP: 217.74.219.56/30 og 152.115.61.32/27 | Månedlig kostpris: 2.495,00 kr.""",
"checks": [
("Reference", lambda r: "027964" in _text(r, "reference")),
("Adresse", lambda r: "firskovvej 36" in _text(r, "address").lower()),
("Postnummer", lambda r: str(r.get("postal_code")) == "2800"),
("By", lambda r: "lyngby" in _text(r, "city").lower()),
("IP-ranges", lambda r: set(r.get("ip_ranges") or []) == {"217.74.219.56/30", "152.115.61.32/27"}),
("Indkøbspris", lambda r: abs(float(r.get("purchase_price_dkk")) - 2495) < .01),
],
},
{
"key": "support_solution", "name": "Løsningsforslag", "category": "Support",
"description": "Diagnosticerer DNS uden at påstå, at problemet allerede er løst.",
"expected": {"category": "DNS/netværk", "probable_cause": "Forkert DNS-konfiguration", "diagnostic_steps": ["Kontrollér DNS-adresser", "Test navneopslag", "Sammenlign eller rul ændringen tilbage"], "customer_reply": "Må ikke påstå at fejlen er løst"},
"prompt": """Analysér supportsagen som JSON med nøglerne category, probable_cause, diagnostic_steps, customer_reply. diagnostic_steps er en liste. Påstå ikke at fejlen er løst.\n\nInternet virker via IP-adresser, men websites åbner ikke via navn på alle PC'er. Routeren svarer, og 8.8.8.8 svarer på ping. Fejlen begyndte efter ændring af DNS i morges.""",
"checks": [
("DNS-årsag", lambda r: "dns" in json.dumps(r, ensure_ascii=False).lower()),
("Mindst to trin", lambda r: isinstance(r.get("diagnostic_steps"), list) and len(r["diagnostic_steps"]) >= 2),
("Ingen falsk løsning", lambda r: not any(x in _text(r, "customer_reply").lower() for x in ("løst", "resolved", "fixed"))),
],
},
{
"key": "danish_rewrite", "name": "Dansk omskrivning", "category": "Kommunikation",
"description": "Forbedrer sproget uden at ændre reference, adresse, tider eller leverandør.",
"expected": {"text": "Professionel dansk tekst, som bevarer NKA-027964, Firskovvej 36, 08:15, 10:00 og GlobalConnect uden at opfinde en løsning."},
"prompt": """Omskriv til en kort professionel dansk kundemail. Bevar alle fakta og opfind ikke en løsning. Returnér JSON med nøglen text.\n\nhej vi kan se jeres forbindelse NKA-027964 på Firskovvej 36 har været nede siden kl 08:15. vi undersøger det hos globalconnect og vender tilbage senest kl 10:00.""",
"checks": [
("Reference", lambda r: "NKA-027964" in _text(r, "text")),
("Adresse", lambda r: "Firskovvej 36" in _text(r, "text")),
("Tider", lambda r: "08:15" in _text(r, "text") and "10:00" in _text(r, "text")),
("Leverandør", lambda r: "globalconnect" in _text(r, "text").lower()),
],
},
{
"key": "ticket_triage", "name": "Sagsklassificering", "category": "Support",
"description": "Finder netværkskategori og kritisk prioritet ved driftsstop for mange brugere.",
"expected": {"category": "Netværk", "priority": "Kritisk", "affected_scope": "Alle 34 medarbejdere", "suggested_actions": ["Kontrollér fiber/LOS", "Eskalér forbindelsesfejlen til leverandøren"]},
"prompt": """Klassificér som JSON med nøglerne category, priority, affected_scope, suggested_actions.\n\nAlle 34 medarbejdere mistede internet og telefoni kl. 09:02. Fiberboksen har rødt LOS-lys, og virksomheden kan ikke ekspedere ordrer.""",
"checks": [
("Netværk", lambda r: any(x in _text(r, "category").lower() for x in ("netværk", "network"))),
("Kritisk", lambda r: any(x in _text(r, "priority").lower() for x in ("kritisk", "critical", "urgent"))),
("Omfang", lambda r: "34" in _text(r, "affected_scope") or "alle" in _text(r, "affected_scope").lower()),
("Handlinger", lambda r: isinstance(r.get("suggested_actions"), list) and len(r["suggested_actions"]) >= 2),
],
},
{
"key": "sentiment", "name": "Kundestemning", "category": "CRM",
"description": "Registrerer frustration og høj risiko uden at kalde beskeden positiv.",
"expected": {"sentiment": "Frustreret eller vred", "urgency": "Høj/kritisk", "risk_score": "7-10"},
"prompt": """Analysér tonen som JSON med nøglerne sentiment, urgency, risk_score. risk_score er 0-10.\n\nDet er tredje gang på en uge systemet går ned. Vi mister salg, og hvis det fortsætter, finder vi en anden leverandør. Ring straks.""",
"checks": [
("Frustration", lambda r: any(x in _text(r, "sentiment").lower() for x in ("frustr", "vred", "angry", "negative"))),
("Haster", lambda r: any(x in _text(r, "urgency").lower() for x in ("høj", "high", "krit", "urgent"))),
("Høj risiko", lambda r: 7 <= float(r.get("risk_score")) <= 10),
],
},
{
"key": "meeting_actions", "name": "Møde → opgaver", "category": "Produktivitet",
"description": "Udtrækker ansvarlige og deadlines fra dansk mødetekst.",
"expected": {"tasks": [{"action": "Opdatér firewallen", "owner": "Christian", "deadline": "fredag 4. september"}, {"action": "Send status til kunden", "owner": "Anna", "deadline": "mandag"}], "must_not_include": "Peter/kaffesnak"},
"prompt": """Udtræk kun konkrete opgaver som JSON med nøglen tasks. Hver opgave har action, owner, deadline.\n\nVi talte om printere. Christian opdaterer firewallen fredag 4. september. Anna sender status til kunden mandag. Peter synes kaffen var god.""",
"checks": [
("To opgaver", lambda r: isinstance(r.get("tasks"), list) and len(r["tasks"]) == 2),
("Christian", lambda r: "christian" in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
("Anna", lambda r: "anna" in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
("Ingen Peter-opgave", lambda r: "peter" not in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
],
},
{
"key": "knowledge_anonymize", "name": "Vidensartikel", "category": "Viden",
"description": "Omdanner en løsning til generel viden og fjerner kunde, bruger og offentlig IP.",
"expected": {"title": "Generel DNS-fejlsøgning", "problem": "Navneopslag virker ikke på en klient", "solution_steps": ["Sæt korrekt/automatisk DNS", "Kør ipconfig /flushdns", "Test igen"], "must_not_include": ["Garant", "Bo Rasmussen", "Ida", "217.74.219.58"]},
"prompt": """Lav en kort vidensartikel som JSON med nøglerne title, problem, solution_steps. Fjern alle kundespecifikke oplysninger og persondata.\n\nHos Garant v/ Bo Rasmussen kunne bruger Ida ikke åbne intranettet. DNS på PC 217.74.219.58 pegede forkert. Teknikeren satte automatisk DNS og kørte ipconfig /flushdns; derefter virkede det.""",
"checks": [
("Løsningstrin", lambda r: isinstance(r.get("solution_steps"), list) and len(r["solution_steps"]) >= 2),
("Ingen kunde", lambda r: "garant" not in json.dumps(r, ensure_ascii=False).lower()),
("Ingen person", lambda r: all(x not in json.dumps(r, ensure_ascii=False).lower() for x in ("bo rasmussen", "ida"))),
("Ingen IP", lambda r: "217.74.219.58" not in json.dumps(r, ensure_ascii=False)),
("Teknisk fakta", lambda r: "flushdns" in json.dumps(r, ensure_ascii=False).lower()),
],
},
{
"key": "billing_summary", "name": "Fakturatekst", "category": "Økonomi",
"description": "Opsummerer udført arbejde og medtager ikke intern fejlsøgning, der ikke blev udført.",
"expected": {"text": "Kontrol af MFA, geninstallation af VPN-profil og efterfølgende test med kunden. Firewall må ikke nævnes som udført arbejde."},
"prompt": """Lav professionel dansk fakturatekst som JSON med nøglen text. Beskriv kun udført arbejde.\n\nSag: VPN virkede ikke. 09:10 kontrolleret brugerens MFA. 09:18 geninstalleret VPN-profil. 09:25 testet login sammen med kunden, nu OK. Overvejede firewallændring, men udførte den ikke.""",
"checks": [
("VPN-profil", lambda r: "vpn" in _text(r, "text").lower() and any(x in _text(r, "text").lower() for x in ("geninstall", "installer"))),
("MFA", lambda r: "mfa" in _text(r, "text").lower()),
("Test", lambda r: "test" in _text(r, "text").lower()),
("Ingen firewallændring", lambda r: "firewall" not in _text(r, "text").lower()),
],
},
{
"key": "ip_reference_safety", "name": "IP-reference sikkerhed", "category": "Internet",
"description": "Holder IP-ranges på den eksplicit angivne kredsløbsreference og undgår forkert adressekobling.",
"expected": {"reference": "NKA-027964", "service_address": "Firskovvej 36, 2800 Kongens Lyngby", "ip_ranges": ["217.74.219.56/30", "152.115.61.32/27"], "conflicting_address": "Mileparken 22 (tilhører NKA-024219 og må ikke kobles)"},
"prompt": """Returnér JSON med nøglerne reference, service_address, ip_ranges, conflicting_address. Brug kun eksplicitte relationer.\n\nNKA-027964, serviceadresse Firskovvej 36, 2800 Kongens Lyngby. Tilknyttede ranges: 217.74.219.56/30 og 152.115.61.32/27. Teksten 'Mileparken 22' står i en uvedkommende fakturalinje for NKA-024219 og må ikke kobles hertil.""",
"checks": [
("Reference", lambda r: "027964" in _text(r, "reference")),
("Korrekt adresse", lambda r: "firskovvej" in _text(r, "service_address").lower()),
("Begge ranges", lambda r: set(r.get("ip_ranges") or []) == {"217.74.219.56/30", "152.115.61.32/27"}),
("Konflikt markeret", lambda r: "mileparken" in _text(r, "conflicting_address").lower()),
],
},
]
def public_tests() -> list[dict]:
return [{key: test[key] for key in ("key", "name", "category", "description")} | {"max_score": len(test["checks"])} for test in BENCHMARK_TESTS]
def _parse_json(content: str) -> dict:
cleaned = (content or "").strip()
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.I)
cleaned = re.sub(r"\s*```$", "", cleaned)
value = json.loads(cleaned)
if not isinstance(value, dict):
raise ValueError("Svaret var ikke et JSON-objekt")
return value
async def fetch_models(endpoint: str) -> list[dict]:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(f"{endpoint.rstrip('/')}/api/tags")
response.raise_for_status()
return [
{
"name": item.get("name"), "size": item.get("size"),
"parameters": (item.get("details") or {}).get("parameter_size"),
"quantization": (item.get("details") or {}).get("quantization_level"),
}
for item in response.json().get("models", []) if item.get("name")
]
async def _run_case(endpoint: str, model: str, test: dict) -> dict:
payload = {
"model": model, "stream": False, "think": False, "format": "json",
"messages": [
{"role": "system", "content": "Returnér kun gyldig JSON og følg det ønskede schema præcist."},
{"role": "user", "content": test["prompt"]},
],
"options": {"temperature": 0, "num_ctx": 8192, "num_predict": 700},
}
started = time.perf_counter()
async with httpx.AsyncClient(timeout=httpx.Timeout(240, connect=15)) as client:
response = await client.post(f"{endpoint.rstrip('/')}/api/chat", json=payload)
response.raise_for_status()
elapsed_ms = round((time.perf_counter() - started) * 1000)
envelope = response.json()
raw = str((envelope.get("message") or {}).get("content") or "")
result = _parse_json(raw)
passed, failed = [], []
for label, check in test["checks"]:
try:
(passed if check(result) else failed).append(label)
except (AttributeError, KeyError, TypeError, ValueError):
failed.append(label)
eval_count = int(envelope.get("eval_count") or 0)
eval_duration = int(envelope.get("eval_duration") or 0)
tokens_per_second = round(eval_count / (eval_duration / 1_000_000_000), 2) if eval_count and eval_duration else None
return {
"score": len(passed), "max_score": len(test["checks"]), "duration_ms": elapsed_ms,
"prompt_tokens": envelope.get("prompt_eval_count"), "response_tokens": eval_count or None,
"tokens_per_second": tokens_per_second, "passed": passed, "failed": failed,
"response_json": result, "response_text": raw, "error": None,
}
async def execute_run(run_id: int, endpoint: str, models: list[str], test_keys: list[str]) -> None:
selected = [test for test in BENCHMARK_TESTS if test["key"] in test_keys]
execute_query("UPDATE ai_benchmark_runs SET status='running', started_at=NOW() WHERE id=%s", (run_id,))
try:
for model in models:
for test in selected:
try:
result = await _run_case(endpoint, model, test)
except Exception as exc:
logger.warning("AI benchmark failed run=%s model=%s test=%s: %s", run_id, model, test["key"], exc)
result = {
"score": 0, "max_score": len(test["checks"]), "duration_ms": 0,
"prompt_tokens": None, "response_tokens": None, "tokens_per_second": None,
"passed": [], "failed": [label for label, _ in test["checks"]],
"response_json": None, "response_text": None, "error": str(exc),
}
execute_query(
"""INSERT INTO ai_benchmark_results
(run_id, model, test_key, test_name, category, score, max_score, duration_ms,
prompt_tokens, response_tokens, tokens_per_second, passed_checks, failed_checks,
response_json, response_text, error_text, expected_json)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,%s,%s,%s::jsonb)
ON CONFLICT (run_id, model, test_key) DO NOTHING""",
(run_id, model, test["key"], test["name"], test["category"], result["score"],
result["max_score"], result["duration_ms"], result["prompt_tokens"],
result["response_tokens"], result["tokens_per_second"], json.dumps(result["passed"]),
json.dumps(result["failed"]), json.dumps(result["response_json"]) if result["response_json"] is not None else None,
result["response_text"], result["error"], json.dumps(test["expected"], ensure_ascii=False)),
)
execute_query("UPDATE ai_benchmark_runs SET completed_cases=completed_cases+1 WHERE id=%s", (run_id,))
execute_query("UPDATE ai_benchmark_runs SET status='completed', completed_at=NOW() WHERE id=%s", (run_id,))
except Exception as exc:
logger.exception("AI benchmark run %s crashed", run_id)
execute_query("UPDATE ai_benchmark_runs SET status='failed', completed_at=NOW(), error_text=%s WHERE id=%s", (str(exc), run_id))
def get_run(run_id: int) -> dict | None:
run = execute_query_single(
"""SELECT r.*, COALESCE(u.full_name, u.username) AS created_by_name
FROM ai_benchmark_runs r LEFT JOIN users u ON u.user_id=r.created_by WHERE r.id=%s""", (run_id,)
)
if not run:
return None
results = execute_query("SELECT * FROM ai_benchmark_results WHERE run_id=%s ORDER BY model, test_key", (run_id,)) or []
expected_by_key = {test["key"]: test["expected"] for test in BENCHMARK_TESTS}
for result in results:
if result.get("expected_json") is None:
result["expected_json"] = expected_by_key.get(result.get("test_key"))
run["results"] = results
return run
def list_runs(limit: int = 30) -> list[dict]:
return execute_query(
"""SELECT r.*, COALESCE(u.full_name, u.username) AS created_by_name,
COALESCE((SELECT jsonb_agg(summary ORDER BY model) FROM (
SELECT model, SUM(score) AS score, SUM(max_score) AS max_score,
SUM(duration_ms) AS duration_ms, ROUND(AVG(tokens_per_second), 2) AS tokens_per_second
FROM ai_benchmark_results br WHERE br.run_id=r.id GROUP BY model
) summary), '[]'::jsonb) AS model_summaries
FROM ai_benchmark_runs r LEFT JOIN users u ON u.user_id=r.created_by
ORDER BY r.created_at DESC LIMIT %s""", (limit,)
) or []

View File

@ -2,11 +2,11 @@
Settings and User Management API Router Settings and User Management API Router
""" """
from fastapi import APIRouter, HTTPException, Request, Depends from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
from app.core.database import execute_query from app.core.database import execute_query, execute_query_single
from app.core.config import settings from app.core.config import settings
from app.core.auth_dependencies import require_superadmin from app.core.auth_dependencies import require_superadmin
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
@ -70,6 +70,11 @@ class SagTestRunRequest(BaseModel):
customer_id: Optional[int] = None customer_id: Optional[int] = None
class AIBenchmarkRunRequest(BaseModel):
models: List[str]
test_keys: Optional[List[str]] = None
MATTERMOST_SETTING_DEFAULTS = ( MATTERMOST_SETTING_DEFAULTS = (
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"), ("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"), ("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
@ -182,6 +187,91 @@ async def run_sag_test(
_sag_test_lock.release() _sag_test_lock.release()
@router.get("/settings/ai-benchmarks/catalog", tags=["AI Benchmarks"])
async def get_ai_benchmark_catalog(
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import fetch_models, public_tests
try:
models = await fetch_models(settings.OLLAMA_ENDPOINT)
model_error = None
except Exception as exc:
logger.warning("Could not load Ollama models for benchmark: %s", exc)
models = []
model_error = str(exc)
return {
"active_model": settings.OLLAMA_MODEL,
"endpoint": settings.OLLAMA_ENDPOINT,
"models": models,
"tests": public_tests(),
"model_error": model_error,
}
@router.get("/settings/ai-benchmarks/runs", tags=["AI Benchmarks"])
async def get_ai_benchmark_runs(
limit: int = 30,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import list_runs
return {"items": list_runs(max(1, min(limit, 100)))}
@router.get("/settings/ai-benchmarks/runs/{run_id}", tags=["AI Benchmarks"])
async def get_ai_benchmark_run(
run_id: int,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import get_run
run = get_run(run_id)
if not run:
raise HTTPException(status_code=404, detail="Testkørslen findes ikke")
return run
@router.post("/settings/ai-benchmarks/runs", tags=["AI Benchmarks"])
async def create_ai_benchmark_run(
payload: AIBenchmarkRunRequest,
background_tasks: BackgroundTasks,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import BENCHMARK_TESTS, execute_run, fetch_models
models = list(dict.fromkeys(model.strip() for model in payload.models if model.strip()))
if not 1 <= len(models) <= 4:
raise HTTPException(status_code=400, detail="Vælg mellem 1 og 4 modeller")
installed = {item["name"] for item in await fetch_models(settings.OLLAMA_ENDPOINT)}
missing = [model for model in models if model not in installed]
if missing:
raise HTTPException(status_code=400, detail=f"Modellen er ikke installeret: {', '.join(missing)}")
available_keys = {test["key"] for test in BENCHMARK_TESTS}
test_keys = list(dict.fromkeys(payload.test_keys or [test["key"] for test in BENCHMARK_TESTS]))
unknown = [key for key in test_keys if key not in available_keys]
if unknown or not test_keys:
raise HTTPException(status_code=400, detail="Vælg mindst én gyldig test")
running = execute_query_single(
"SELECT id FROM ai_benchmark_runs WHERE status IN ('queued', 'running') ORDER BY id DESC LIMIT 1"
)
if running:
raise HTTPException(status_code=409, detail=f"Testkørsel #{running['id']} kører allerede")
user_id = current_user.get("id") or current_user.get("user_id")
rows = execute_query(
"""INSERT INTO ai_benchmark_runs
(status, models, test_keys, total_cases, completed_cases, created_by)
VALUES ('queued', %s::jsonb, %s::jsonb, %s, 0, %s) RETURNING *""",
(json.dumps(models), json.dumps(test_keys), len(models) * len(test_keys), user_id),
)
run = rows[0]
background_tasks.add_task(execute_run, run["id"], settings.OLLAMA_ENDPOINT, models, test_keys)
return run
# Settings Endpoints # Settings Endpoints
@router.get("/settings", response_model=List[Setting], tags=["Settings"]) @router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None): async def get_settings(category: Optional[str] = None):

View File

@ -81,6 +81,62 @@
font-size: 0.82rem; font-size: 0.82rem;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.ai-benchmark-hero {
color: #fff;
background: linear-gradient(135deg, #0b3b5a 0%, #0f6b87 55%, #18a0a8 100%);
border: 0;
overflow: hidden;
position: relative;
}
.ai-benchmark-hero::after {
content: '';
position: absolute;
width: 260px;
height: 260px;
border-radius: 50%;
right: -80px;
top: -130px;
background: rgba(255,255,255,.1);
}
.ai-model-option, .ai-test-option {
border: 1px solid var(--border-color, #dde5eb);
border-radius: 14px;
padding: .9rem 1rem;
cursor: pointer;
transition: .18s ease;
background: var(--bs-body-bg, #fff);
height: 100%;
}
.ai-model-option:hover, .ai-test-option:hover,
.ai-model-option:has(input:checked), .ai-test-option:has(input:checked) {
border-color: var(--accent);
box-shadow: 0 8px 24px rgba(15, 76, 117, .1);
transform: translateY(-1px);
}
.ai-benchmark-score {
width: 58px;
height: 58px;
border-radius: 16px;
display: grid;
place-items: center;
font-weight: 800;
background: var(--accent-light);
color: var(--accent);
}
.ai-benchmark-result-table td { vertical-align: middle; }
.ai-benchmark-json { max-height: 320px; overflow: auto; white-space: pre-wrap; font-size: .78rem; }
.ai-benchmark-comparison-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
align-items: stretch;
}
</style> </style>
{% endblock %} {% endblock %}
@ -127,6 +183,9 @@
<a class="nav-link" href="#ai-prompts" data-tab="ai-prompts"> <a class="nav-link" href="#ai-prompts" data-tab="ai-prompts">
<i class="bi bi-robot me-2"></i>AI Prompts <i class="bi bi-robot me-2"></i>AI Prompts
</a> </a>
<a class="nav-link" href="#ai-modeltest" data-tab="ai-modeltest">
<i class="bi bi-speedometer2 me-2"></i>AI Modeltest
</a>
<a class="nav-link" href="#email-templates" data-tab="email-templates"> <a class="nav-link" href="#email-templates" data-tab="email-templates">
<i class="bi bi-envelope-paper me-2"></i>Email skabeloner <i class="bi bi-envelope-paper me-2"></i>Email skabeloner
</a> </a>
@ -1275,6 +1334,79 @@
</div> </div>
</div> </div>
<!-- AI Model Benchmark -->
<div class="tab-pane fade" id="ai-modeltest">
<div class="card ai-benchmark-hero p-4 mb-4">
<div class="position-relative" style="z-index:1">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<div class="text-uppercase small fw-semibold opacity-75 mb-2">Ollama · Kontrolleret A/B-test</div>
<h3 class="fw-bold mb-2">Hvilken model er faktisk bedst til BMC Hub?</h3>
<p class="mb-0 opacity-75">Sammenlign præcision, JSON-stabilitet, hastighed og svar på virkelige CRM-opgaver.</p>
</div>
<div class="text-end">
<div class="small opacity-75">Aktiv model</div>
<div class="fs-5 fw-bold" id="aiBenchmarkActiveModel"></div>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-xl-8">
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div><h5 class="fw-bold mb-1">1. Vælg modeller</h5><p class="text-muted small mb-0">Op til fire installerede Ollama-modeller.</p></div>
<button class="btn btn-sm btn-outline-secondary" onclick="loadAIBenchmark()"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button>
</div>
<div class="row g-3" id="aiBenchmarkModels"><div class="text-muted">Henter modeller…</div></div>
</div>
<div class="card p-4 mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div><h5 class="fw-bold mb-1">2. Vælg tests</h5><p class="text-muted small mb-0">Alle tests giver objektive point for kendte fakta.</p></div>
<div class="btn-group btn-group-sm"><button class="btn btn-outline-secondary" onclick="toggleAllAIBenchmarkTests(true)">Vælg alle</button><button class="btn btn-outline-secondary" onclick="toggleAllAIBenchmarkTests(false)">Ryd</button></div>
</div>
<div class="row g-3" id="aiBenchmarkTests"></div>
</div>
<div class="card p-4 mb-4 d-none" id="aiBenchmarkRunningCard">
<div class="d-flex justify-content-between align-items-center mb-3">
<div><h5 class="fw-bold mb-1">Testkørsel <span id="aiBenchmarkRunNumber"></span></h5><div class="text-muted small" id="aiBenchmarkRunState">Forbereder…</div></div>
<span class="spinner-border text-primary" id="aiBenchmarkSpinner"></span>
</div>
<div class="progress" style="height:12px"><div class="progress-bar progress-bar-striped progress-bar-animated" id="aiBenchmarkProgress" style="width:0%"></div></div>
<div class="small text-muted mt-2" id="aiBenchmarkProgressText">0 / 0 tests</div>
</div>
<div id="aiBenchmarkResult"></div>
</div>
<div class="col-xl-4">
<div class="card p-4 mb-4">
<h5 class="fw-bold mb-3">Start sammenligning</h5>
<div class="d-flex justify-content-between py-2 border-bottom"><span class="text-muted">Modeller</span><strong id="aiBenchmarkModelCount">0</strong></div>
<div class="d-flex justify-content-between py-2 border-bottom"><span class="text-muted">Tests</span><strong id="aiBenchmarkTestCount">0</strong></div>
<div class="d-flex justify-content-between py-2 mb-3"><span class="text-muted">Modelsvar i alt</span><strong id="aiBenchmarkCaseCount">0</strong></div>
<div class="alert alert-light small"><i class="bi bi-info-circle me-1"></i>Kørslen ændrer ikke den aktive AI-model.</div>
<button class="btn btn-primary btn-lg w-100" id="startAIBenchmarkBtn" onclick="startAIBenchmark()"><i class="bi bi-play-fill me-2"></i>Kør modeltest</button>
<div class="alert alert-primary mt-3 mb-0 d-none" id="aiBenchmarkLiveStatus">
<div class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm flex-shrink-0" id="aiBenchmarkLiveSpinner"></span>
<div><strong class="d-block" id="aiBenchmarkLiveTitle">Testen starter…</strong><span class="small" id="aiBenchmarkLiveDetail">Forbereder modellerne</span></div>
</div>
</div>
<div class="small text-danger mt-2" id="aiBenchmarkError"></div>
</div>
<div class="card p-4">
<div class="d-flex justify-content-between align-items-center mb-3"><h5 class="fw-bold mb-0">Historik</h5><span class="badge text-bg-light" id="aiBenchmarkHistoryCount">0</span></div>
<div id="aiBenchmarkHistory"><div class="text-muted small">Ingen kørsler endnu.</div></div>
</div>
</div>
</div>
</div>
<!-- Modules Documentation --> <!-- Modules Documentation -->
<div class="tab-pane fade" id="modules"> <div class="tab-pane fade" id="modules">
<div class="card p-4"> <div class="card p-4">
@ -5062,7 +5194,7 @@ async function loadSagModuleTests() {
const state = document.getElementById('sagTestState'); const state = document.getElementById('sagTestState');
try { try {
const response = await fetch('/api/v1/settings/tests/sag', { credentials: 'include' }); const response = await fetch('/api/v1/settings/tests/sag', { credentials: 'include' });
if (!response.ok) throw new Error(await extractApiError(response, 'Kunne ikke hente tests')); if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente tests'));
const data = await response.json(); const data = await response.json();
renderSagTestReport(data.latest); renderSagTestReport(data.latest);
renderSagTestHistory(data.history); renderSagTestHistory(data.history);
@ -5094,7 +5226,7 @@ async function runSagModuleTest() {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}) body: JSON.stringify({})
}); });
if (!response.ok) throw new Error(await extractApiError(response, 'Sag-testen fejlede')); if (!response.ok) throw new Error(await getErrorMessage(response, 'Sag-testen fejlede'));
const report = await response.json(); const report = await response.json();
renderSagTestReport(report); renderSagTestReport(report);
await loadSagModuleTests(); await loadSagModuleTests();
@ -5109,6 +5241,255 @@ async function runSagModuleTest() {
} }
} }
let aiBenchmarkCatalog = null;
let aiBenchmarkPollTimer = null;
function aiBenchEscape(value) {
return String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[ch]));
}
function aiBenchBytes(value) {
const bytes = Number(value || 0);
return bytes ? `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB` : '';
}
function updateAIBenchmarkSelection() {
const models = document.querySelectorAll('.ai-benchmark-model:checked').length;
const tests = document.querySelectorAll('.ai-benchmark-test:checked').length;
document.getElementById('aiBenchmarkModelCount').textContent = models;
document.getElementById('aiBenchmarkTestCount').textContent = tests;
document.getElementById('aiBenchmarkCaseCount').textContent = models * tests;
const button = document.getElementById('startAIBenchmarkBtn');
if (button) button.disabled = !models || !tests || models > 4;
}
function toggleAllAIBenchmarkTests(checked) {
document.querySelectorAll('.ai-benchmark-test').forEach(input => { input.checked = checked; });
updateAIBenchmarkSelection();
}
async function loadAIBenchmark() {
const error = document.getElementById('aiBenchmarkError');
error.textContent = '';
try {
const [catalogResponse, historyResponse] = await Promise.all([
fetch('/api/v1/settings/ai-benchmarks/catalog', {credentials:'include'}),
fetch('/api/v1/settings/ai-benchmarks/runs?limit=30', {credentials:'include'})
]);
if (!catalogResponse.ok) throw new Error(await getErrorMessage(catalogResponse, 'Kunne ikke hente AI-modeller'));
aiBenchmarkCatalog = await catalogResponse.json();
document.getElementById('aiBenchmarkActiveModel').textContent = aiBenchmarkCatalog.active_model || '';
renderAIBenchmarkModels(aiBenchmarkCatalog.models || []);
renderAIBenchmarkTests(aiBenchmarkCatalog.tests || []);
if (aiBenchmarkCatalog.model_error) error.textContent = `Ollama: ${aiBenchmarkCatalog.model_error}`;
if (historyResponse.ok) renderAIBenchmarkHistory((await historyResponse.json()).items || []);
} catch (exc) {
error.textContent = exc.message || 'Kunne ikke indlæse modeltesten';
}
}
function renderAIBenchmarkModels(models) {
const root = document.getElementById('aiBenchmarkModels');
if (!models.length) {
root.innerHTML = '<div class="col-12"><div class="alert alert-warning mb-0">Ingen installerede Ollama-modeller fundet.</div></div>';
return;
}
const preferred = new Set([aiBenchmarkCatalog?.active_model, 'qwen3.5:9b']);
root.innerHTML = models.map(model => `
<div class="col-md-6">
<label class="ai-model-option d-flex align-items-start gap-3">
<input class="form-check-input mt-1 ai-benchmark-model" type="checkbox" value="${aiBenchEscape(model.name)}" ${preferred.has(model.name) ? 'checked' : ''} onchange="updateAIBenchmarkSelection()">
<span class="min-w-0">
<span class="d-flex align-items-center gap-2 flex-wrap"><strong>${aiBenchEscape(model.name)}</strong>${model.name === aiBenchmarkCatalog?.active_model ? '<span class="badge text-bg-primary">Aktiv</span>' : ''}</span>
<span class="small text-muted">${aiBenchEscape(model.parameters || 'Ukendt størrelse')} · ${aiBenchEscape(model.quantization || '')} · ${aiBenchBytes(model.size)}</span>
</span>
</label>
</div>`).join('');
updateAIBenchmarkSelection();
}
function renderAIBenchmarkTests(tests) {
const colors = {CRM:'primary', Support:'success', 'Økonomi':'warning', Kommunikation:'info', Produktivitet:'secondary', Viden:'dark', Internet:'danger'};
document.getElementById('aiBenchmarkTests').innerHTML = tests.map(test => `
<div class="col-md-6">
<label class="ai-test-option d-flex align-items-start gap-3">
<input class="form-check-input mt-1 ai-benchmark-test" type="checkbox" value="${aiBenchEscape(test.key)}" checked onchange="updateAIBenchmarkSelection()">
<span><span class="d-flex align-items-center gap-2 mb-1"><strong>${aiBenchEscape(test.name)}</strong><span class="badge text-bg-${colors[test.category] || 'light'}">${aiBenchEscape(test.category)}</span></span><span class="small text-muted d-block">${aiBenchEscape(test.description)}</span><span class="small fw-semibold text-primary">${test.max_score} point</span></span>
</label>
</div>`).join('');
updateAIBenchmarkSelection();
}
async function startAIBenchmark() {
const models = [...document.querySelectorAll('.ai-benchmark-model:checked')].map(input => input.value);
const testKeys = [...document.querySelectorAll('.ai-benchmark-test:checked')].map(input => input.value);
const button = document.getElementById('startAIBenchmarkBtn');
const error = document.getElementById('aiBenchmarkError');
error.textContent = '';
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starter…';
showAIBenchmarkProgress({id: '…', status: 'queued', completed_cases: 0, total_cases: models.length * testKeys.length});
try {
const response = await fetch('/api/v1/settings/ai-benchmarks/runs', {
method:'POST', credentials:'include', headers:{'Content-Type':'application/json'},
body: JSON.stringify({models, test_keys:testKeys})
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke starte testen'));
const run = await response.json();
showAIBenchmarkProgress(run);
pollAIBenchmarkRun(run.id);
} catch (exc) {
error.textContent = exc.message || 'Testen kunne ikke startes';
button.disabled = false;
document.getElementById('aiBenchmarkLiveStatus').classList.add('d-none');
document.getElementById('aiBenchmarkRunningCard').classList.add('d-none');
} finally {
if (!button.disabled) button.innerHTML = '<i class="bi bi-play-fill me-2"></i>Kør modeltest';
}
}
function showAIBenchmarkProgress(run) {
const card = document.getElementById('aiBenchmarkRunningCard');
card.classList.remove('d-none');
document.getElementById('aiBenchmarkRunNumber').textContent = `#${run.id}`;
const complete = Number(run.completed_cases || 0);
const total = Number(run.total_cases || 0);
const pct = total ? Math.round(complete / total * 100) : 0;
document.getElementById('aiBenchmarkProgress').style.width = `${pct}%`;
document.getElementById('aiBenchmarkProgressText').textContent = `${complete} / ${total} modelsvar · ${pct}%`;
document.getElementById('aiBenchmarkRunState').textContent = run.status === 'queued' ? 'Venter på Ollama…' : run.status === 'running' ? 'Tester modellerne én ad gangen…' : run.status;
const finished = ['completed','failed'].includes(run.status);
document.getElementById('aiBenchmarkSpinner').classList.toggle('d-none', finished);
document.getElementById('aiBenchmarkProgress').classList.toggle('progress-bar-animated', !finished);
const live = document.getElementById('aiBenchmarkLiveStatus');
const liveSpinner = document.getElementById('aiBenchmarkLiveSpinner');
const liveTitle = document.getElementById('aiBenchmarkLiveTitle');
const liveDetail = document.getElementById('aiBenchmarkLiveDetail');
const startButton = document.getElementById('startAIBenchmarkBtn');
live.classList.remove('d-none', 'alert-success', 'alert-danger');
live.classList.add(finished ? (run.status === 'completed' ? 'alert-success' : 'alert-danger') : 'alert-primary');
liveSpinner.classList.toggle('d-none', finished);
liveTitle.textContent = run.status === 'completed' ? 'Testen er færdig' : run.status === 'failed' ? 'Testen fejlede' : `AI-test #${run.id} kører`;
liveDetail.textContent = `${complete} af ${total} modelsvar · ${pct}%`;
if (!finished) {
startButton.disabled = true;
startButton.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Test kører · ${complete}/${total}`;
} else {
startButton.disabled = false;
startButton.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i>Kør ny modeltest';
}
}
function pollAIBenchmarkRun(runId) {
clearTimeout(aiBenchmarkPollTimer);
const poll = async () => {
try {
const response = await fetch(`/api/v1/settings/ai-benchmarks/runs/${runId}`, {credentials:'include'});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Kunne ikke hente teststatus'));
const run = await response.json();
showAIBenchmarkProgress(run);
if (['completed','failed'].includes(run.status)) {
renderAIBenchmarkResult(run);
const history = await fetch('/api/v1/settings/ai-benchmarks/runs?limit=30', {credentials:'include'});
if (history.ok) renderAIBenchmarkHistory((await history.json()).items || []);
return;
}
aiBenchmarkPollTimer = setTimeout(poll, 2500);
} catch (exc) {
document.getElementById('aiBenchmarkError').textContent = exc.message;
aiBenchmarkPollTimer = setTimeout(poll, 5000);
}
};
poll();
}
function renderAIBenchmarkResult(run) {
const root = document.getElementById('aiBenchmarkResult');
const grouped = {};
(run.results || []).forEach(result => (grouped[result.model] ||= []).push(result));
const summaries = Object.entries(grouped).map(([model, results]) => ({
model, results,
score: results.reduce((sum, item) => sum + Number(item.score || 0), 0),
max: results.reduce((sum, item) => sum + Number(item.max_score || 0), 0),
duration: results.reduce((sum, item) => sum + Number(item.duration_ms || 0), 0),
tps: results.filter(item => item.tokens_per_second).reduce((sum, item, _, arr) => sum + Number(item.tokens_per_second) / arr.length, 0)
})).sort((a,b) => (b.score/b.max) - (a.score/a.max) || a.duration-b.duration);
if (!summaries.length) {
root.innerHTML = `<div class="alert alert-danger">Kørslen gav ingen resultater. ${aiBenchEscape(run.error_text || '')}</div>`;
return;
}
const winner = summaries[0]?.model;
const testKeys = [...new Set((run.results || []).map(item => item.test_key))];
const comparisonCards = testKeys.map((testKey, testIndex) => {
const answers = summaries.map(summary => summary.results.find(item => item.test_key === testKey)).filter(Boolean);
const first = answers[0];
const expected = answers.find(item => item.expected_json)?.expected_json;
return `
<div class="border rounded-4 overflow-hidden mb-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 bg-body-tertiary">
<div><span class="badge text-bg-light border me-2">${aiBenchEscape(first.category)}</span><strong>${aiBenchEscape(first.test_name)}</strong></div>
<div class="d-flex gap-2 flex-wrap">${answers.map(item => `<span class="badge ${item.score === item.max_score ? 'text-bg-success' : item.score ? 'text-bg-warning' : 'text-bg-danger'}">${aiBenchEscape(item.model)} · ${item.score}/${item.max_score}</span>`).join('')}</div>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#aiCompare${run.id}_${testIndex}"><i class="bi bi-layout-three-columns me-1"></i>Sammenlign alle svar</button>
</div>
<div class="collapse" id="aiCompare${run.id}_${testIndex}">
<div class="p-3">
<div class="ai-benchmark-comparison-grid">
<div class="border border-success-subtle rounded-3 overflow-hidden">
<div class="px-3 py-2 bg-success-subtle fw-bold small text-uppercase"><i class="bi bi-bullseye me-1"></i>Forventet svar</div>
<pre class="ai-benchmark-json p-3 mb-0">${aiBenchEscape(JSON.stringify(expected, null, 2) || 'Intet facit gemt')}</pre>
</div>
${answers.map(item => `
<div class="border rounded-3 overflow-hidden">
<div class="px-3 py-2 bg-body-tertiary d-flex justify-content-between align-items-center"><strong>${aiBenchEscape(item.model)}</strong><span class="badge ${item.score === item.max_score ? 'text-bg-success' : item.score ? 'text-bg-warning' : 'text-bg-danger'}">${item.score}/${item.max_score}</span></div>
<pre class="ai-benchmark-json p-3 mb-0">${aiBenchEscape(item.error_text || JSON.stringify(item.response_json, null, 2) || item.response_text || '[Tomt svar]')}</pre>
<div class="border-top p-3 small">
<div class="mb-2">${(item.passed_checks || []).map(x=>`<span class="badge text-bg-success me-1 mb-1"><i class="bi bi-check me-1"></i>${aiBenchEscape(x)}</span>`).join('') || ''}</div>
<div>${(item.failed_checks || []).map(x=>`<span class="badge text-bg-danger me-1 mb-1"><i class="bi bi-x me-1"></i>${aiBenchEscape(x)}</span>`).join('') || ''}</div>
<div class="text-muted mt-2">${(Number(item.duration_ms)/1000).toFixed(1)} sek. · ${item.tokens_per_second ? Number(item.tokens_per_second).toFixed(1)+' tok/s' : ''}</div>
</div>
</div>`).join('')}
</div>
</div>
</div>
</div>`;
}).join('');
root.innerHTML = `
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-start mb-4"><div><div class="text-uppercase text-muted small fw-semibold">Resultat fra kørsel #${run.id}</div><h4 class="fw-bold mb-0">${aiBenchEscape(winner)} vandt testen</h4></div><span class="badge text-bg-success fs-6">Færdig</span></div>
<div class="row g-3 mb-4">${summaries.map((summary, index) => `
<div class="col-md-6"><div class="border rounded-4 p-3 h-100">
<div class="d-flex gap-3 align-items-center"><div class="ai-benchmark-score">${Math.round(summary.score/summary.max*100)}%</div><div><div class="d-flex gap-2 align-items-center"><strong>${aiBenchEscape(summary.model)}</strong>${index === 0 ? '<span class="badge text-bg-success">Bedst</span>' : ''}</div><div class="text-muted small">${summary.score}/${summary.max} point · ${(summary.duration/1000).toFixed(1)} sek. · ${summary.tps ? summary.tps.toFixed(1) + ' tok/s' : ''}</div></div></div>
<div class="progress mt-3" style="height:7px"><div class="progress-bar" style="width:${summary.score/summary.max*100}%"></div></div>
</div></div>`).join('')}</div>
<div class="d-flex justify-content-between align-items-center mt-4 mb-3"><h5 class="fw-bold mb-0">Svar sammenlignet test for test</h5><span class="text-muted small">Facit + ${summaries.length} modeller</span></div>
${comparisonCards}
</div>`;
}
function renderAIBenchmarkHistory(runs) {
document.getElementById('aiBenchmarkHistoryCount').textContent = runs.length;
const root = document.getElementById('aiBenchmarkHistory');
if (!runs.length) { root.innerHTML = '<div class="text-muted small">Ingen kørsler endnu.</div>'; return; }
root.innerHTML = runs.map(run => {
const summaries = run.model_summaries || [];
const best = [...summaries].sort((a,b) => (Number(b.score)/Number(b.max_score))-(Number(a.score)/Number(a.max_score)))[0];
const statusClass = run.status === 'completed' ? 'success' : run.status === 'failed' ? 'danger' : 'primary';
return `<button class="btn btn-light text-start w-100 p-3 mb-2 border" onclick="openAIBenchmarkRun(${run.id})"><div class="d-flex justify-content-between"><strong>#${run.id} ${best ? aiBenchEscape(best.model) : 'Testkørsel'}</strong><span class="badge text-bg-${statusClass}">${aiBenchEscape(run.status)}</span></div><div class="small text-muted mt-1">${new Date(run.created_at).toLocaleString('da-DK')} · ${run.completed_cases}/${run.total_cases} svar</div>${best ? `<div class="small fw-semibold text-primary mt-1">Bedst: ${best.score}/${best.max_score} point</div>` : ''}</button>`;
}).join('');
const active = runs.find(run => ['queued','running'].includes(run.status));
if (active) { showAIBenchmarkProgress(active); pollAIBenchmarkRun(active.id); }
}
async function openAIBenchmarkRun(runId) {
const response = await fetch(`/api/v1/settings/ai-benchmarks/runs/${runId}`, {credentials:'include'});
if (!response.ok) return;
const run = await response.json();
showAIBenchmarkProgress(run);
renderAIBenchmarkResult(run);
document.getElementById('aiBenchmarkResult').scrollIntoView({behavior:'smooth', block:'start'});
if (!['completed','failed'].includes(run.status)) pollAIBenchmarkRun(run.id);
}
// Tab navigation // Tab navigation
document.querySelectorAll('.settings-nav .nav-link').forEach(link => { document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
@ -5146,6 +5527,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
renderMissionSettings(); renderMissionSettings();
} else if (tab === 'ai-prompts') { } else if (tab === 'ai-prompts') {
loadAIPrompts(); loadAIPrompts();
} else if (tab === 'ai-modeltest') {
loadAIBenchmark();
} else if (tab === 'modules') { } else if (tab === 'modules') {
loadModules(); loadModules();
} else if (tab === 'tests') { } else if (tab === 'tests') {

View File

@ -26,6 +26,17 @@
--bottom-bar-zindex: 1030; --bottom-bar-zindex: 1030;
} }
/* Keep the content viewport stable when a tall dropdown opens/closes.
Without a reserved scrollbar gutter, wide case hero sections visibly
jump a few pixels on smaller monitors. */
html {
scrollbar-gutter: stable;
}
@supports not (scrollbar-gutter: stable) {
html { overflow-y: scroll; }
}
[data-bs-theme="dark"] { [data-bs-theme="dark"] {
--bg-body: #212529; --bg-body: #212529;
--bg-card: #2c3034; --bg-card: #2c3034;
@ -44,7 +55,7 @@
background-color: var(--bg-body); background-color: var(--bg-body);
color: var(--text-primary); color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px; padding-top: 68px;
transition: background-color 0.3s, color 0.3s; transition: background-color 0.3s, color 0.3s;
} }
@ -812,32 +823,357 @@
} }
.navbar { .navbar {
background: var(--bg-card); background: color-mix(in srgb, var(--bg-card) 94%, transparent);
box-shadow: 0 2px 15px rgba(0,0,0,0.03); -webkit-backdrop-filter: blur(18px) saturate(1.15);
backdrop-filter: blur(18px) saturate(1.15);
box-shadow: 0 8px 28px rgba(15, 47, 72, 0.07);
padding: 1rem 0; padding: 1rem 0;
border-bottom: 1px solid rgba(0,0,0,0.1); border-bottom: 1px solid color-mix(in srgb, var(--accent) 14%, transparent);
}
/* The complete BMC menu needs more room than Bootstrap's normal lg
breakpoint. Use a dedicated wide-desktop breakpoint so 1315 inch
monitors get a clean collapsible menu instead of clipped items. */
@media (min-width: 1180px) {
.navbar-expand-wide { flex-wrap: nowrap; justify-content: flex-start; }
.navbar-expand-wide .navbar-toggler { display: none; }
.navbar-expand-wide .navbar-collapse { display: flex !important; flex-basis: auto; }
.navbar-expand-wide .navbar-nav { flex-direction: row; }
.navbar-expand-wide .navbar-nav .dropdown-menu,
.navbar-expand-wide .navbar-actions .dropdown-menu {
position: absolute;
}
}
@media (min-width: 1180px) and (max-width: 1679.98px) {
.navbar-expand-wide {
padding: .55rem 0;
}
.navbar-expand-wide > .container-fluid {
padding-left: .75rem !important;
padding-right: .75rem !important;
}
.navbar-expand-wide .navbar-brand {
width: 44px;
min-width: 44px;
overflow: visible;
margin-right: .55rem;
}
.navbar-expand-wide .navbar-brand > div {
margin-right: 0 !important;
}
.navbar-expand-wide .bmc-brand-copy {
display: none;
}
.navbar-expand-wide .navbar-nav {
margin-left: 0 !important;
margin-right: .25rem !important;
padding: 0;
border: 0;
background: transparent;
}
.navbar-expand-wide .nav-link {
min-height: 38px !important;
padding: .45rem .68rem !important;
margin: 0 .03rem;
font-size: 1rem;
white-space: nowrap;
}
.navbar-expand-wide .navbar-nav .nav-link > i,
.navbar-expand-wide .navbar-actions > .dropdown:first-child .nav-link > i {
display: none;
}
.navbar-expand-wide .navbar-actions {
flex: 1 1 auto;
gap: .12rem !important;
flex-wrap: nowrap;
}
.navbar-expand-wide .navbar-actions #globalSearchBtn {
margin-left: auto;
}
.navbar-expand-wide .navbar-actions > .btn.rounded-circle {
width: 34px;
height: 34px;
min-width: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.navbar-expand-wide .navbar-utility-dock {
gap: .18rem;
padding: .18rem;
border-color: color-mix(in srgb, var(--accent) 8%, transparent);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
}
.navbar-expand-wide .navbar-utility-dock > .btn.rounded-circle {
width: 35px;
height: 35px;
min-width: 35px;
}
.navbar-expand-wide #currentUserDisplayName {
display: none;
}
.navbar-expand-wide #currentUserAvatar {
margin-right: 0 !important;
width: 32px;
height: 32px;
}
}
@media (min-width: 1280px) {
.navbar-expand-wide > .container-fluid {
position: relative;
}
.navbar-expand-wide .navbar-collapse {
position: static;
}
#navbarNav > .navbar-nav {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin-left: 0 !important;
margin-right: 0 !important;
}
#navbarNav > .navbar-actions {
margin-left: auto;
}
}
@media (max-width: 1179.98px) {
.navbar-expand-wide {
padding: .65rem 0;
}
.navbar-expand-wide > .container-fluid {
padding-left: 1rem !important;
padding-right: 1rem !important;
}
.navbar-expand-wide .navbar-toggler {
display: inline-flex;
align-items: center;
gap: .45rem;
border: 1px solid rgba(15, 76, 117, .18);
border-radius: 10px;
padding: .45rem .7rem;
color: var(--accent);
background: var(--accent-light);
font-size: .88rem;
font-weight: 700;
}
.navbar-expand-wide .navbar-toggler:focus {
box-shadow: 0 0 0 .2rem rgba(15, 76, 117, .12);
}
.navbar-expand-wide .navbar-collapse {
max-height: calc(100vh - 76px);
overflow-y: auto;
padding: .85rem 0 1rem;
}
.navbar-expand-wide .navbar-nav {
width: 100%;
margin: 0 !important;
gap: .2rem;
}
.navbar-expand-wide .nav-link {
display: flex;
align-items: center;
min-height: 44px;
margin: 0;
}
.navbar-expand-wide .navbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: flex-start;
gap: .65rem !important;
border-top: 1px solid rgba(15, 76, 117, .12);
margin-top: .75rem;
padding-top: .85rem;
}
.navbar-expand-wide .navbar-actions > .dropdown:first-child {
flex: 1 0 100%;
}
.navbar-expand-wide .navbar-utility-dock {
width: 100%;
margin-left: 0;
border-radius: 14px;
justify-content: flex-start;
}
.navbar-expand-wide .dropdown-menu {
max-height: min(60vh, 520px);
overflow-y: auto;
}
} }
.navbar-brand { .navbar-brand {
font-weight: 700; font-weight: 700;
color: var(--accent); color: var(--accent);
font-size: 1.25rem; font-size: 1.25rem;
letter-spacing: -0.025em;
}
.navbar-brand > div {
background: linear-gradient(145deg, var(--accent), #2488bd) !important;
box-shadow: 0 6px 14px rgba(15, 76, 117, .2);
}
.bmc-brand-copy {
display: flex;
flex-direction: column;
justify-content: center;
line-height: 1.02;
}
.bmc-brand-name {
font-size: 1.08rem;
font-weight: 820;
}
.bmc-brand-subtitle {
margin-top: .16rem;
color: var(--text-secondary);
font-size: .58rem;
font-weight: 750;
letter-spacing: .12em;
text-transform: uppercase;
}
@media (min-width: 1180px) {
#navbarNav > .navbar-nav {
align-items: center;
padding: .24rem;
border: 1px solid color-mix(in srgb, var(--accent) 9%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
}
#navbarNav > .navbar-nav > .nav-item > .nav-link {
min-height: 40px;
padding-top: .52rem !important;
padding-bottom: .52rem !important;
}
#navbarNav > .navbar-nav > .nav-item > .nav-link.active,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active {
background: var(--accent);
color: #fff;
box-shadow: 0 7px 16px rgba(15, 76, 117, .2);
}
#navbarNav > .navbar-nav > .nav-item > .nav-link.active::after,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show::after,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active::after {
border-top-color: currentColor;
}
}
@media (min-width: 1180px) and (max-width: 1679.98px) {
#navbarNav > .navbar-nav {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
#navbarNav > .navbar-nav > .nav-item > .nav-link {
min-height: 38px;
padding-top: .45rem !important;
padding-bottom: .45rem !important;
box-shadow: none;
}
#navbarNav > .navbar-nav > .nav-item > .nav-link.active,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active {
box-shadow: 0 5px 13px rgba(15, 76, 117, .18);
}
.navbar-actions > .dropdown:first-child > .nav-link {
min-height: 38px;
border-radius: 10px;
}
}
.navbar-utility-dock {
display: flex;
align-items: center;
gap: .35rem;
margin-left: auto;
padding: .28rem;
border: 1px solid color-mix(in srgb, var(--accent) 10%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 4%, var(--bg-card));
}
.navbar-utility-dock > .btn.rounded-circle {
width: 38px;
height: 38px;
min-width: 38px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0 !important;
background: transparent !important;
box-shadow: none;
}
.navbar-utility-dock > .btn.rounded-circle:hover {
background: var(--accent-light) !important;
transform: none;
box-shadow: none;
}
.navbar-utility-dock > .dropdown:last-child > a {
min-height: 38px;
padding: .15rem .3rem .15rem .2rem;
border-radius: 999px;
}
.navbar-utility-dock > .dropdown:last-child > a:hover {
background: var(--accent-light);
}
@media (min-width: 1180px) and (max-width: 1679.98px) {
.navbar-expand-wide .navbar-utility-dock {
gap: .16rem;
padding: .16rem;
border-color: color-mix(in srgb, var(--accent) 8%, transparent);
background: color-mix(in srgb, var(--accent) 3%, var(--bg-card));
}
.navbar-expand-wide .navbar-utility-dock > .btn.rounded-circle {
width: 35px;
height: 35px;
min-width: 35px;
}
} }
.nav-link { .nav-link {
color: var(--text-secondary); color: var(--text-secondary);
padding: 0.6rem 1.2rem !important; padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius); border-radius: 11px;
transition: all 0.2s; transition: background-color .16s ease, color .16s ease, box-shadow .16s ease;
font-weight: 500; font-weight: 650;
margin: 0 0.2rem; margin: 0 0.2rem;
} }
.nav-link:hover, .nav-link.active { .nav-link:hover, .nav-link.active, .nav-link.show, .nav-link.section-active {
background-color: var(--accent-light); background-color: var(--accent-light);
color: var(--accent); color: var(--accent);
} }
#navbarNav > .navbar-nav > .nav-item > .nav-link.active,
#navbarNav > .navbar-nav > .nav-item > .nav-link.show,
#navbarNav > .navbar-nav > .nav-item > .nav-link.section-active {
box-shadow: inset 0 -2px 0 var(--accent);
}
/* Top-level labels are clearer and use materially less horizontal room
without decorative icons. Icons inside dropdown items stay visible. */
#navbarNav > .navbar-nav > .nav-item > .nav-link > i,
#navbarNav > .navbar-actions > .dropdown:first-child > .nav-link > i {
display: none;
}
.card { .card {
border: 2px solid var(--frame-border-strong); border: 2px solid var(--frame-border-strong);
border-left: 4px solid var(--accent); border-left: 4px solid var(--accent);
@ -954,11 +1290,75 @@
} }
.dropdown-menu { .dropdown-menu {
border: none; min-width: 245px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08); border: 1px solid color-mix(in srgb, var(--accent) 13%, transparent);
border-radius: 12px; box-shadow: 0 18px 46px rgba(15, 47, 72, .16), 0 3px 10px rgba(15, 47, 72, .07);
padding: 0.5rem; border-radius: 16px;
background-color: var(--bg-card); padding: .55rem;
background: color-mix(in srgb, var(--bg-card) 97%, transparent);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
animation: bmcMenuReveal .14s ease-out;
}
@keyframes bmcMenuReveal {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
#navbarNav .dropdown-item {
display: flex;
align-items: center;
min-height: 39px;
border-radius: 10px;
color: var(--text-primary);
font-size: .95rem;
font-weight: 560;
transition: background-color .14s ease, color .14s ease, padding-left .14s ease;
}
#navbarNav .dropdown-item:hover,
#navbarNav .dropdown-item:focus {
padding-left: .85rem;
background: color-mix(in srgb, var(--accent) 9%, var(--bg-card));
color: var(--accent);
}
#navbarNav .dropdown-item.active {
background: color-mix(in srgb, var(--accent) 14%, var(--bg-card));
color: var(--accent);
font-weight: 750;
}
#navbarNav .dropdown-item > i {
width: 1.35rem;
color: color-mix(in srgb, var(--accent) 82%, var(--text-secondary));
}
#navbarNav .dropdown-header {
padding: .55rem .65rem .3rem;
color: color-mix(in srgb, var(--text-secondary) 84%, var(--accent));
font-size: .66rem;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
#navbarNav .dropdown-divider {
margin: .45rem .35rem;
border-color: color-mix(in srgb, var(--accent) 12%, transparent);
}
.navbar-actions > .btn.rounded-circle {
border: 1px solid transparent !important;
box-shadow: 0 3px 9px rgba(15, 76, 117, .07);
transition: transform .14s ease, box-shadow .14s ease, background-color .14s ease;
}
.navbar-actions > .btn.rounded-circle:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 16%, transparent) !important;
box-shadow: 0 7px 16px rgba(15, 76, 117, .13);
} }
/* Nested dropdown support - simplified click-based approach */ /* Nested dropdown support - simplified click-based approach */
@ -1047,16 +1447,16 @@
{% set _can_click_to_call = true %} {% set _can_click_to_call = true %}
<body> <body>
<nav class="navbar navbar-expand-lg fixed-top"> <nav class="navbar navbar-expand-wide fixed-top">
<div class="container-fluid px-4"> <div class="container-fluid px-4">
<a class="navbar-brand d-flex align-items-center" href="/"> <a class="navbar-brand d-flex align-items-center" href="/">
<div class="bg-primary text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 32px; height: 32px; background-color: var(--accent) !important;"> <div class="bmc-brand-mark text-white rounded p-1 me-2 d-flex align-items-center justify-content-center" style="width: 36px; height: 36px;">
<i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i> <i class="bi bi-hdd-network-fill" style="font-size: 16px;"></i>
</div> </div>
BMC Hub <span class="bmc-brand-copy"><span class="bmc-brand-name">BMC Hub</span><span class="bmc-brand-subtitle">Operations</span></span>
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Åbn hovedmenu">
<span class="navbar-toggler-icon"></span> <i class="bi bi-list fs-5"></i><span>Menu</span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav mx-auto"> <ul class="navbar-nav mx-auto">
@ -1100,6 +1500,8 @@
<li data-menu-key="menu-support-tickets"><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li> <li data-menu-key="menu-support-tickets"><a class="dropdown-item py-2" href="/ticket/archived"><i class="bi bi-archive me-2"></i>Arkiverede Tickets</a></li>
<li data-menu-key="menu-support-emails"><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li> <li data-menu-key="menu-support-emails"><a class="dropdown-item py-2" href="/emails"><i class="bi bi-envelope me-2"></i>Email</a></li>
<li data-menu-key="menu-support-telefoni"><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li> <li data-menu-key="menu-support-telefoni"><a class="dropdown-item py-2" href="/telefoni"><i class="bi bi-telephone me-2"></i>Telefoni</a></li>
<li data-menu-key="menu-support-internet-connections"><a class="dropdown-item py-2" href="/economy/internet-connections"><i class="bi bi-hdd-network me-2"></i>Internetforbindelser</a></li>
<li data-menu-key="menu-support-losninger"><a class="dropdown-item py-2 {{ 'active' if request and (request.url.path.startswith('/solutions') or request.url.path.startswith('/knowledge')) else '' }}" href="/solutions"><i class="bi bi-lightbulb me-2"></i>Løsninger</a></li>
<li data-menu-key="menu-support-mission"><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li> <li data-menu-key="menu-support-mission"><a class="dropdown-item py-2" href="/dashboard/mission-control"><i class="bi bi-broadcast-pin me-2"></i>Mission Control</a></li>
<li data-menu-key="menu-support-anydesk"><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li> <li data-menu-key="menu-support-anydesk"><a class="dropdown-item py-2" href="/anydesk/sessions"><i class="bi bi-display me-2"></i>AnyDesk Sessions</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@ -1138,14 +1540,13 @@
<li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li> <li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li> <li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li> <li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li data-menu-key="menu-okonomi-internet-connections"><a class="dropdown-item py-2" href="/economy/internet-connections"><i class="bi bi-hdd-network me-2"></i>Internetforbindelser</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Kontrol</h6></li> <li><h6 class="dropdown-header">Kontrol</h6></li>
<li data-menu-key="menu-okonomi-invoice-error-finder"><a class="dropdown-item py-2" href="/invoice-error-finder"><i class="bi bi-search me-2"></i>Faktura-fejl-finder</a></li> <li data-menu-key="menu-okonomi-invoice-error-finder"><a class="dropdown-item py-2" href="/invoice-error-finder"><i class="bi bi-search me-2"></i>Faktura-fejl-finder</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
<div class="d-flex align-items-center gap-3"> <div class="navbar-actions d-flex align-items-center gap-3">
<div class="dropdown" data-menu-key="menu-datamigration"> <div class="dropdown" data-menu-key="menu-datamigration">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-clock-history me-2"></i>Data migration <i class="bi bi-clock-history me-2"></i>Data migration
@ -1164,6 +1565,7 @@
<li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li> <li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul> </ul>
</div> </div>
<div class="navbar-utility-dock">
<button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)"> <button class="btn btn-light rounded-circle border-0" id="globalSearchBtn" style="background: var(--accent-light); color: var(--accent);" title="Global søgning (Cmd/Ctrl+K)">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
</button> </button>
@ -1193,10 +1595,12 @@
<li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li> <li><a class="dropdown-item py-2" href="/tags#search"><i class="bi bi-tags me-2"></i>Tag søgning</a></li>
<li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li> <li><a class="dropdown-item py-2" href="/backups"><i class="bi bi-hdd-stack me-2"></i>Backup System</a></li>
<li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li> <li><a class="dropdown-item py-2" href="/devportal"><i class="bi bi-code-square me-2"></i>DEV Portal</a></li>
<li id="projectCtProfileLink" class="d-none"><a class="dropdown-item py-2" href="/admin/hub-impact"><i class="bi bi-graph-up-arrow me-2"></i>Project CT · Hub Impact</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2 text-danger" href="#" onclick="logoutUser(event)"><i class="bi bi-box-arrow-right me-2"></i>Log ud</a></li> <li><a class="dropdown-item py-2 text-danger" href="#" onclick="logoutUser(event)"><i class="bi bi-box-arrow-right me-2"></i>Log ud</a></li>
</ul> </ul>
</div> </div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -1755,7 +2159,29 @@ if (bmcOriginalFetch) {
let allResults = []; let allResults = [];
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const primaryMenu = document.querySelector('#navbarNav > .navbar-nav');
const dataMigrationMenu = document.querySelector('#navbarNav > .navbar-actions > [data-menu-key="menu-datamigration"]');
if (primaryMenu && dataMigrationMenu) {
const menuItem = document.createElement('li');
menuItem.className = 'nav-item dropdown';
menuItem.setAttribute('data-menu-key', 'menu-datamigration');
while (dataMigrationMenu.firstChild) menuItem.appendChild(dataMigrationMenu.firstChild);
primaryMenu.appendChild(menuItem);
dataMigrationMenu.remove();
}
loadAndApplyMenuVisibility(); loadAndApplyMenuVisibility();
const currentPath = String(window.location.pathname || '/').replace(/\/$/, '') || '/';
const menuLinks = Array.from(document.querySelectorAll('#navbarNav a[href^="/"]'))
.filter((link) => {
const href = String(link.getAttribute('href') || '').replace(/\/$/, '') || '/';
return href !== '/' && (currentPath === href || currentPath.startsWith(`${href}/`));
})
.sort((a, b) => String(b.getAttribute('href')).length - String(a.getAttribute('href')).length);
const bestMenuLink = menuLinks[0];
if (bestMenuLink) bestMenuLink.classList.add('active');
document.querySelectorAll('#navbarNav .dropdown-item.active').forEach((item) => {
item.closest('.dropdown')?.querySelector(':scope > .nav-link')?.classList.add('section-active');
});
const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal')); const searchModal = new bootstrap.Modal(document.getElementById('globalSearchModal'));
const searchBubbleBtn = document.getElementById('globalSearchBtn'); const searchBubbleBtn = document.getElementById('globalSearchBtn');
const contextManualBtn = document.getElementById('contextManualBtn'); const contextManualBtn = document.getElementById('contextManualBtn');
@ -2630,6 +3056,8 @@ if (bmcOriginalFetch) {
{ key: 'menu-support-tickets', label: 'Support: Arkiverede Tickets' }, { key: 'menu-support-tickets', label: 'Support: Arkiverede Tickets' },
{ key: 'menu-support-emails', label: 'Support: Email' }, { key: 'menu-support-emails', label: 'Support: Email' },
{ key: 'menu-support-telefoni', label: 'Support: Telefoni' }, { key: 'menu-support-telefoni', label: 'Support: Telefoni' },
{ key: 'menu-support-internet-connections', label: 'Support: Internetforbindelser' },
{ key: 'menu-support-losninger', label: 'Support: Løsninger' },
{ key: 'menu-support-mission', label: 'Support: Mission Control' }, { key: 'menu-support-mission', label: 'Support: Mission Control' },
{ key: 'menu-support-anydesk', label: 'Support: AnyDesk Sessions' }, { key: 'menu-support-anydesk', label: 'Support: AnyDesk Sessions' },
{ key: 'menu-support-hardware', label: 'Support: BMC Assets' }, { key: 'menu-support-hardware', label: 'Support: BMC Assets' },
@ -2932,6 +3360,10 @@ if (bmcOriginalFetch) {
avatarEl.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=0f4c75&color=fff`; avatarEl.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=0f4c75&color=fff`;
avatarEl.alt = displayName; avatarEl.alt = displayName;
} }
const projectCtLink = document.getElementById('projectCtProfileLink');
if (projectCtLink && user.is_superadmin === true) {
projectCtLink.classList.remove('d-none');
}
} catch (e) { } catch (e) {
console.error('Failed to load current user identity', e); console.error('Failed to load current user identity', e);
} }

View File

@ -18,7 +18,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <div class="container-fluid pt-2 pb-4">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4"> <div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div> <div>
<h1 class="h3 mb-1">🛠️ Tekniker Dashboard V1</h1> <h1 class="h3 mb-1">🛠️ Tekniker Dashboard V1</h1>

19
main.py
View File

@ -108,6 +108,8 @@ from app.opportunities.frontend import views as opportunities_views
from app.auth.backend import router as auth_api from app.auth.backend import router as auth_api
from app.auth.backend import views as auth_views from app.auth.backend import views as auth_views
from app.auth.backend import admin as auth_admin_api from app.auth.backend import admin as auth_admin_api
from app.admin import router as project_ct_admin_api
from app.admin import views as project_ct_admin_views
from app.devportal.backend import router as devportal_api from app.devportal.backend import router as devportal_api
from app.devportal.backend import views as devportal_views from app.devportal.backend import views as devportal_views
from app.routers import anydesk from app.routers import anydesk
@ -280,6 +282,21 @@ async def lifespan(app: FastAPI):
settings.ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES, settings.ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES,
) )
if settings.PROJECT_CT_ARCHIVE_SYNC_ENABLED:
from app.jobs.project_ct_archive_sync import run_project_ct_archive_sync
backup_scheduler.scheduler.add_job(
func=run_project_ct_archive_sync,
trigger=IntervalTrigger(minutes=settings.PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES),
id='project_ct_archive_sync',
name='Project CT permanent vTiger archive sync',
max_instances=1,
replace_existing=True,
)
logger.info(
"✅ Project CT archive sync scheduled (every %d minutes)",
settings.PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES,
)
backup_scheduler.scheduler.add_job( backup_scheduler.scheduler.add_job(
func=run_uptime_kuma_sync, func=run_uptime_kuma_sync,
trigger=IntervalTrigger(seconds=120), trigger=IntervalTrigger(seconds=120),
@ -505,6 +522,7 @@ app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Bi
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"]) app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"]) app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"]) app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
app.include_router(project_ct_admin_api.router, prefix="/api/v1", tags=["Project CT"])
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"]) app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
app.include_router(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"]) app.include_router(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"])
app.include_router(website_content_api.router, prefix="/api/v1/website-content", tags=["Website Content"]) app.include_router(website_content_api.router, prefix="/api/v1/website-content", tags=["Website Content"])
@ -549,6 +567,7 @@ app.include_router(internet_connections_views.router, tags=["Frontend"])
app.include_router(invoice_error_finder_views.router, tags=["Frontend"]) app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
app.include_router(migration_center_views.router, tags=["Frontend"]) app.include_router(migration_center_views.router, tags=["Frontend"])
app.include_router(website_content_views.router, tags=["Frontend"]) app.include_router(website_content_views.router, tags=["Frontend"])
app.include_router(project_ct_admin_views.router, tags=["Project CT Frontend"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS ai_benchmark_runs (
id BIGSERIAL PRIMARY KEY,
status VARCHAR(24) NOT NULL DEFAULT 'queued',
models JSONB NOT NULL DEFAULT '[]'::jsonb,
test_keys JSONB NOT NULL DEFAULT '[]'::jsonb,
total_cases INTEGER NOT NULL DEFAULT 0,
completed_cases INTEGER NOT NULL DEFAULT 0,
created_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_text TEXT
);
CREATE TABLE IF NOT EXISTS ai_benchmark_results (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES ai_benchmark_runs(id) ON DELETE CASCADE,
model VARCHAR(160) NOT NULL,
test_key VARCHAR(100) NOT NULL,
test_name VARCHAR(200) NOT NULL,
category VARCHAR(80) NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
max_score INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
prompt_tokens INTEGER,
response_tokens INTEGER,
tokens_per_second NUMERIC(10, 2),
passed_checks JSONB NOT NULL DEFAULT '[]'::jsonb,
failed_checks JSONB NOT NULL DEFAULT '[]'::jsonb,
response_json JSONB,
response_text TEXT,
error_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(run_id, model, test_key)
);
CREATE INDEX IF NOT EXISTS idx_ai_benchmark_runs_created
ON ai_benchmark_runs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_benchmark_results_run
ON ai_benchmark_results(run_id, model);

View File

@ -0,0 +1,2 @@
ALTER TABLE ai_benchmark_results
ADD COLUMN IF NOT EXISTS expected_json JSONB;

View File

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS internet_connection_change_cases (
id BIGSERIAL PRIMARY KEY,
connection_id INTEGER NOT NULL REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
source_type VARCHAR(80) NOT NULL,
source_key VARCHAR(255) NOT NULL,
source_label TEXT,
source_url TEXT,
sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(connection_id, source_type, source_key)
);
CREATE INDEX IF NOT EXISTS idx_internet_change_cases_sag
ON internet_connection_change_cases(sag_id);

View File

@ -0,0 +1,85 @@
-- Project CT: permanent, versioned and application-read-only vTiger archive.
CREATE TABLE IF NOT EXISTS vtiger_archive_versions (
id BIGSERIAL PRIMARY KEY,
sync_kind VARCHAR(20) NOT NULL CHECK (sync_kind IN ('full','incremental','final')),
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
source_cutoff TIMESTAMPTZ NOT NULL DEFAULT NOW(),
initiated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
module_counts JSONB NOT NULL DEFAULT '{}'::jsonb,
warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
critical_errors JSONB NOT NULL DEFAULT '[]'::jsonb,
control_report JSONB NOT NULL DEFAULT '{}'::jsonb,
control_approved_at TIMESTAMPTZ,
control_approved_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
raw_export_sha256 VARCHAR(64),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS vtiger_archive_records (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
module VARCHAR(80) NOT NULL,
vtiger_id VARCHAR(120) NOT NULL,
revision_no INTEGER NOT NULL,
source_created_at TIMESTAMPTZ,
source_modified_at TIMESTAMPTZ,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
payload JSONB NOT NULL,
payload_sha256 VARCHAR(64) NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(module, vtiger_id, revision_no),
UNIQUE(version_id, module, vtiger_id, payload_sha256)
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_records_lookup
ON vtiger_archive_records(module, vtiger_id, revision_no DESC);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_records_version
ON vtiger_archive_records(version_id, module);
CREATE TABLE IF NOT EXISTS vtiger_archive_relations (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
source_module VARCHAR(80) NOT NULL,
source_vtiger_id VARCHAR(120) NOT NULL,
field_name VARCHAR(120) NOT NULL,
target_vtiger_id VARCHAR(120) NOT NULL,
target_module VARCHAR(80),
UNIQUE(version_id, source_module, source_vtiger_id, field_name, target_vtiger_id)
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_relations_target
ON vtiger_archive_relations(target_vtiger_id);
CREATE TABLE IF NOT EXISTS vtiger_archive_checkpoints (
module VARCHAR(80) PRIMARY KEY,
last_modified_at TIMESTAMPTZ,
last_vtiger_id VARCHAR(120),
last_successful_version_id BIGINT REFERENCES vtiger_archive_versions(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS hub_impact_reports (
id BIGSERIAL PRIMARY KEY,
archive_version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
vtiger_from DATE NOT NULL,
vtiger_to DATE NOT NULL,
hub_from DATE NOT NULL,
hub_to DATE NOT NULL,
filters JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB NOT NULL,
result_sha256 VARCHAR(64) NOT NULL,
generated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE OR REPLACE FUNCTION deny_vtiger_archive_mutation() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'vTiger archive revisions are append-only';
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_vtiger_archive_records_immutable ON vtiger_archive_records;
CREATE TRIGGER trg_vtiger_archive_records_immutable
BEFORE UPDATE OR DELETE ON vtiger_archive_records
FOR EACH ROW EXECUTE FUNCTION deny_vtiger_archive_mutation();

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS vtiger_archive_transfers (
id BIGSERIAL PRIMARY KEY,
direction VARCHAR(10) NOT NULL CHECK (direction IN ('export','import')),
bundle_sha256 VARCHAR(64),
through_version_id BIGINT,
source_instance VARCHAR(255),
status VARCHAR(20) NOT NULL CHECK (status IN ('running','completed','failed')),
counts JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT,
initiated_by INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_transfers_started ON vtiger_archive_transfers(started_at DESC);

View File

@ -0,0 +1,21 @@
-- Project CT: preserve actual vTiger document bytes before the subscription ends.
CREATE TABLE IF NOT EXISTS vtiger_archive_files (
id BIGSERIAL PRIMARY KEY,
version_id BIGINT NOT NULL REFERENCES vtiger_archive_versions(id) ON DELETE RESTRICT,
document_vtiger_id VARCHAR(120) NOT NULL,
resource_vtiger_id VARCHAR(120) NOT NULL,
filename TEXT NOT NULL,
content_type TEXT,
size_bytes BIGINT NOT NULL,
content_sha256 VARCHAR(64) NOT NULL,
content BYTEA NOT NULL,
archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(document_vtiger_id, resource_vtiger_id, content_sha256)
);
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_files_version
ON vtiger_archive_files(version_id);
DROP TRIGGER IF EXISTS trg_vtiger_archive_files_immutable ON vtiger_archive_files;
CREATE TRIGGER trg_vtiger_archive_files_immutable
BEFORE UPDATE OR DELETE ON vtiger_archive_files
FOR EACH ROW EXECUTE FUNCTION deny_vtiger_archive_mutation();

View File

@ -0,0 +1,5 @@
-- Document bytes can originate from Documents or email attachments.
ALTER TABLE vtiger_archive_files
ADD COLUMN IF NOT EXISTS source_module VARCHAR(80) NOT NULL DEFAULT 'Documents';
CREATE INDEX IF NOT EXISTS idx_vtiger_archive_files_source
ON vtiger_archive_files(source_module, document_vtiger_id);

View File

@ -0,0 +1,11 @@
-- Direct, customer-independent links between cases and internet connections.
CREATE TABLE IF NOT EXISTS sag_internet_connections (
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
connection_id INTEGER NOT NULL REFERENCES internet_connections_connections(id) ON DELETE RESTRICT,
linked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (sag_id, connection_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_internet_connections_connection
ON sag_internet_connections(connection_id, created_at DESC);

View File

@ -0,0 +1,12 @@
-- Central solution management: reversible archiving without losing case history.
ALTER TABLE sag_solutions
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS deleted_by_user_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_sag_solutions_management
ON sag_solutions(deleted_at, approval_status, visibility, updated_at DESC);
ALTER TABLE knowledge_articles
ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS archived_by_user_id INTEGER;

View File

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Small, deterministic CRM benchmark for models exposed through Ollama."""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.request
CASES = [
{
"name": "contact_signature",
"prompt": """Extract contact data from this email as JSON with exactly these keys:
name, title, company, mobile, email. Do not mistake greetings such as 'Kind regards'
for a job title. Use null when a value is absent.
Kind regards
Ida Gundersen
Technical Advisor & Co-owner
Mobile: +45 42 25 59 08 DK: +45 55 86 05 00
Email: ida@createx-onstage.com
Web: createx-onstage.com
Createx
Storegade 4C | 4780 Stege, DK""",
"checks": [
("name", lambda value: "ida gundersen" in value.lower()),
("title", lambda value: "technical advisor" in value.lower() and "kind regards" not in value.lower()),
("company", lambda value: "createx" in value.lower()),
("mobile", lambda value: "42255908" in re.sub(r"\D", "", value)),
("email", lambda value: value.lower() == "ida@createx-onstage.com"),
],
},
{
"name": "internet_invoice",
"prompt": """Extract this supplier invoice line as JSON with exactly these keys:
reference, address, postal_code, city, ip_ranges, purchase_price_dkk.
ip_ranges must be an array and purchase_price_dkk a number.
GlobalConnect faktura 3018657
NKA-027964 | Firskovvej 36 | 2800 Kongens Lyngby | Internet 1 Gbit
IP: 217.74.219.56/30 og 152.115.61.32/27 | Månedlig kostpris: 2.495,00 kr.""",
"checks": [
("reference", lambda value: "027964" in value),
("address", lambda value: "firskovvej 36" in value.lower()),
("postal_code", lambda value: str(value) == "2800"),
("city", lambda value: "lyngby" in value.lower()),
("ip_ranges", lambda value: set(value) == {"217.74.219.56/30", "152.115.61.32/27"}),
("purchase_price_dkk", lambda value: abs(float(value) - 2495.0) < 0.01),
],
},
{
"name": "support_solution",
"prompt": """Analyze this support case and return JSON with exactly these keys:
category, probable_cause, diagnostic_steps, customer_reply. diagnostic_steps must be
an array of short actions. Do not claim the problem has been fixed.
Customer: Internet works by IP address, but websites do not open by name on all PCs.
The router is reachable and 8.8.8.8 responds to ping. The issue started after the
customer changed DNS settings this morning.""",
"checks": [
("cause", lambda value: "dns" in json.dumps(value).lower()),
("steps", lambda value: isinstance(value, list) and len(value) >= 2),
("uncertainty", lambda value: not any(word in value.lower() for word in ("løst", "fixed", "resolved"))),
],
},
{
"name": "danish_rewrite",
"prompt": """Rewrite this as a concise, professional Danish customer email. Preserve every
fact and do not invent a resolution. Return JSON with exactly one key: text.
hej vi kan se jeres forbindelse NKA-027964 Firskovvej 36 har været nede siden
kl 08:15. vi undersøger det hos globalconnect og vender tilbage senest kl 10:00.""",
"checks": [
("reference", lambda value: "NKA-027964" in value),
("address", lambda value: "Firskovvej 36" in value),
("times", lambda value: "08:15" in value and "10:00" in value),
("supplier", lambda value: "globalconnect" in value.lower()),
],
},
]
def ask(endpoint: str, model: str, prompt: str, timeout: int) -> tuple[dict, float]:
payload = {
"model": model,
"stream": False,
"think": False,
"format": "json",
"messages": [
{"role": "system", "content": "Return only valid JSON. Follow the requested schema exactly."},
{"role": "user", "content": prompt},
],
"options": {"temperature": 0, "num_ctx": 8192},
}
started = time.perf_counter()
request = urllib.request.Request(
f"{endpoint.rstrip('/')}/api/chat",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
envelope = json.load(response)
elapsed = time.perf_counter() - started
content = envelope.get("message", {}).get("content", "")
return json.loads(content), elapsed
def score(case: dict, result: dict) -> tuple[int, list[str]]:
passed = []
if case["name"] == "support_solution":
values = {
"cause": result,
"steps": result.get("diagnostic_steps"),
"uncertainty": str(result.get("customer_reply", "")),
}
elif case["name"] == "danish_rewrite":
values = {label: str(result.get("text", "")) for label, _ in case["checks"]}
else:
values = result
for label, check in case["checks"]:
try:
if check(values.get(label) if isinstance(values, dict) else values):
passed.append(label)
except (AttributeError, TypeError, ValueError):
pass
return len(passed), passed
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--endpoint", default="http://172.16.31.195:11434")
parser.add_argument("--models", nargs="+", default=["qwen2.5:7b", "qwen3.5:9b"])
parser.add_argument("--cases", nargs="+", help="Run only the named benchmark cases")
parser.add_argument("--timeout", type=int, default=180)
args = parser.parse_args()
report = []
for model in args.models:
model_score = 0
model_max = 0
model_seconds = 0.0
cases = []
selected_cases = [case for case in CASES if not args.cases or case["name"] in args.cases]
for case in selected_cases:
maximum = len(case["checks"])
model_max += maximum
try:
result, seconds = ask(args.endpoint, model, case["prompt"], args.timeout)
points, passed = score(case, result)
error = None
except Exception as exc: # benchmark must report failures and continue
result, seconds, points, passed, error = None, 0.0, 0, [], str(exc)
model_score += points
model_seconds += seconds
cases.append({
"case": case["name"], "score": points, "max": maximum,
"seconds": round(seconds, 2), "passed": passed,
"error": error, "result": result,
})
report.append({
"model": model, "score": model_score, "max": model_max,
"seconds": round(model_seconds, 2), "cases": cases,
})
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,47 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.modules.internet_connections.backend import change_case_service as service
def test_relevant_change_filter_excludes_manual_classification_and_customer():
changes = service.filter_relevant_changes({
'monthly_cost': {'from': 100, 'to': 125},
'customer_id': {'from': None, 'to': 7},
'allocation_model': {'from': 'dedicated', 'to': 'shared'},
'notes': {'from': 'før', 'to': 'efter'},
})
assert changes == {'monthly_cost': {'from': 100, 'to': 125}}
def test_same_import_merges_into_existing_case(monkeypatch):
writes = []
monkeypatch.setattr(service, 'execute_query_single', lambda query, params=None: (
{'id': 4, 'sag_id': 91, 'changes': {'monthly_cost': {'from': 100, 'to': 125}}}
if 'FROM internet_connection_change_cases' in query else None
))
monkeypatch.setattr(service, 'execute_query', lambda query, params=None, fetch=True: writes.append((query, params)))
result = service.ensure_external_change_case(
connection_id=5, source_type='test_import', source_key='same-file',
source_label='Testfil', connection_name='Fiber', reference='NKA-1', provider='Leverandør',
owner_customer_id=7, changes={'speed_mbps': {'from': 100, 'to': 200}},
)
assert result['case_id'] == 91
assert result['created'] is False
assert set(result['changes']) == {'monthly_cost', 'speed_mbps'}
assert any('UPDATE sag_sager' in query for query, _ in writes)
def test_case_failure_is_reported_without_raising(monkeypatch):
monkeypatch.setattr(service, 'execute_query_single', lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError('database down')))
monkeypatch.setattr(service, 'execute_query', lambda *args, **kwargs: None)
result = service.ensure_external_change_case(
connection_id=5, source_type='test', source_key='run-1', source_label='Test',
changes={'status': {'from': 'active', 'to': 'inactive'}},
)
assert result['case_id'] is None
assert result['error'] == 'database down'

View File

@ -87,6 +87,38 @@ def test_ip_nordic_import_preview_never_assigns_customer(monkeypatch):
assert response.json()['customer_auto_assignment'] is False assert response.json()['customer_auto_assignment'] is False
def test_ip_nordic_existing_connection_creates_one_change_case_without_customer_assignment(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
writes = []
cases = []
monkeypatch.setattr(internet_router, 'execute_query_single', lambda query, params=None: {
'id': 44, 'name': 'IP Nordic forbindelse', 'address': 'Engholm Parkvej 8, 3450 Allerød',
'customer_id': None, 'monthly_cost': 1000, 'sales_price': 2000,
'provider': 'IP Nordic', 'status': 'active', 'technology': 'fiber',
'connection_type': 'fiber', 'circuit_number': None,
})
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None, fetch=True: writes.append((query, params)))
monkeypatch.setattr(internet_router, '_create_history_entry', lambda *args: writes.append(args))
monkeypatch.setattr(internet_router, 'ensure_external_change_case', lambda **kwargs: cases.append(kwargs) or {
'case_id': 88, 'created': True, 'error': None,
})
response = TestClient(app).post(
'/api/v1/internet-connections/import/ip-nordic',
files={'file': ('IP_Nordic.xlsx', _build_ip_nordic_test_xlsx(), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')},
data={'commit': 'true'},
)
assert response.status_code == 200
assert response.json()['updated_count'] == 1
assert response.json()['change_case_ids'] == [88]
assert len(cases) == 1
assert cases[0]['owner_customer_id'] is None
assert cases[0]['changes']['monthly_cost']['to'] == 1474
assert all('customer_id=' not in str(query).lower() for query, *_ in writes if isinstance(query, str))
def test_internet_connections_page_has_ip_nordic_preview_import(): def test_internet_connections_page_has_ip_nordic_preview_import():
template = Path('app/modules/internet_connections/templates/index.html').read_text() template = Path('app/modules/internet_connections/templates/index.html').read_text()

View File

@ -0,0 +1,257 @@
import asyncio
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.admin import router
from app.admin import vtiger_archive as archive
from app.admin import hub_impact
from app.admin import archive_bundle
from app.services.vtiger_service import VTigerService
def test_hidden_admin_dependency_returns_404_for_non_superadmin():
with pytest.raises(HTTPException) as exc:
router.require_hidden_superadmin({'username': 'employee', 'is_superadmin': False})
assert exc.value.status_code == 404
def test_archive_record_is_append_only_and_deduplicated(monkeypatch):
writes = []
previous = {'revision_no': 1, 'payload_sha256': 'different'}
monkeypatch.setattr(archive, 'execute_query_single', lambda query, params=None: previous if 'payload_sha256' in query else None)
monkeypatch.setattr(archive, 'execute_query', lambda query, params=None, fetch=True: writes.append((query, params)))
inserted = archive.archive_record(3, 'Accounts', {
'id': '3x44', 'accountname': 'Test', 'contact_id': '4x99', 'modifiedtime': '2026-08-30 10:00:00',
})
assert inserted is True
assert any('INSERT INTO vtiger_archive_records' in sql for sql, _ in writes)
assert any('INSERT INTO vtiger_archive_relations' in sql for sql, _ in writes)
def test_unchanged_payload_does_not_create_revision(monkeypatch):
record = {'id': '3x44', 'accountname': 'Test'}
_, digest = archive._canonical_payload(record)
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: {'revision_no': 2, 'payload_sha256': digest})
monkeypatch.setattr(archive, 'execute_query', lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError('must not write')))
assert archive.archive_record(4, 'Accounts', record) is False
def test_readiness_requires_successful_approved_final(monkeypatch):
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: {
'id': 9, 'status': 'completed', 'critical_errors': [], 'control_approved_at': '2026-08-30',
})
assert archive.termination_readiness()['ready'] is True
monkeypatch.setattr(archive, 'execute_query_single', lambda *args, **kwargs: None)
result = archive.termination_readiness()
assert result['ready'] is False
assert len(result['reasons']) == 2
def test_failed_vtiger_query_is_not_treated_as_empty_module(monkeypatch):
class Service:
last_query_error = {'message': 'rate limited'}
last_query_status = 429
async def query(self, query):
return []
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
with pytest.raises(RuntimeError):
asyncio.run(archive._fetch_module('Accounts'))
def test_failed_vtiger_count_cannot_pass_full_archive_control(monkeypatch):
class Service:
last_query_error = {'message': 'timeout'}
last_query_status = None
async def query(self, query):
return []
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
with pytest.raises(RuntimeError, match='kontrollere antal poster'):
asyncio.run(archive._source_module_count('Accounts'))
def test_vtiger_file_retrieve_accepts_list_result(monkeypatch):
import base64
class Response:
status = 200
async def __aenter__(self): return self
async def __aexit__(self, *args): return None
async def json(self, content_type=None):
return {'success': True, 'result': [{
'fileid': '7x2', 'filename': 'test.txt', 'filecontents': base64.b64encode(b'hello').decode(),
}]}
class Session:
async def __aenter__(self): return self
async def __aexit__(self, *args): return None
def get(self, *args, **kwargs): return Response()
monkeypatch.setattr('app.services.vtiger_service.aiohttp.ClientSession', Session)
service = VTigerService()
service.rest_endpoint = 'https://example.invalid'
service.api_key = 'key'
service.username = 'user'
result = asyncio.run(service.retrieve_file('7x2'))
assert result['content'] == b'hello'
def test_vtiger_archive_uses_offset_pagination(monkeypatch):
queries = []
class Service:
last_query_error = None
last_query_status = 200
async def query(self, query):
queries.append(query)
if 'LIMIT 0, 100' in query:
return [{'id': f'3x{i}'} for i in range(100)]
if 'LIMIT 100, 100' in query:
return [{'id': '3x101'}, {'id': '3x102'}]
return []
async def no_sleep(_seconds):
return None
monkeypatch.setattr(archive, 'get_vtiger_service', lambda: Service())
monkeypatch.setattr(archive.asyncio, 'sleep', no_sleep)
rows = asyncio.run(archive._fetch_module('Accounts'))
assert len(rows) == 102
assert 'LIMIT 0, 100' in queries[0]
assert 'LIMIT 100, 100' in queries[1]
assert 'LIMIT 102, 100' in queries[2]
def test_project_ct_migration_enforces_immutable_revisions():
sql = Path('migrations/1030_project_ct_vtiger_archive.sql').read_text()
assert 'vtiger_archive_versions' in sql
assert 'vtiger_archive_records' in sql
assert 'vtiger_archive_relations' in sql
assert 'vtiger_archive_checkpoints' in sql
assert 'hub_impact_reports' in sql
assert 'BEFORE UPDATE OR DELETE ON vtiger_archive_records' in sql
file_sql = Path('migrations/1032_project_ct_archive_files.sql').read_text()
assert 'vtiger_archive_files' in file_sql
assert 'BEFORE UPDATE OR DELETE ON vtiger_archive_files' in file_sql
assert 'source_module' in Path('migrations/1033_project_ct_archive_file_sources.sql').read_text()
def test_impact_periods_must_be_equal(monkeypatch):
from datetime import date
with pytest.raises(ValueError, match='lige lange'):
hub_impact.build_impact_report(1, date(2025, 1, 1), date(2025, 1, 10),
date(2026, 1, 1), date(2026, 1, 9))
def test_vtiger_employee_matching_and_long_entry_exclusion():
from datetime import date
records = [
{'module': 'Users', 'vtiger_id': '19x1', 'is_deleted': False,
'payload': {'id': '19x1', 'email1': 'TECH@EXAMPLE.COM', 'first_name': 'Old', 'last_name': 'Name'}},
{'module': 'Timelog', 'vtiger_id': '36x1', 'is_deleted': False,
'payload': {'createdtime': '2025-01-03 10:00:00', 'assigned_user_id': '19x1', 'time_spent': '02:30'}},
{'module': 'Timelog', 'vtiger_id': '36x2', 'is_deleted': False,
'payload': {'createdtime': '2025-01-04 10:00:00', 'assigned_user_id': '19x1', 'hours': '17'}},
{'module': 'Cases', 'vtiger_id': '17x3', 'is_deleted': False,
'payload': {'createdtime': '2025-01-05 10:00:00', 'assigned_user_id': '19x1'}},
]
result = hub_impact._finalize(
hub_impact._vtiger_metrics(records, date(2025, 1, 1), date(2025, 1, 10), {
'tech@example.com': {'user_id': 7, 'email': 'tech@example.com', 'full_name': 'Hub Technician'}
}), date(2025, 1, 1), date(2025, 1, 10),
)
assert result['totals']['hours'] == 2.5
assert result['totals']['time_entries'] == 1
assert result['totals']['cases'] == 1
assert result['employees'][0]['key'] == 'hub:7'
assert result['anomalies'][0]['type'] == 'over_16_hours'
def test_hub_active_timer_is_anomaly_not_kpi(monkeypatch):
from datetime import date
def fake_query(sql, params=None):
if 'FROM tmodule_times' in sql:
return [{'id': 1, 'medarbejder_id': 4, 'aktiv_timer': True, 'hours': 8},
{'id': 2, 'medarbejder_id': 4, 'aktiv_timer': False, 'hours': 2}]
return []
monkeypatch.setattr(hub_impact, 'execute_query', fake_query)
metrics = hub_impact._finalize(hub_impact._hub_metrics(
date(2026, 1, 1), date(2026, 1, 2),
{4: {'user_id': 4, 'email': 'a@example.com', 'full_name': 'A'}},
), date(2026, 1, 1), date(2026, 1, 2))
assert metrics['totals']['hours'] == 2
assert metrics['totals']['time_entries'] == 1
assert metrics['anomalies'][0]['type'] == 'active_timer'
def test_excel_export_contains_required_sheets():
from app.admin.router import _impact_workbook
load_workbook = pytest.importorskip('openpyxl').load_workbook
import io
result = {
'archive_version': {'id': 2},
'periods': {'vtiger': {'from': '2025-01-01', 'to': '2025-01-31'}, 'hub': {'from': '2026-01-01', 'to': '2026-01-31'}},
'comparison': {'hours': 1, 'time_entries': 2, 'cases': 3, 'orders': 4},
'vtiger': {'totals': {'hours': 1, 'time_entries': 1, 'cases': 1, 'orders': 1}, 'employees': [], 'anomalies': [], 'data_quality': {'missing_dates': 0, 'unmatched_employees': []}},
'hub': {'totals': {'hours': 2, 'time_entries': 3, 'cases': 4, 'orders': 5}, 'employees': [], 'anomalies': [], 'data_quality': {'missing_dates': 0, 'unmatched_employees': []}},
}
workbook = load_workbook(io.BytesIO(_impact_workbook(result)))
assert workbook.sheetnames == ['Overblik', 'Effekt', 'Pr medarbejder', 'Afvigelser', 'Datakvalitet']
def test_secret_page_and_profile_link_are_registered():
assert '@router.get("/admin/hub-impact"' in Path('app/admin/views.py').read_text()
base = Path('app/shared/frontend/base.html').read_text()
assert 'projectCtProfileLink' in base
assert "user.is_superadmin === true" in base
def test_bundle_checksum_rejects_changed_content(tmp_path):
import hashlib
import json
import zipfile
bundle = tmp_path / 'archive.zip'
with zipfile.ZipFile(bundle, 'w') as archive_file:
archive_file.writestr('records.jsonl', b'{"id":1}\n')
archive_file.writestr('manifest.json', json.dumps({
'schema_version': 1,
'entries': {'records.jsonl': {'sha256': hashlib.sha256(b'other').hexdigest()}},
}))
with zipfile.ZipFile(bundle) as archive_file:
manifest = json.loads(archive_file.read('manifest.json'))
with pytest.raises(ValueError, match='Checksumfejl'):
archive_bundle._verify_entry(archive_file, manifest, 'records.jsonl')
def test_bundle_transfer_contains_original_files_and_never_uses_vtiger():
source = Path('app/admin/archive_bundle.py').read_text()
assert 'document_policy' in source
assert 'metadata_and_original_bytes' in source
assert 'files.jsonl' in source
assert 'contacted_vtiger": False' in source
assert 'vtiger_service' not in source
router_source = Path('app/admin/router.py').read_text()
assert '/admin/vtiger-archive/bundle.zip' in router_source
assert '/admin/vtiger-archive/import-bundle' in router_source
def test_productivity_exposes_clear_daily_efficiency_metrics():
from datetime import date
metrics = {'employees': {
'hub:1': {'key': 'hub:1', 'name': 'A', 'email': 'a@example.com', 'hours': hub_impact.Decimal('8'),
'time_entries': 4, 'cases': 2, 'orders': 1}},
'anomalies': [], 'data_quality': {'unmatched_employees': [], 'missing_dates': 0}}
result = hub_impact._finalize(metrics, date(2026, 8, 31), date(2026, 8, 31))
assert result['totals']['productivity']['deliveries_per_workday'] == 7
assert result['totals']['productivity']['hours_per_case_or_order'] == 2.67

View File

@ -176,6 +176,20 @@ def test_case_create_sends_relations_in_atomic_create_payload():
assert "Omdirigerer..." not in template assert "Omdirigerer..." not in template
def test_case_can_link_internet_connection_without_customer_allocation():
template = Path("app/modules/sag/templates/create.html").read_text(encoding="utf-8")
sag_router = Path("app/modules/sag/backend/router.py").read_text(encoding="utf-8")
internet_router = Path("app/modules/internet_connections/backend/router.py").read_text(encoding="utf-8")
detail = Path("app/modules/internet_connections/templates/detail.html").read_text(encoding="utf-8")
migration = Path("migrations/1034_sag_internet_connections.sql").read_text(encoding="utf-8")
assert "internet_connection_ids" in template
assert "INSERT INTO sag_internet_connections" in sag_router
assert "/sag/{sag_id}/internet-connections" in sag_router
assert "/internet-connections/{connection_id:int}/cases" in internet_router
assert "Sager på forbindelsen" in detail
assert "sag_internet_connections" in migration
def test_case_detail_has_no_overwriting_relation_functions_or_blocking_alerts(): def test_case_detail_has_no_overwriting_relation_functions_or_blocking_alerts():
template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8") template = Path("app/modules/sag/templates/detail_v3.html").read_text(encoding="utf-8")
assert len(re.findall(r"function\s+removeContact\s*\(", template)) == 1 assert len(re.findall(r"function\s+removeContact\s*\(", template)) == 1

View File

@ -0,0 +1,76 @@
from pathlib import Path
import sys
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.modules.sag.backend import solutions
def test_solution_payload_normalizes_legacy_quick_action_values():
payload = solutions._normalize_payload({
"title": " Outlook virker igen ",
"solution_type": "standard",
"result": "resolved",
"visibility": "general",
"tags": ["Outlook", " outlook ", "Microsoft 365"],
})
assert payload["title"] == "Outlook virker igen"
assert payload["solution_type"] == "Support"
assert payload["result"] == "Løst"
assert payload["tags"] == ["Outlook", "Microsoft 365"]
def test_solution_payload_rejects_empty_title():
with pytest.raises(HTTPException) as exc:
solutions._normalize_payload({"title": " "})
assert exc.value.status_code == 422
def test_solution_payload_rejects_unknown_visibility():
with pytest.raises(HTTPException) as exc:
solutions._normalize_payload({"visibility": "public-internet"})
assert exc.value.status_code == 422
def test_ai_context_redacts_credentials_before_processing():
cleaned, warnings = solutions._redact_sensitive(
"Login til router: password=SuperSecret og Authorization: Bearer abc.def.ghi"
)
assert "SuperSecret" not in cleaned
assert "abc.def.ghi" not in cleaned
assert len(warnings) == 2
def test_knowledge_tokens_are_stable_and_remove_fill_words():
assert solutions._knowledge_tokens("Outlook kan ikke synkronisere Outlook med Microsoft 365") == [
"outlook", "synkronisere", "microsoft", "365"
]
def test_ai_source_references_accept_common_model_variants():
refs = solutions._normalize_source_refs(
"[Sag #135], Kommentar #12; Artikel 4",
{"Sag 135", "Kommentar 12", "Artikel 4"},
135,
)
assert refs == ["Sag 135", "Kommentar 12", "Artikel 4"]
def test_customer_articles_are_excluded_without_customer_scope(monkeypatch):
captured = []
def fake_single(query, params=None):
captured.append((query, params))
return {"total": 0}
monkeypatch.setattr(solutions, "execute_query_single", fake_single)
monkeypatch.setattr(solutions, "execute_query", lambda query, params=None: [])
import asyncio
result = asyncio.run(solutions.search_knowledge_articles(q="vpn", customer_id=None, _current_user={"id": 1}))
assert result["total"] == 0
assert "ka.visibility IN ('general','internal')" in captured[0][0]
assert "visibility='customer'" not in captured[0][0]