2026-08-30 14:34:43 +02:00
import json
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
import logging
2026-08-31 13:01:35 +02:00
import re
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
from typing import Optional
2026-08-30 14:34:43 +02:00
from fastapi import APIRouter , Depends , HTTPException , Query , Request
from app . core . auth_dependencies import get_current_user , require_any_permission
from app . core . database import execute_query , execute_query_single
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
from app . models . schemas import Solution , SolutionCreate , SolutionUpdate
2026-08-31 13:01:35 +02:00
from app . services . case_analysis_service import CaseAnalysisService
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
2026-07-28 14:18:24 +02:00
case_edit_access = require_any_permission ( " cases.edit " , " tickets.edit " )
2026-08-30 14:34:43 +02:00
VISIBILITIES = { " internal " , " general " , " customer " }
APPROVAL_STATUSES = { " draft " , " pending " , " approved " , " outdated " , " rejected " }
RESULT_ALIASES = { " resolved " : " Løst " , " partial " : " Delvist " , " unresolved " : " Ej løst " , " løst " : " Løst " , " delvist " : " Delvist " , " workaround " : " Workaround " , " ej løst " : " Ej løst " }
TYPE_ALIASES = { " standard " : " Support " , " permanent " : " Support " , " external " : " Ekstern " , " support " : " Support " , " drift " : " Drift " , " konsulent " : " Konsulent " , " infrastruktur " : " Infrastruktur " , " workaround " : " Support " }
2026-08-31 13:01:35 +02:00
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] " ) ,
)
2026-08-30 14:34:43 +02:00
def _user_id ( current_user : dict ) - > Optional [ int ] :
value = current_user . get ( " id " ) or current_user . get ( " user_id " )
return int ( value ) if value is not None else None
def _clean_list ( values ) - > list [ str ] :
cleaned , seen = [ ] , set ( )
for value in values or [ ] :
item = str ( value or " " ) . strip ( )
key = item . casefold ( )
if item and key not in seen :
seen . add ( key )
cleaned . append ( item [ : 100 ] )
return cleaned [ : 30 ]
def _normalize_payload ( data : dict ) - > dict :
if " title " in data :
data [ " title " ] = str ( data . get ( " title " ) or " " ) . strip ( )
if not data [ " title " ] :
raise HTTPException ( status_code = 422 , detail = " Løsningen skal have en titel " )
if " visibility " in data :
data [ " visibility " ] = str ( data . get ( " visibility " ) or " internal " ) . lower ( )
if data [ " visibility " ] not in VISIBILITIES :
raise HTTPException ( status_code = 422 , detail = " Ugyldig synlighed " )
if " approval_status " in data :
data [ " approval_status " ] = str ( data . get ( " approval_status " ) or " draft " ) . lower ( )
if data [ " approval_status " ] not in APPROVAL_STATUSES :
raise HTTPException ( status_code = 422 , detail = " Ugyldig godkendelsesstatus " )
if data . get ( " result " ) is not None :
raw = str ( data [ " result " ] ) . strip ( )
data [ " result " ] = RESULT_ALIASES . get ( raw . casefold ( ) , raw )
if data . get ( " solution_type " ) is not None :
raw = str ( data [ " solution_type " ] ) . strip ( )
data [ " solution_type " ] = TYPE_ALIASES . get ( raw . casefold ( ) , raw )
for field in ( " tags " , " products " ) :
if field in data :
data [ field ] = _clean_list ( data [ field ] )
return data
2026-08-31 13:01:35 +02:00
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
2026-08-30 14:34:43 +02:00
def _version_solution ( solution : dict , user_id : Optional [ int ] , change_note : Optional [ str ] = None ) - > None :
version_row = execute_query_single ( " SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s " , ( solution [ " id " ] , ) ) or { " next_version " : 1 }
snapshot = dict ( solution )
for key , value in list ( snapshot . items ( ) ) :
if hasattr ( value , " isoformat " ) :
snapshot [ key ] = value . isoformat ( )
execute_query (
" INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES ( %s , %s , %s ::jsonb, %s , %s ) " ,
( solution [ " id " ] , version_row [ " next_version " ] , json . dumps ( snapshot ) , user_id , change_note ) ,
)
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
2026-08-31 13:01:35 +02:00
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
feat(sag): Add Varekøb & Salg module with database migration and frontend template
- Created a new SQL migration for the sag_salgsvarer table to manage sales and purchase items.
- Implemented a new HTML template for the Varekøb & Salg module, including summary cards and tables for sales and purchases.
- Added JavaScript functions for loading and rendering order data dynamically.
- Introduced a new backend search module for customers, contacts, hardware, and locations with autocomplete functionality.
- Developed an email templates API for managing system and customer-specific email templates.
- Created multiple migrations for Nextcloud instances, cache, audit logs, email templates, sag comments, hardware locations, and billing methods.
- Enhanced the sag module with solutions, order lines, work types, and 2FA support for user authentication.
2026-02-02 20:23:56 +01:00
@router.get ( " /sag/ {sag_id} /solution " , response_model = Optional [ Solution ] )
2026-08-30 14:34:43 +02:00
async def get_solution ( sag_id : int , _current_user : dict = Depends ( get_current_user ) ) :
2026-08-31 13:01:35 +02:00
result = execute_query ( " SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL " , ( sag_id , ) )
2026-08-30 14:34:43 +02:00
return result [ 0 ] if result else None
@router.get ( " /sag/ {sag_id} /solution/versions " )
async def get_solution_versions ( sag_id : int , _current_user : dict = Depends ( get_current_user ) ) :
2026-08-31 13:01:35 +02:00
solution = execute_query_single ( " SELECT id FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL " , ( sag_id , ) )
2026-08-30 14:34:43 +02:00
if not solution :
return { " items " : [ ] , " total " : 0 }
items = execute_query (
""" SELECT v.id, v.version_number, v.change_note, v.created_at,
COALESCE ( u . full_name , u . username ) AS changed_by
FROM sag_solution_versions v LEFT JOIN users u ON u . user_id = v . changed_by_user_id
WHERE v . solution_id = % s ORDER BY v . version_number DESC """ ,
( solution [ " id " ] , ) ,
) or [ ]
return { " items " : items , " total " : len ( items ) }
@router.post ( " /sag/ {sag_id} /solution " , response_model = Solution , dependencies = [ Depends ( case_edit_access ) ] )
async def create_solution ( sag_id : int , solution : SolutionCreate , current_user : dict = Depends ( get_current_user ) ) :
if not execute_query_single ( " SELECT id FROM sag_sager WHERE id= %s AND deleted_at IS NULL " , ( sag_id , ) ) :
raise HTTPException ( status_code = 404 , detail = " Sagen findes ikke " )
2026-08-31 13:01:35 +02:00
existing = execute_query_single ( " SELECT id, deleted_at FROM sag_solutions WHERE sag_id= %s " , ( sag_id , ) )
if existing :
detail = " Sagen har en arkiveret løsning, som skal gendannes " if existing . get ( " deleted_at " ) else " Der findes allerede en løsning på sagen "
raise HTTPException ( status_code = 409 , detail = detail )
2026-08-30 14:34:43 +02:00
data = _normalize_payload ( solution . model_dump ( exclude = { " sag_id " , " created_by_user_id " } ) )
user_id = _user_id ( current_user )
result = execute_query (
""" INSERT INTO sag_solutions (
sag_id , title , description , solution_type , result , problem , root_cause , investigation , workaround ,
visibility , approval_status , is_final , tags , products , created_by_user_id , updated_by_user_id
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s : : jsonb , % s : : jsonb , % s , % s ) RETURNING * """ ,
( sag_id , data [ " title " ] , data . get ( " description " ) , data . get ( " solution_type " ) , data . get ( " result " ) , data . get ( " problem " ) , data . get ( " root_cause " ) , data . get ( " investigation " ) , data . get ( " workaround " ) , data . get ( " visibility " , " internal " ) , data . get ( " approval_status " , " draft " ) , data . get ( " is_final " , True ) , json . dumps ( data . get ( " tags " , [ ] ) ) , json . dumps ( data . get ( " products " , [ ] ) ) , user_id , user_id ) ,
)
created = result [ 0 ]
_version_solution ( created , user_id , " Løsning oprettet " )
return created
@router.patch ( " /sag/ {sag_id} /solution " , response_model = Solution , dependencies = [ Depends ( case_edit_access ) ] )
async def update_solution ( sag_id : int , updates : SolutionUpdate , current_user : dict = Depends ( get_current_user ) ) :
2026-08-31 13:01:35 +02:00
if not execute_query_single ( " SELECT id FROM sag_solutions WHERE sag_id= %s AND deleted_at IS NULL " , ( sag_id , ) ) :
2026-08-30 14:34:43 +02:00
raise HTTPException ( status_code = 404 , detail = " Løsningen findes ikke " )
data = _normalize_payload ( updates . model_dump ( exclude_unset = True ) )
change_note = data . pop ( " change_note " , None )
allowed = { " title " , " description " , " solution_type " , " result " , " problem " , " root_cause " , " investigation " , " workaround " , " visibility " , " approval_status " , " is_final " , " tags " , " products " }
fields , params = [ ] , [ ]
for key , value in data . items ( ) :
if key not in allowed :
continue
fields . append ( f " { key } = %s " + ( " ::jsonb " if key in { " tags " , " products " } else " " ) )
params . append ( json . dumps ( value ) if key in { " tags " , " products " } else value )
if not fields :
raise HTTPException ( status_code = 400 , detail = " Ingen ændringer at gemme " )
user_id = _user_id ( current_user )
fields . extend ( [ " updated_by_user_id = %s " , " updated_at = NOW() " ] )
params . extend ( [ user_id , sag_id ] )
updated = execute_query ( f " UPDATE sag_solutions SET { ' , ' . join ( fields ) } WHERE sag_id=%s RETURNING * " , tuple ( params ) ) [ 0 ]
_version_solution ( updated , user_id , change_note or " Løsning redigeret " )
return updated
@router.post ( " /sag/ {sag_id} /solution/workflow " , dependencies = [ Depends ( case_edit_access ) ] )
async def solution_workflow ( sag_id : int , request : Request , current_user : dict = Depends ( get_current_user ) ) :
action = str ( ( await request . json ( ) ) . get ( " action " ) or " " ) . lower ( )
2026-08-31 13:01:35 +02:00
solution = execute_query_single ( " SELECT * FROM sag_solutions WHERE sag_id= %s AND deleted_at IS NULL " , ( sag_id , ) )
2026-08-30 14:34:43 +02:00
if not solution :
raise HTTPException ( status_code = 404 , detail = " Løsningen findes ikke " )
user_id = _user_id ( current_user )
if action == " submit " :
status = " pending "
elif action == " approve " :
if not str ( solution . get ( " description " ) or " " ) . strip ( ) :
raise HTTPException ( status_code = 422 , detail = " Beskriv den endelige løsning før godkendelse " )
status = " approved "
elif action in { " reject " , " outdate " } :
status = " rejected " if action == " reject " else " outdated "
else :
raise HTTPException ( status_code = 422 , detail = " Ukendt handling " )
if status == " approved " :
updated = execute_query_single ( " UPDATE sag_solutions SET approval_status= %s ,approved_by_user_id= %s ,approved_at=NOW(),updated_by_user_id= %s ,updated_at=NOW() WHERE sag_id= %s RETURNING * " , ( status , user_id , user_id , sag_id ) )
else :
updated = execute_query_single ( " UPDATE sag_solutions SET approval_status= %s ,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id= %s ,updated_at=NOW() WHERE sag_id= %s RETURNING * " , ( status , user_id , sag_id ) )
_version_solution ( updated , user_id , f " Status ændret til { status } " )
return updated
@router.post ( " /sag/ {sag_id} /solution/publish " , dependencies = [ Depends ( case_edit_access ) ] )
async def publish_solution ( sag_id : int , current_user : dict = Depends ( get_current_user ) ) :
2026-08-31 13:01:35 +02:00
solution = execute_query_single ( " SELECT * FROM sag_solutions WHERE sag_id= %s AND deleted_at IS NULL " , ( sag_id , ) )
2026-08-30 14:34:43 +02:00
if not solution :
raise HTTPException ( status_code = 404 , detail = " Løsningen findes ikke " )
if solution . get ( " approval_status " ) != " approved " :
raise HTTPException ( status_code = 409 , detail = " Løsningen skal godkendes før udgivelse " )
2026-08-31 13:01:35 +02:00
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. " )
2026-08-30 14:34:43 +02:00
customer_id = None
if solution . get ( " visibility " ) == " customer " :
customer = execute_query_single ( " SELECT customer_id FROM sag_kunder WHERE sag_id= %s AND deleted_at IS NULL ORDER BY id LIMIT 1 " , ( sag_id , ) )
if not customer :
raise HTTPException ( status_code = 409 , detail = " Kundespecifik viden kræver en kunde på sagen " )
customer_id = customer [ " customer_id " ]
description = str ( solution . get ( " description " ) or " " ) . strip ( )
summary = description [ : 300 ] + ( " … " if len ( description ) > 300 else " " )
article = execute_query_single (
""" INSERT INTO knowledge_articles (
solution_id , sag_id , customer_id , title , summary , problem , root_cause , investigation , solution , workaround ,
visibility , status , tags , products , published_by_user_id , reviewed_at
) VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , ' published ' , % s : : jsonb , % s : : jsonb , % s , NOW ( ) )
ON CONFLICT ( solution_id ) DO UPDATE SET customer_id = EXCLUDED . customer_id , title = EXCLUDED . title ,
summary = EXCLUDED . summary , problem = EXCLUDED . problem , root_cause = EXCLUDED . root_cause ,
investigation = EXCLUDED . investigation , solution = EXCLUDED . solution , workaround = EXCLUDED . workaround ,
visibility = EXCLUDED . visibility , tags = EXCLUDED . tags , products = EXCLUDED . products , status = ' published ' ,
version_number = knowledge_articles . version_number + 1 , published_by_user_id = EXCLUDED . published_by_user_id ,
2026-08-31 13:01:35 +02:00
reviewed_at = NOW ( ) , updated_at = NOW ( ) , archived_at = NULL , archived_by_user_id = NULL RETURNING * """ ,
2026-08-30 14:34:43 +02:00
( 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
2026-08-31 13:01:35 +02:00
@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 } \n Titel: { case . get ( ' titel ' ) or ' ' } \n Beskrivelse: { 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 ' ] } \n Problem: { row . get ( ' problem ' ) or ' ' } \n Lø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 " ] }
2026-08-30 14:34:43 +02:00
@router.get ( " /knowledge/articles " )
async def search_knowledge_articles ( q : str = Query ( " " , max_length = 200 ) , customer_id : Optional [ int ] = None , limit : int = Query ( 25 , ge = 1 , le = 100 ) , offset : int = Query ( 0 , ge = 0 ) , _current_user : dict = Depends ( get_current_user ) ) :
term = q . strip ( )
scope = " (ka.visibility IN ( ' general ' , ' internal ' ) OR (ka.visibility= ' customer ' AND ka.customer_id= %s )) " if customer_id else " ka.visibility IN ( ' general ' , ' internal ' ) "
params : list = [ customer_id ] if customer_id else [ ]
search = " "
if term :
search = " AND (ka.search_document @@ websearch_to_tsquery( ' simple ' , %s ) OR ka.title ILIKE %s ) "
params . extend ( [ term , f " % { term } % " ] )
count = execute_query_single ( f " SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status= ' published ' AND { scope } { search } " , tuple ( params ) ) or { " total " : 0 }
item_params = [ ]
rank_expr = " ts_rank_cd(ka.search_document, websearch_to_tsquery( ' simple ' , %s )) " if term else " 0 "
if term :
item_params . append ( term )
item_params . extend ( params )
item_params . extend ( [ limit , offset ] )
items = execute_query (
f """ SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
ka . version_number , ka . updated_at , ka . sag_id , { rank_expr } AS relevance , c . name AS customer_name
FROM knowledge_articles ka LEFT JOIN customers c ON c . id = ka . customer_id
WHERE ka . status = ' published ' AND { scope } { search }
ORDER BY relevance DESC , ka . updated_at DESC , ka . id DESC LIMIT % s OFFSET % s """ ,
tuple ( item_params ) ,
) or [ ]
return { " items " : items , " total " : int ( count [ " total " ] ) , " limit " : limit , " offset " : offset }
@router.get ( " /knowledge/articles/ {article_id} " )
async def get_knowledge_article ( article_id : int , _current_user : dict = Depends ( get_current_user ) ) :
article = execute_query_single (
""" SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
FROM knowledge_articles ka LEFT JOIN customers c ON c . id = ka . customer_id
LEFT JOIN users u ON u . user_id = ka . published_by_user_id
WHERE ka . id = % s AND ka . status = ' published ' """ ,
( article_id , ) ,
)
if not article :
raise HTTPException ( status_code = 404 , detail = " Vidensartiklen findes ikke " )
return article