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:
parent
adc4fb5876
commit
1cfe5aee76
1
app/admin/__init__.py
Normal file
1
app/admin/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Restricted administrative reporting modules."""
|
||||||
254
app/admin/archive_bundle.py
Normal file
254
app/admin/archive_bundle.py
Normal 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
60
app/admin/hub_impact.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
235
app/admin/hub_impact.py
Normal 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
344
app/admin/router.py
Normal 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
13
app/admin/views.py
Normal 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
378
app/admin/vtiger_archive.py
Normal 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}
|
||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
20
app/jobs/project_ct_archive_sync.py
Normal file
20
app/jobs/project_ct_archive_sync.py
Normal 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")
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
168
app/modules/internet_connections/backend/change_case_service.py
Normal file
168
app/modules/internet_connections/backend/change_case_service.py
Normal 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)}
|
||||||
@ -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."""
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
@ -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 på dansk.
|
||||||
|
- confidence er et tal mellem 0 og 1.
|
||||||
|
"""
|
||||||
|
service = CaseAnalysisService()
|
||||||
|
# A full case history is materially larger than QuickCreate input. Keep the
|
||||||
|
# global QuickCreate timeout unchanged, but allow the local model time to
|
||||||
|
# produce a source-grounded solution draft.
|
||||||
|
service.ai_timeout = max(service.ai_timeout, 60)
|
||||||
|
result = await service._call_ollama(prompt, context)
|
||||||
|
if not result:
|
||||||
|
warnings.append("Den lokale AI-tjeneste er utilgængelig; der vises en sikker grundkladde uden AI-genererede konklusioner")
|
||||||
|
result = {
|
||||||
|
"title": case.get("titel") or "",
|
||||||
|
"problem": case.get("beskrivelse") or "",
|
||||||
|
"root_cause": "",
|
||||||
|
"investigation": "",
|
||||||
|
"description": "",
|
||||||
|
"workaround": "",
|
||||||
|
"solution_type": "Support",
|
||||||
|
"result": "Ej løst",
|
||||||
|
"tags": _knowledge_tokens(case.get("tag_text") or "")[:10],
|
||||||
|
"products": [],
|
||||||
|
"confidence": 0.2,
|
||||||
|
"uncertainties": ["Årsag og endelig løsning skal udfyldes af en medarbejder"],
|
||||||
|
"source_refs": [f"Sag {sag_id}"],
|
||||||
|
}
|
||||||
|
allowed_refs = {f"Kommentar {row['id']}" for row in comments} | {f"Mail {row['id']}" for row in emails} | {f"Tid {row['id']}" for row in time_entries} | {f"Artikel {row['id']}" for row in articles} | {f"Sag {sag_id}"}
|
||||||
|
source_refs = _normalize_source_refs(result.get("source_refs", []), allowed_refs, sag_id)
|
||||||
|
draft = {
|
||||||
|
"title": str(result.get("title") or case.get("titel") or "")[:255],
|
||||||
|
"problem": str(result.get("problem") or ""),
|
||||||
|
"root_cause": str(result.get("root_cause") or ""),
|
||||||
|
"investigation": str(result.get("investigation") or ""),
|
||||||
|
"description": str(result.get("description") or ""),
|
||||||
|
"workaround": str(result.get("workaround") or ""),
|
||||||
|
"solution_type": TYPE_ALIASES.get(str(result.get("solution_type") or "Support").casefold(), "Support"),
|
||||||
|
"result": RESULT_ALIASES.get(str(result.get("result") or "Ej løst").casefold(), "Ej løst"),
|
||||||
|
"tags": _clean_list(result.get("tags")),
|
||||||
|
"products": _clean_list(result.get("products")),
|
||||||
|
"confidence": max(0.0, min(1.0, float(result.get("confidence") or 0))),
|
||||||
|
"uncertainties": _clean_list(result.get("uncertainties")),
|
||||||
|
"source_refs": source_refs,
|
||||||
|
}
|
||||||
|
if not draft["description"]:
|
||||||
|
warnings.append("Sagen indeholder ikke en sikkert dokumenteret endelig løsning")
|
||||||
|
if not source_refs:
|
||||||
|
warnings.append("AI-udkastet indeholdt ingen gyldige kildehenvisninger")
|
||||||
|
return {"draft": draft, "warnings": warnings, "model": service.ollama_model, "review_required": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/solutions")
|
||||||
|
async def list_solutions(
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
approval_status: Optional[str] = None,
|
||||||
|
visibility: Optional[str] = None,
|
||||||
|
include_archived: bool = False,
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
_current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
where = ["s.deleted_at IS NOT NULL" if include_archived else "s.deleted_at IS NULL"]
|
||||||
|
params: list = []
|
||||||
|
if approval_status:
|
||||||
|
if approval_status not in APPROVAL_STATUSES:
|
||||||
|
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
|
||||||
|
where.append("s.approval_status=%s")
|
||||||
|
params.append(approval_status)
|
||||||
|
if visibility:
|
||||||
|
if visibility not in VISIBILITIES:
|
||||||
|
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
|
||||||
|
where.append("s.visibility=%s")
|
||||||
|
params.append(visibility)
|
||||||
|
term = q.strip()
|
||||||
|
if term:
|
||||||
|
where.append("(s.title ILIKE %s OR s.description ILIKE %s OR s.problem ILIKE %s OR sg.titel ILIKE %s OR c.name ILIKE %s OR s.tags::text ILIKE %s OR s.products::text ILIKE %s)")
|
||||||
|
like = f"%{term}%"
|
||||||
|
params.extend([like] * 7)
|
||||||
|
where_sql = " AND ".join(where)
|
||||||
|
count = execute_query_single(
|
||||||
|
f"""SELECT COUNT(*) AS total FROM sag_solutions s
|
||||||
|
JOIN sag_sager sg ON sg.id=s.sag_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
|
||||||
|
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
|
||||||
|
) c ON TRUE WHERE {where_sql}""",
|
||||||
|
tuple(params),
|
||||||
|
) or {"total": 0}
|
||||||
|
items = execute_query(
|
||||||
|
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name,
|
||||||
|
COALESCE(creator.full_name,creator.username) AS created_by,
|
||||||
|
COALESCE(updater.full_name,updater.username) AS updated_by,
|
||||||
|
ka.id AS article_id, ka.status AS article_status
|
||||||
|
FROM sag_solutions s JOIN sag_sager sg ON sg.id=s.sag_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
|
||||||
|
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
|
||||||
|
) c ON TRUE
|
||||||
|
LEFT JOIN users creator ON creator.user_id=s.created_by_user_id
|
||||||
|
LEFT JOIN users updater ON updater.user_id=s.updated_by_user_id
|
||||||
|
LEFT JOIN knowledge_articles ka ON ka.solution_id=s.id
|
||||||
|
WHERE {where_sql} ORDER BY s.updated_at DESC,s.id DESC LIMIT %s OFFSET %s""",
|
||||||
|
tuple(params + [limit, offset]),
|
||||||
|
) or []
|
||||||
|
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset, "archived": include_archived}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/solutions/{solution_id}", dependencies=[Depends(case_edit_access)])
|
||||||
|
async def archive_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NULL", (solution_id,))
|
||||||
|
if not solution:
|
||||||
|
raise HTTPException(status_code=404, detail="Løsningen findes ikke eller er allerede arkiveret")
|
||||||
|
user_id = _user_id(current_user)
|
||||||
|
execute_query("UPDATE knowledge_articles SET status='archived',archived_at=NOW(),archived_by_user_id=%s,updated_at=NOW() WHERE solution_id=%s", (user_id, solution_id))
|
||||||
|
archived = execute_query_single("UPDATE sag_solutions SET deleted_at=NOW(),deleted_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
|
||||||
|
_version_solution(archived, user_id, "Løsning arkiveret")
|
||||||
|
return {"status": "archived", "id": solution_id, "sag_id": solution["sag_id"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/solutions/{solution_id}/restore", dependencies=[Depends(case_edit_access)])
|
||||||
|
async def restore_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
|
||||||
|
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NOT NULL", (solution_id,))
|
||||||
|
if not solution:
|
||||||
|
raise HTTPException(status_code=404, detail="Den arkiverede løsning findes ikke")
|
||||||
|
user_id = _user_id(current_user)
|
||||||
|
restored = execute_query_single("UPDATE sag_solutions SET deleted_at=NULL,deleted_by_user_id=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
|
||||||
|
_version_solution(restored, user_id, "Løsning gendannet")
|
||||||
|
return {"status": "restored", "id": solution_id, "sag_id": solution["sag_id"]}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/knowledge/articles")
|
@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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
58
app/modules/sag/templates/solutions_management.html
Normal file
58
app/modules/sag/templates/solutions_management.html
Normal 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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||||
@ -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
|
||||||
|
|||||||
278
app/settings/backend/ai_benchmark.py
Normal file
278
app/settings/backend/ai_benchmark.py
Normal 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 []
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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') {
|
||||||
|
|||||||
@ -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 13–15 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
19
main.py
@ -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
|
||||||
|
|||||||
41
migrations/1027_ai_model_benchmarks.sql
Normal file
41
migrations/1027_ai_model_benchmarks.sql
Normal 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);
|
||||||
2
migrations/1028_ai_benchmark_expected_answers.sql
Normal file
2
migrations/1028_ai_benchmark_expected_answers.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE ai_benchmark_results
|
||||||
|
ADD COLUMN IF NOT EXISTS expected_json JSONB;
|
||||||
17
migrations/1029_internet_change_cases.sql
Normal file
17
migrations/1029_internet_change_cases.sql
Normal 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);
|
||||||
85
migrations/1030_project_ct_vtiger_archive.sql
Normal file
85
migrations/1030_project_ct_vtiger_archive.sql
Normal 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();
|
||||||
|
|
||||||
14
migrations/1031_project_ct_archive_transfers.sql
Normal file
14
migrations/1031_project_ct_archive_transfers.sql
Normal 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);
|
||||||
21
migrations/1032_project_ct_archive_files.sql
Normal file
21
migrations/1032_project_ct_archive_files.sql
Normal 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();
|
||||||
5
migrations/1033_project_ct_archive_file_sources.sql
Normal file
5
migrations/1033_project_ct_archive_file_sources.sql
Normal 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);
|
||||||
11
migrations/1034_sag_internet_connections.sql
Normal file
11
migrations/1034_sag_internet_connections.sql
Normal 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);
|
||||||
12
migrations/234_case_solution_management.sql
Normal file
12
migrations/234_case_solution_management.sql
Normal 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;
|
||||||
|
|
||||||
171
scripts/benchmark_ollama_crm.py
Normal file
171
scripts/benchmark_ollama_crm.py
Normal 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 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 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()
|
||||||
47
tests/test_internet_change_case_service.py
Normal file
47
tests/test_internet_change_case_service.py
Normal 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'
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
257
tests/test_project_ct_archive.py
Normal file
257
tests/test_project_ct_archive.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
76
tests/test_sag_solution_knowledge.py
Normal file
76
tests/test_sag_solution_knowledge.py
Normal 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]
|
||||||
Loading…
Reference in New Issue
Block a user