1469 lines
65 KiB
Go
1469 lines
65 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"log/slog"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/example/glpi-ai-agent/internal/config"
|
||
"github.com/example/glpi-ai-agent/internal/knowledge"
|
||
"github.com/example/glpi-ai-agent/internal/learning"
|
||
"github.com/example/glpi-ai-agent/internal/metrics"
|
||
"github.com/example/glpi-ai-agent/internal/model"
|
||
"github.com/example/glpi-ai-agent/internal/ollama"
|
||
"github.com/example/glpi-ai-agent/internal/prioritysignals"
|
||
"github.com/example/glpi-ai-agent/internal/queue"
|
||
"github.com/example/glpi-ai-agent/internal/state"
|
||
)
|
||
|
||
type GLPI interface {
|
||
Ping(context.Context) error
|
||
ValidateContract(context.Context) error
|
||
ListRecentTickets(context.Context, int, string) ([]model.Ticket, error)
|
||
GetTicket(context.Context, int64) (model.Ticket, error)
|
||
GetFollowups(context.Context, int64) ([]model.Followup, error)
|
||
SetCategory(context.Context, int64, int64) error
|
||
AddFollowup(context.Context, int64, string, bool) error
|
||
GetCategories(context.Context) ([]model.Category, error)
|
||
}
|
||
type AI interface {
|
||
Ping(context.Context) error
|
||
AnalyseCategory(context.Context, model.Ticket, []model.Category, []model.KnowledgeHit, model.ContextSnapshot) (model.Decision, error)
|
||
AnalyseStatus(context.Context, model.Ticket, model.Category, []model.ServiceIssueCandidate) (model.StatusDecision, error)
|
||
AnalyseReply(context.Context, model.Ticket, model.Category, []model.KnowledgeHit, model.ContextSnapshot) (model.Decision, error)
|
||
}
|
||
type ContextCollector interface {
|
||
Collect(context.Context, model.Ticket) model.ContextSnapshot
|
||
}
|
||
type Service struct {
|
||
cfg config.Config
|
||
glpi GLPI
|
||
ai AI
|
||
knowledge *knowledge.Store
|
||
learning *learning.Store
|
||
state *state.Store
|
||
q *queue.Queue
|
||
metrics *metrics.Metrics
|
||
policy Policy
|
||
context ContextCollector
|
||
locks sync.Map
|
||
catMu sync.RWMutex
|
||
pollLogOnce sync.Once
|
||
categories []model.Category
|
||
catAt time.Time
|
||
}
|
||
|
||
func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, l *learning.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service {
|
||
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeRetrievalFloor, cfg.KnowledgeEvidenceRetrievalWeight, cfg.KnowledgeEvidenceAIWeight, cfg.KnowledgeEvidenceCategoryWeight, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.AIContentLabelEnabled, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
|
||
}
|
||
func (s *Service) Queue() *queue.Queue { return s.q }
|
||
func (s *Service) Start(ctx context.Context) {
|
||
go s.healthLoop(ctx)
|
||
go s.pollLoop(ctx)
|
||
if s.cfg.EscalationEnabled {
|
||
go s.escalationLoop(ctx)
|
||
}
|
||
for i := 0; i < s.cfg.Workers; i++ {
|
||
go s.worker(ctx, i)
|
||
}
|
||
}
|
||
func (s *Service) pollLoop(ctx context.Context) {
|
||
ticker := time.NewTicker(s.cfg.GLPIPollInterval)
|
||
defer ticker.Stop()
|
||
s.poll(ctx)
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
s.poll(ctx)
|
||
}
|
||
}
|
||
}
|
||
func (s *Service) poll(ctx context.Context) {
|
||
pollAt := time.Now()
|
||
tickets, err := s.glpi.ListRecentTickets(ctx, s.cfg.GLPIPollLimit, s.cfg.GLPITicketFilter)
|
||
s.metrics.Polls.Add(1)
|
||
if err != nil {
|
||
s.metrics.SetPollStatus(metrics.PollStatus{At: pollAt, Error: err.Error()})
|
||
s.metrics.Errors.Add(1)
|
||
slog.Error("GLPI poll failed", "error", err)
|
||
return
|
||
}
|
||
seen, unseen, enqueued, rejected := 0, 0, 0, 0
|
||
for _, t := range tickets {
|
||
version := sourceVersion(t)
|
||
if s.state.Seen(t.ID, version) {
|
||
seen++
|
||
continue
|
||
}
|
||
unseen++
|
||
if s.q.EnqueueWork(queue.WorkItem{TicketID: t.ID, Trigger: "poll", Priority: queue.PriorityPoll}) {
|
||
enqueued++
|
||
s.metrics.QueueDepth.Store(int64(s.q.Len()))
|
||
} else {
|
||
rejected++
|
||
}
|
||
}
|
||
status := metrics.PollStatus{At: pollAt, Fetched: len(tickets), Seen: seen, Unseen: unseen, Enqueued: enqueued, Rejected: rejected}
|
||
s.metrics.SetPollStatus(status)
|
||
s.pollLogOnce.Do(func() {
|
||
slog.Info("initial GLPI ticket poll completed", "fetched", status.Fetched, "already_processed", status.Seen, "unseen", status.Unseen, "enqueued", status.Enqueued, "rejected", status.Rejected, "filter_configured", strings.TrimSpace(s.cfg.GLPITicketFilter) != "")
|
||
})
|
||
slog.Debug("GLPI ticket poll completed", "fetched", status.Fetched, "already_processed", status.Seen, "unseen", status.Unseen, "enqueued", status.Enqueued, "rejected", status.Rejected)
|
||
}
|
||
func (s *Service) healthLoop(ctx context.Context) {
|
||
check := func() {
|
||
c, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
defer cancel()
|
||
gerr := s.glpi.Ping(c)
|
||
oerr := s.ai.Ping(c)
|
||
s.metrics.SetHealth(gerr == nil, oerr == nil)
|
||
}
|
||
check()
|
||
ticker := time.NewTicker(30 * time.Second)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-ticker.C:
|
||
check()
|
||
}
|
||
}
|
||
}
|
||
func (s *Service) worker(ctx context.Context, n int) {
|
||
for {
|
||
item, ok := s.q.NextWork(ctx)
|
||
if !ok {
|
||
return
|
||
}
|
||
s.metrics.QueueDepth.Store(int64(s.q.Len()))
|
||
if err := s.ProcessWork(ctx, item); err != nil {
|
||
slog.Error("ticket processing failed", "worker", n, "ticket_id", item.TicketID, "trigger", item.Trigger, "error", err)
|
||
}
|
||
s.q.DoneWork(item)
|
||
s.metrics.QueueDepth.Store(int64(s.q.Len()))
|
||
}
|
||
}
|
||
|
||
// Process remains the compatibility entry point used by tests and manual callers.
|
||
func (s *Service) Process(ctx context.Context, id int64) error {
|
||
return s.ProcessWork(ctx, queue.WorkItem{TicketID: id, Trigger: "manual", Priority: queue.PriorityManual})
|
||
}
|
||
|
||
func (s *Service) ProcessWork(ctx context.Context, item queue.WorkItem) error {
|
||
if strings.EqualFold(strings.TrimSpace(item.Trigger), "scheduled_escalation") {
|
||
return s.processEscalation(ctx, item)
|
||
}
|
||
id := item.TicketID
|
||
muAny, _ := s.locks.LoadOrStore(id, &sync.Mutex{})
|
||
mu := muAny.(*sync.Mutex)
|
||
mu.Lock()
|
||
defer func() {
|
||
mu.Unlock()
|
||
s.locks.Delete(id)
|
||
}()
|
||
start := time.Now()
|
||
trigger := strings.TrimSpace(item.Trigger)
|
||
if trigger == "" {
|
||
trigger = "poll"
|
||
}
|
||
run := model.RunRecord{RunID: newRunID(), TicketID: id, Trigger: trigger, StartedAt: start, DryRun: s.cfg.DryRun, Outcome: "error"}
|
||
finish := func(err error) {
|
||
run.FinishedAt = time.Now()
|
||
if err != nil {
|
||
run.Error = err.Error()
|
||
s.metrics.Errors.Add(1)
|
||
}
|
||
if e := s.state.Append(run); e != nil {
|
||
slog.Error("persist run failed", "error", e)
|
||
}
|
||
}
|
||
t, err := s.glpi.GetTicket(ctx, id)
|
||
if err != nil {
|
||
run.Reason = "ticket_load_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
run.TicketName = t.Name
|
||
run.SourceVersion = sourceVersion(t)
|
||
run.CategoryBefore = t.CategoryID
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_ticket_loaded", Group: "eligibility", Label: "Ticket konnte geladen werden", Status: "pass", Actual: "ja", Expected: "ja"})
|
||
alreadySeen := s.state.Seen(t.ID, run.SourceVersion)
|
||
eligibleVersion := !alreadySeen || item.Force
|
||
versionDetail := ""
|
||
if item.Force && alreadySeen {
|
||
versionDetail = "Manuelle Neuanalyse erzwingt einen einmaligen Lauf für die bereits bekannte Ticketversion."
|
||
}
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_not_already_processed", Group: "eligibility", Label: "Diese Ticketversion wurde noch nicht verarbeitet", Status: passFail(eligibleVersion), Blocking: !eligibleVersion, Actual: boolText(!alreadySeen), Expected: "ja oder manuell erzwungen", Detail: versionDetail})
|
||
if alreadySeen && !item.Force {
|
||
run.Outcome = "skipped"
|
||
run.Reason = "already_processed"
|
||
s.metrics.Skipped.Add(1)
|
||
finish(nil)
|
||
return nil
|
||
}
|
||
statusAllowed := s.statusAllowed(t.StatusID)
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_status_allowed", Group: "eligibility", Label: "Ticketstatus ist zur Verarbeitung freigegeben", Status: passFail(statusAllowed), Blocking: !statusAllowed, Actual: fmt.Sprintf("Status #%d", t.StatusID), Expected: fmt.Sprintf("einer von %v", s.cfg.GLPIAllowedStatusIDs)})
|
||
if !statusAllowed {
|
||
run.Outcome = "skipped"
|
||
run.Reason = "status_not_allowed"
|
||
s.metrics.Skipped.Add(1)
|
||
finish(nil)
|
||
return nil
|
||
}
|
||
followups, err := s.glpi.GetFollowups(ctx, id)
|
||
if err != nil {
|
||
run.Reason = "followup_check_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
canReply := len(followups) == 0
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_no_existing_followup", Group: "execution", Label: "Ticket hat noch keine Antwort / kein Followup", Status: passFail(canReply), Blocking: !canReply, Actual: fmt.Sprintf("%d Followups", len(followups)), Expected: "0 Followups"})
|
||
categories, err := s.getCategories(ctx)
|
||
if err != nil {
|
||
run.Reason = "categories_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
run.CategoryBeforeName = categoryName(categories, t.CategoryID)
|
||
promptCats := shortlistCategories(t, categories, s.cfg.CategoryPromptLimit)
|
||
auditTopK := s.cfg.KnowledgeAuditTopK
|
||
if auditTopK <= 0 {
|
||
auditTopK = s.cfg.KnowledgeTopK
|
||
if auditTopK <= 0 {
|
||
auditTopK = 10
|
||
}
|
||
}
|
||
llmTopK := s.cfg.KnowledgeTopK
|
||
if llmTopK <= 0 {
|
||
llmTopK = 6
|
||
}
|
||
allRetrievalHits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), 0, categories)
|
||
if err != nil {
|
||
run.Reason = "knowledge_search_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
categoryRetrievalHits := knowledge.FilterHitsBySources(allRetrievalHits, s.cfg.KnowledgeCategorySources, 0)
|
||
retrievalHits := knowledge.FilterHitsBySources(allRetrievalHits, s.cfg.KnowledgeAllowedSources, 0)
|
||
categoryLLMHits, categoryCutoff := selectKnowledgeCandidates(categoryRetrievalHits, llmTopK, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap)
|
||
run.CategoryKnowledgeLLMCandidates = len(categoryLLMHits)
|
||
run.CategoryKnowledgeCandidateCutoff = categoryCutoff
|
||
run.KnowledgeCandidateMaxGap = s.cfg.KnowledgeCandidateMaxGap
|
||
run.KnowledgeAuditTopK = auditTopK
|
||
categoryCandidateIDs := knowledgeHitIDSet(categoryLLMHits)
|
||
run.CategoryKnowledgeCandidates = auditKnowledgeCandidates(categoryRetrievalHits, s.cfg.KnowledgeMinScore, auditTopK, categoryCandidateIDs, categoryCutoff, s.cfg.KnowledgeRetrievalFloor, llmTopK)
|
||
|
||
contextData := model.ContextSnapshot{}
|
||
if s.context != nil && s.cfg.ContextEnabled {
|
||
s.metrics.ContextFetches.Add(1)
|
||
contextData = s.context.Collect(ctx, t)
|
||
run.ContextChanges = len(contextData.Changes)
|
||
run.ContextIncidents = len(contextData.MajorIncidents)
|
||
run.ContextIssues = len(contextData.ServiceIssues)
|
||
run.ContextDevices = len(contextData.UserDevices)
|
||
run.ContextWarnings = append([]string(nil), contextData.Warnings...)
|
||
run.ContextDetails = auditContextDetails(contextData, 5)
|
||
if contextData.Incomplete {
|
||
s.metrics.ContextErrors.Add(1)
|
||
}
|
||
}
|
||
|
||
// Stage 1: classify the ticket using only category knowledge. The result is
|
||
// persisted separately and becomes the deterministic basis for reply retrieval.
|
||
categoryStarted := time.Now()
|
||
categoryAnalysis := newAnalysis(run, "category", categoryPromptVersion, map[string]any{"ticket": t, "categories": promptCats, "knowledge_candidates": categoryLLMHits, "context": contextData}, categoryStarted)
|
||
run.CategoryAnalysisExecuted = true
|
||
categoryCtx, categoryTrace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode)
|
||
categoryDecision, err := s.ai.AnalyseCategory(categoryCtx, t, promptCats, categoryLLMHits, contextData)
|
||
run.CategoryAnalysisDurationMS = time.Since(categoryStarted).Milliseconds()
|
||
if err != nil {
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_category_ai", Group: "execution", Label: "Kategorieanalyse konnte ausgeführt werden", Status: "fail", Blocking: true, Actual: err.Error(), Expected: "erfolgreich"})
|
||
finishAnalysis(&categoryAnalysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "set_category", Result: "skipped: category_ai_failed"}, err)
|
||
attachAnalysisTrace(&categoryAnalysis, categoryTrace)
|
||
run.Analyses = append(run.Analyses, categoryAnalysis)
|
||
run.Reason = "category_ai_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_category_ai", Group: "execution", Label: "Kategorieanalyse konnte ausgeführt werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
|
||
run.CategoryAIReason = strings.TrimSpace(categoryDecision.Reason)
|
||
finishAnalysis(&categoryAnalysis, s.cfg.OllamaModel, categoryDecision.Category, nil, categoryDecision.Reason, categoryDecision.Category.Confidence, nil, model.ActionAudit{Type: "set_category"}, nil)
|
||
attachAnalysisTrace(&categoryAnalysis, categoryTrace)
|
||
run.Analyses = append(run.Analyses, categoryAnalysis)
|
||
categoryAnalysisIndex := len(run.Analyses) - 1
|
||
|
||
replyBasis := effectiveReplyCategory(t, categoryDecision, categories, s.cfg.AutoCategory, s.cfg.CategoryConfidence)
|
||
run.ReplyBasisCategoryID = replyBasis.ID
|
||
run.ReplyBasisCategoryName = categoryDisplayName(replyBasis)
|
||
|
||
// Independent priority stage. The model only recommends a GLPI priority and
|
||
// controlled reason codes; deterministic Go policy decides whether a change
|
||
// would be permitted. In the default Shadow Mode no write occurs.
|
||
var priorityResult model.PriorityResult
|
||
priorityAnalysisIndex := -1
|
||
if s.cfg.PriorityEnabled {
|
||
priorityStarted := time.Now()
|
||
priorityEvidence := prioritysignals.Extract(t)
|
||
priorityTimeout := s.cfg.PriorityAnalysisTimeout
|
||
if priorityTimeout <= 0 {
|
||
priorityTimeout = 45 * time.Second
|
||
}
|
||
if s.cfg.OllamaTimeout > 0 && priorityTimeout > s.cfg.OllamaTimeout {
|
||
priorityTimeout = s.cfg.OllamaTimeout
|
||
}
|
||
priorityAnalysis := newAnalysis(run, "priority", priorityPromptVersion, map[string]any{"ticket": t, "effective_category": replyBasis, "context": contextData, "deterministic_evidence": priorityEvidence, "allowed_reason_codes": s.cfg.PriorityAllowedReasonCodes, "neutral_reason_codes": []string{"single_user_affected", "workaround_available", "insufficient_information"}, "threshold": s.cfg.PriorityConfidence, "max_increase": s.cfg.PriorityMaxIncrease, "analysis_timeout": priorityTimeout.String()}, priorityStarted)
|
||
run.PriorityAnalysisExecuted = true
|
||
run.PriorityBefore = t.Priority
|
||
run.PriorityThreshold = s.cfg.PriorityConfidence
|
||
if priorityClient, ok := s.ai.(priorityAI); ok {
|
||
priorityBaseCtx, priorityTrace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode)
|
||
priorityCtx, cancelPriority := context.WithTimeout(priorityBaseCtx, priorityTimeout)
|
||
priorityDecision, priorityErr := priorityClient.AnalysePriority(priorityCtx, t, replyBasis, contextData)
|
||
cancelPriority()
|
||
run.PriorityAnalysisDurationMS = time.Since(priorityStarted).Milliseconds()
|
||
if priorityErr != nil {
|
||
run.PriorityDecision = "priority_ai_failed"
|
||
finishAnalysis(&priorityAnalysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "set_priority", Result: "skipped: priority_ai_failed"}, priorityErr)
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_priority_ai", Group: "execution", Label: "Prioritätsanalyse konnte ausgeführt werden", Status: "warn", Actual: priorityErr.Error(), Expected: "erfolgreich", Detail: "Kategorie- und Antwortverarbeitung laufen weiter; es wird keine Priorität geändert."})
|
||
s.metrics.Errors.Add(1)
|
||
} else {
|
||
priorityResult = evaluatePriority(s.cfg, t, priorityDecision)
|
||
run.PriorityAIReason = strings.TrimSpace(priorityDecision.Reason)
|
||
run.AIRecommendedPriority = priorityDecision.RecommendedPriority
|
||
run.AIRecommendedImpact = priorityDecision.RecommendedImpact
|
||
run.AIRecommendedUrgency = priorityDecision.RecommendedUrgency
|
||
run.PriorityAffectedScope = strings.TrimSpace(priorityDecision.AffectedScope)
|
||
run.PriorityTimeCriticality = strings.TrimSpace(priorityDecision.TimeCriticality)
|
||
run.AIPriorityConfidence = priorityDecision.Confidence
|
||
run.PriorityReasonCodes = append([]string(nil), priorityResult.ReasonCodes...)
|
||
run.PriorityChecks = append([]model.RuleCheck(nil), priorityResult.Checks...)
|
||
run.PriorityDecision = priorityResult.Decision
|
||
run.PriorityProposed = priorityResult.PriorityAfter
|
||
run.PriorityWouldChange = priorityResult.ChangePriority
|
||
action := model.ActionAudit{Type: "set_priority", Proposed: priorityResult.ChangePriority, DryRun: s.cfg.DryRun || !s.cfg.AutoPriority, Before: fmt.Sprintf("priority=%d", t.Priority), After: fmt.Sprintf("priority=%d", priorityResult.PriorityAfter), Result: priorityResult.Decision}
|
||
finishAnalysis(&priorityAnalysis, s.cfg.OllamaModel, priorityResult, priorityResult.ReasonCodes, priorityDecision.Reason, priorityDecision.Confidence, priorityResult.Checks, action, nil)
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_priority_ai", Group: "execution", Label: "Prioritätsanalyse konnte ausgeführt werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
|
||
if priorityDecision.RecommendedPriority > 0 {
|
||
s.metrics.PriorityRecommendations.Add(1)
|
||
}
|
||
}
|
||
attachAnalysisTrace(&priorityAnalysis, priorityTrace)
|
||
} else {
|
||
priorityErr := fmt.Errorf("AI client does not implement priority analysis")
|
||
run.PriorityDecision = "priority_ai_unavailable"
|
||
finishAnalysis(&priorityAnalysis, s.cfg.OllamaModel, nil, nil, "", 0, nil, model.ActionAudit{Type: "set_priority", Result: "skipped: priority_ai_unavailable"}, priorityErr)
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_priority_ai", Group: "execution", Label: "Prioritätsanalyse konnte ausgeführt werden", Status: "warn", Actual: priorityErr.Error(), Expected: "erfolgreich"})
|
||
}
|
||
run.Analyses = append(run.Analyses, priorityAnalysis)
|
||
priorityAnalysisIndex = len(run.Analyses) - 1
|
||
} else {
|
||
run.PriorityDecision = "priority_disabled"
|
||
}
|
||
|
||
// Stage 2: the model may only decide whether one active Uptime Kuma entry
|
||
// clearly explains the ticket. It never receives or returns an end-user text.
|
||
// A deterministic Go policy combines this confidence with the existing
|
||
// relevance score and, if accepted, renders an operator-defined template.
|
||
statusCandidates := statusIssueCandidates(contextData.ServiceIssues)
|
||
run.StatusReplyMinRelevance = s.cfg.ContextStatusReplyMinRelevance
|
||
run.StatusReplyMinAIConfidence = s.cfg.ContextStatusReplyMinAIConfidence
|
||
run.StatusReplyMinFinalScore = s.cfg.ContextStatusReplyMinFinalScore
|
||
var statusDecision model.StatusDecision
|
||
var statusEval statusReplyEvaluation
|
||
statusAnalysisStarted := time.Now()
|
||
var statusAnalysisErr error
|
||
var statusTrace *ollama.Trace
|
||
switch {
|
||
case !s.cfg.ContextStatusReplyEnabled:
|
||
run.StatusAnalysisSkipReason = "status_reply_disabled"
|
||
case !canReply:
|
||
run.StatusAnalysisSkipReason = "existing_followup"
|
||
case !s.cfg.AutoReply:
|
||
run.StatusAnalysisSkipReason = "auto_reply_disabled"
|
||
case contextData.Incomplete:
|
||
run.StatusAnalysisSkipReason = "context_incomplete"
|
||
case len(statusCandidates) == 0:
|
||
run.StatusAnalysisSkipReason = "no_status_candidates"
|
||
default:
|
||
run.StatusAnalysisExecuted = true
|
||
statusCtx, trace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode)
|
||
statusTrace = trace
|
||
statusDecision, err = s.ai.AnalyseStatus(statusCtx, t, replyBasis, statusCandidates)
|
||
run.StatusAnalysisDurationMS = time.Since(statusAnalysisStarted).Milliseconds()
|
||
if err != nil {
|
||
statusAnalysisErr = err
|
||
run.StatusAnalysisSkipReason = "status_ai_failed"
|
||
run.StatusAIReason = "Statuszuordnung fehlgeschlagen: " + err.Error()
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_status_ai", Group: "execution", Label: "Status- und Störungszuordnung konnte ausgeführt werden", Status: "warn", Actual: err.Error(), Expected: "erfolgreich", Detail: "Es wird kein Status-Template verwendet; der normale Reply-Pfad bleibt fail-closed."})
|
||
s.metrics.Errors.Add(1)
|
||
slog.Warn("status analysis failed; normal reply policy retained", "ticket_id", id, "error", err)
|
||
} else {
|
||
run.StatusAIReason = strings.TrimSpace(statusDecision.Reason)
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_status_ai", Group: "execution", Label: "Status- und Störungszuordnung konnte ausgeführt werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
|
||
}
|
||
}
|
||
statusEval = evaluateStatusReply(s.cfg, s.policy, contextData, statusCandidates, statusDecision)
|
||
run.StatusChecks = append([]model.RuleCheck(nil), statusEval.Checks...)
|
||
run.StatusCandidates = auditStatusCandidates(statusCandidates, statusDecision, s.cfg, statusEval)
|
||
run.StatusReplySelected = statusEval.Accepted
|
||
run.StatusReplyDecision = statusEval.DecisionCode
|
||
if run.StatusAnalysisSkipReason != "" && !statusEval.Accepted {
|
||
run.StatusReplyDecision = run.StatusAnalysisSkipReason
|
||
}
|
||
run.StatusReplyType = statusEval.Type
|
||
run.StatusReplyCandidateID = strings.TrimSpace(statusDecision.CandidateID)
|
||
run.StatusReplyAIConfidence = statusDecision.Confidence
|
||
run.StatusReplyFinalScore = statusEval.FinalScore
|
||
run.StatusReplyRenderedText = statusEval.RenderedText
|
||
if statusEval.Candidate.ID != "" {
|
||
run.StatusReplyCandidateName = statusCandidateName(statusEval.Candidate.Issue)
|
||
run.StatusReplyCandidateStatus = statusEval.Candidate.Issue.Status
|
||
run.StatusReplyRelevance = statusEval.Candidate.Issue.Relevance
|
||
}
|
||
statusAnalysis := newAnalysis(run, "status_match", statusPromptVersion, map[string]any{"ticket": t, "effective_category": replyBasis, "candidates": statusCandidates, "thresholds": map[string]float64{"relevance": s.cfg.ContextStatusReplyMinRelevance, "ai_confidence": s.cfg.ContextStatusReplyMinAIConfidence, "final_score": s.cfg.ContextStatusReplyMinFinalScore}}, statusAnalysisStarted)
|
||
statusActionResult := statusEval.DecisionCode
|
||
if !run.StatusAnalysisExecuted {
|
||
statusActionResult = "skipped: " + run.StatusAnalysisSkipReason
|
||
}
|
||
finishAnalysis(&statusAnalysis, s.cfg.OllamaModel, statusEval, nil, statusDecision.Reason, statusDecision.Confidence, statusEval.Checks, model.ActionAudit{Type: "add_status_followup", Proposed: statusEval.Accepted, DryRun: s.cfg.DryRun, Result: statusActionResult}, statusAnalysisErr)
|
||
attachAnalysisTrace(&statusAnalysis, statusTrace)
|
||
run.Analyses = append(run.Analyses, statusAnalysis)
|
||
statusAnalysisIndex := len(run.Analyses) - 1
|
||
|
||
// Stage 3 starts only after the category and optional status result are known. Reply knowledge is
|
||
// reranked and selected against the effective category, so unrelated articles
|
||
// are less likely to reach the answer-selection model.
|
||
hits := s.knowledge.RerankForCategory(retrievalHits, replyBasis.ID)
|
||
replyLLMHits, candidateCutoff := selectKnowledgeCandidates(hits, llmTopK, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap)
|
||
if statusEval.Accepted {
|
||
replyLLMHits = nil
|
||
run.ReplyAnalysisSkipReason = "status_reply_selected"
|
||
} else if !canReply {
|
||
replyLLMHits = nil
|
||
run.ReplyAnalysisSkipReason = "existing_followup"
|
||
} else if !s.cfg.AutoReply {
|
||
replyLLMHits = nil
|
||
run.ReplyAnalysisSkipReason = "auto_reply_disabled"
|
||
} else if len(replyLLMHits) == 0 {
|
||
run.ReplyAnalysisSkipReason = "no_reply_knowledge_candidates"
|
||
}
|
||
run.KnowledgeLLMCandidates = len(replyLLMHits)
|
||
run.KnowledgeCandidateCutoff = candidateCutoff
|
||
replyCandidateIDs := knowledgeHitIDSet(replyLLMHits)
|
||
run.ReplyKnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, auditTopK, replyCandidateIDs, candidateCutoff, s.cfg.KnowledgeRetrievalFloor, llmTopK)
|
||
// Backwards-compatible alias for existing API consumers and old UI code.
|
||
run.KnowledgeCandidates = append([]model.KnowledgeCandidateAudit(nil), run.ReplyKnowledgeCandidates...)
|
||
|
||
var replyDecision model.Decision
|
||
replyAnalysisStarted := time.Now()
|
||
var replyAnalysisErr error
|
||
var replyTrace *ollama.Trace
|
||
switch run.ReplyAnalysisSkipReason {
|
||
case "status_reply_selected":
|
||
replyDecision.Reason = "Normale Antwortanalyse nicht ausgeführt: Ein vordefiniertes Status-Template wurde freigegeben."
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_ai", Group: "execution", Label: "Normale KB-Antwortanalyse wurde benötigt", Status: "info", Actual: "übersprungen: Status-Template ausgewählt", Expected: "nur ohne freigegebenes Status-Template"})
|
||
case "existing_followup":
|
||
replyDecision.Reason = "Antwortanalyse nicht ausgeführt: Ticket besitzt bereits ein Followup."
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_ai", Group: "execution", Label: "Antwortanalyse wurde benötigt", Status: "info", Actual: "übersprungen: vorhandenes Followup", Expected: "nur ohne vorhandenes Followup"})
|
||
case "auto_reply_disabled":
|
||
replyDecision.Reason = "Antwortanalyse nicht ausgeführt: AUTO_REPLY ist deaktiviert."
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_ai", Group: "execution", Label: "Antwortanalyse wurde benötigt", Status: "info", Actual: "übersprungen: AUTO_REPLY=false", Expected: "AUTO_REPLY=true"})
|
||
case "no_reply_knowledge_candidates":
|
||
replyDecision.Reason = "Antwortanalyse nicht ausgeführt: Keine Antwort-KB erreichte die Kandidatenauswahl."
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_ai", Group: "execution", Label: "Antwortanalyse wurde benötigt", Status: "info", Actual: "übersprungen: keine Kandidaten", Expected: "mindestens ein Antwortkandidat"})
|
||
default:
|
||
run.ReplyAnalysisExecuted = true
|
||
replyCtx, trace := ollama.WithTrace(ctx, s.cfg.OllamaRoutingMode)
|
||
replyTrace = trace
|
||
replyDecision, err = s.ai.AnalyseReply(replyCtx, t, replyBasis, replyLLMHits, contextData)
|
||
run.ReplyAnalysisDurationMS = time.Since(replyAnalysisStarted).Milliseconds()
|
||
if err != nil {
|
||
replyAnalysisErr = err
|
||
// A failed second stage must not discard a valid category result. The
|
||
// reply is disabled and the category continues through the Go policy.
|
||
run.ReplyAnalysisSkipReason = "reply_ai_failed"
|
||
replyDecision = model.Decision{}
|
||
replyDecision.Reason = "Antwortanalyse fehlgeschlagen: " + err.Error()
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_ai", Group: "execution", Label: "Antwortanalyse konnte ausgeführt werden", Status: "warn", Actual: err.Error(), Expected: "erfolgreich", Detail: "Die Kategorieanalyse bleibt gültig; es wird keine Antwort vorgeschlagen."})
|
||
s.metrics.Errors.Add(1)
|
||
slog.Warn("reply analysis failed; category result retained", "ticket_id", id, "error", err)
|
||
} else {
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_ai", Group: "execution", Label: "Antwortanalyse konnte ausgeführt werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
|
||
}
|
||
}
|
||
run.ReplyAIReason = strings.TrimSpace(replyDecision.Reason)
|
||
replyAnalysis := newAnalysis(run, "reply_selection", replyPromptVersion, map[string]any{"ticket": t, "effective_category": replyBasis, "knowledge_candidates": replyLLMHits, "context": contextData}, replyAnalysisStarted)
|
||
replyActionResult := "reply_analysis_completed"
|
||
if run.ReplyAnalysisSkipReason != "" {
|
||
replyActionResult = "skipped: " + run.ReplyAnalysisSkipReason
|
||
}
|
||
finishAnalysis(&replyAnalysis, s.cfg.OllamaModel, replyDecision.Reply, nil, replyDecision.Reason, replyDecision.Reply.Confidence, nil, model.ActionAudit{Type: "add_followup", DryRun: s.cfg.DryRun, Result: replyActionResult}, replyAnalysisErr)
|
||
attachAnalysisTrace(&replyAnalysis, replyTrace)
|
||
run.Analyses = append(run.Analyses, replyAnalysis)
|
||
replyAnalysisIndex := len(run.Analyses) - 1
|
||
|
||
decision := model.Decision{}
|
||
decision.Category = categoryDecision.Category
|
||
decision.Reply = replyDecision.Reply
|
||
decision.Reason = joinAIReasons(run.CategoryAIReason, run.StatusAIReason, run.ReplyAIReason)
|
||
|
||
if len(hits) > 0 {
|
||
run.KnowledgeTopID = hits[0].Doc.ID
|
||
run.KnowledgeTopTitle = hits[0].Doc.Title
|
||
run.KnowledgeScore = hits[0].Score
|
||
run.KnowledgeSemanticScore = hits[0].SemanticScore
|
||
run.KnowledgeTitleScore = hits[0].TitleScore
|
||
run.KnowledgeLexicalScore = hits[0].LexicalScore
|
||
run.KnowledgeKeywordScore = hits[0].KeywordScore
|
||
run.KnowledgeCategoryScore = hits[0].CategoryScore
|
||
run.KnowledgeBestChunk = hits[0].BestChunkExcerpt
|
||
run.KnowledgeBestQueryChunk = hits[0].BestQueryExcerpt
|
||
run.KnowledgeQueryChunks = hits[0].QueryChunkCount
|
||
run.KnowledgeDocumentChunks = hits[0].DocumentChunkCount
|
||
run.KnowledgeThreshold = s.cfg.KnowledgeMinScore
|
||
if hits[0].Doc.MinScore > run.KnowledgeThreshold {
|
||
run.KnowledgeThreshold = hits[0].Doc.MinScore
|
||
}
|
||
}
|
||
result, err := s.policy.Evaluate(t, decision, categories, hits, contextData)
|
||
if err == nil {
|
||
result.CategoryChecks = append(result.CategoryChecks, categoryKnowledgeMappingChecks(categoryLLMHits, categories, result.CategoryRecommendationID)...)
|
||
}
|
||
if err != nil {
|
||
run.Reason = "policy_rejected"
|
||
finish(err)
|
||
return err
|
||
}
|
||
if statusEval.Accepted {
|
||
// This is not model-generated prose. The model only selected a verified
|
||
// Uptime Kuma candidate; the exact operator-defined template is rendered
|
||
// deterministically and takes precedence over the normal KB reply.
|
||
result.Reply = true
|
||
result.ReplyText = statusEval.ReplyText
|
||
result.ReplyIsHTML = statusEval.ReplyIsHTML
|
||
result.KnowledgeID = ""
|
||
result.ReplyKnowledgeID = ""
|
||
result.ReplyRecommendation = true
|
||
result.ReplyConfidence = statusDecision.Confidence
|
||
result.ReplyThreshold = s.cfg.ContextStatusReplyMinAIConfidence
|
||
result.ReplyDecision = statusEval.DecisionCode
|
||
result.ReplyChecks = append([]model.RuleCheck(nil), statusEval.Checks...)
|
||
result.AIReason = joinAIReasons(run.CategoryAIReason, run.StatusAIReason, run.ReplyAIReason)
|
||
}
|
||
run.AIReason = result.AIReason
|
||
run.Reason = result.AIReason // backwards compatible audit field
|
||
run.AIRecommendedCategoryID = result.CategoryRecommendationID
|
||
run.AIRecommendedCategoryName = result.CategoryRecommendationName
|
||
run.AICategoryConfidence = result.CategoryConfidence
|
||
run.CategoryThreshold = result.CategoryThreshold
|
||
run.CategoryDecision = result.CategoryDecision
|
||
run.CategoryProposed = result.CategoryID
|
||
run.CategoryWouldChange = result.ChangeCategory
|
||
run.AIReplyRecommended = result.ReplyRecommendation
|
||
run.AIReplyConfidence = result.ReplyConfidence
|
||
run.ReplyThreshold = result.ReplyThreshold
|
||
run.AIKnowledgeID = result.ReplyKnowledgeID
|
||
run.ReplyDecision = result.ReplyDecision
|
||
run.ReplyProposed = result.Reply
|
||
run.KnowledgeID = result.KnowledgeID
|
||
if result.KnowledgeThreshold > 0 {
|
||
run.KnowledgeThreshold = result.KnowledgeThreshold
|
||
}
|
||
run.KnowledgeEvidenceScore = result.KnowledgeEvidenceScore
|
||
run.KnowledgeRetrievalFloor = result.KnowledgeRetrievalFloor
|
||
run.KnowledgeCategoryAligned = result.KnowledgeCategoryAligned
|
||
run.CategoryChecks = append([]model.RuleCheck(nil), result.CategoryChecks...)
|
||
run.ReplyChecks = append([]model.RuleCheck(nil), result.ReplyChecks...)
|
||
if categoryAnalysisIndex >= 0 && categoryAnalysisIndex < len(run.Analyses) {
|
||
a := &run.Analyses[categoryAnalysisIndex]
|
||
a.Checks = append([]model.RuleCheck(nil), result.CategoryChecks...)
|
||
a.Action = model.ActionAudit{Type: "set_category", Proposed: result.ChangeCategory, DryRun: s.cfg.DryRun, Before: fmt.Sprintf("category=%d", t.CategoryID), After: fmt.Sprintf("category=%d", result.CategoryID), Result: result.CategoryDecision}
|
||
}
|
||
if replyAnalysisIndex >= 0 && replyAnalysisIndex < len(run.Analyses) {
|
||
a := &run.Analyses[replyAnalysisIndex]
|
||
a.Checks = append([]model.RuleCheck(nil), result.ReplyChecks...)
|
||
a.Action.Proposed = result.Reply && canReply && !statusEval.Accepted
|
||
a.Action.Result = result.ReplyDecision
|
||
}
|
||
if statusAnalysisIndex >= 0 && statusAnalysisIndex < len(run.Analyses) {
|
||
run.Analyses[statusAnalysisIndex].Action.Proposed = statusEval.Accepted && canReply
|
||
run.Analyses[statusAnalysisIndex].Action.Result = statusEval.DecisionCode
|
||
}
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_dry_run", Group: "execution", Label: "Live-Schreibmodus aktiv", Status: map[bool]string{true: "info", false: "pass"}[s.cfg.DryRun], Blocking: false, Actual: map[bool]string{true: "DRY RUN", false: "LIVE"}[s.cfg.DryRun], Expected: "LIVE für tatsächliche Änderungen", Detail: "Im DRY RUN werden freigegebene Aktionen nur simuliert."})
|
||
run.PolicyReason = policySummary(result.CategoryDecision, run.PriorityDecision, result.ReplyDecision)
|
||
if !canReply {
|
||
if replyAnalysisIndex >= 0 {
|
||
run.Analyses[replyAnalysisIndex].Action.Proposed = false
|
||
run.Analyses[replyAnalysisIndex].Action.Result = "reply_existing_followup"
|
||
}
|
||
if statusAnalysisIndex >= 0 {
|
||
run.Analyses[statusAnalysisIndex].Action.Proposed = false
|
||
run.Analyses[statusAnalysisIndex].Action.Result = "reply_existing_followup"
|
||
}
|
||
// An existing followup is the authoritative execution-level reason why
|
||
// no reply can be proposed, regardless of the model/policy recommendation.
|
||
run.ReplyProposed = false
|
||
run.ReplyDecision = "reply_existing_followup"
|
||
run.PolicyReason = policySummary(result.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
}
|
||
|
||
// Re-read the ticket immediately before any write. This prevents a stale
|
||
// model decision from overwriting a human change made during inference.
|
||
priorityWritePlanned := priorityResult.ChangePriority && s.cfg.AutoPriority
|
||
if (result.ChangeCategory || priorityWritePlanned || (result.Reply && canReply)) && !s.cfg.DryRun {
|
||
fresh, err := s.glpi.GetTicket(ctx, id)
|
||
if err != nil {
|
||
run.Reason = "prewrite_ticket_recheck_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
if sourceVersion(fresh) != run.SourceVersion {
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_ticket_unchanged", Group: "execution", Label: "Ticket seit Analyse unverändert", Status: "fail", Blocking: true, Actual: "geändert", Expected: "unverändert"})
|
||
run.Outcome = "skipped"
|
||
run.Reason = "ticket_changed_before_write"
|
||
if result.ChangeCategory {
|
||
run.CategoryDecision = "category_ticket_changed_before_write"
|
||
}
|
||
if result.Reply && canReply {
|
||
run.ReplyDecision = "reply_ticket_changed_before_write"
|
||
}
|
||
run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
s.metrics.Skipped.Add(1)
|
||
finish(nil)
|
||
return nil
|
||
}
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_ticket_unchanged", Group: "execution", Label: "Ticket seit Analyse unverändert", Status: "pass", Actual: "unverändert", Expected: "unverändert"})
|
||
}
|
||
|
||
if result.ChangeCategory && !s.cfg.DryRun {
|
||
if err := s.glpi.SetCategory(ctx, id, result.CategoryID); err != nil {
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_category_write", Group: "execution", Label: "Kategorie konnte in GLPI geschrieben werden", Status: "fail", Blocking: true, Actual: err.Error(), Expected: "erfolgreich"})
|
||
run.Reason = "category_write_failed"
|
||
run.CategoryDecision = "category_write_failed"
|
||
run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
finish(err)
|
||
return err
|
||
}
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_category_write", Group: "execution", Label: "Kategorie konnte in GLPI geschrieben werden", Status: "pass", Actual: fmt.Sprintf("#%d", result.CategoryID), Expected: "erfolgreich"})
|
||
run.CategoryChanged = true
|
||
run.CategoryDecision = "category_written"
|
||
s.metrics.CategoryChanged.Add(1)
|
||
} else if result.ChangeCategory {
|
||
run.CategoryDecision = "category_accepted_dry_run"
|
||
}
|
||
if categoryAnalysisIndex >= 0 {
|
||
run.Analyses[categoryAnalysisIndex].Action.Executed = run.CategoryChanged
|
||
run.Analyses[categoryAnalysisIndex].Action.Result = run.CategoryDecision
|
||
}
|
||
run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
|
||
if priorityResult.ChangePriority {
|
||
if !s.cfg.AutoPriority {
|
||
run.PriorityDecision = "priority_accepted_shadow"
|
||
} else if s.cfg.DryRun {
|
||
run.PriorityDecision = "priority_accepted_dry_run"
|
||
} else {
|
||
fresh, loadErr := s.glpi.GetTicket(ctx, id)
|
||
if loadErr != nil {
|
||
run.PriorityDecision = "priority_prewrite_recheck_failed"
|
||
if priorityAnalysisIndex >= 0 {
|
||
run.Analyses[priorityAnalysisIndex].Action.Error = loadErr.Error()
|
||
run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
|
||
}
|
||
finish(loadErr)
|
||
return loadErr
|
||
}
|
||
expectedCategory := t.CategoryID
|
||
if run.CategoryChanged {
|
||
expectedCategory = result.CategoryID
|
||
}
|
||
if !sameDecisionSource(t, fresh, expectedCategory) || fresh.Priority != t.Priority {
|
||
run.PriorityDecision = "priority_ticket_changed_before_write"
|
||
run.PriorityWouldChange = false
|
||
if priorityAnalysisIndex >= 0 {
|
||
run.Analyses[priorityAnalysisIndex].Action.Proposed = false
|
||
run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
|
||
}
|
||
} else if writer, ok := s.glpi.(priorityWriter); !ok {
|
||
writeErr := fmt.Errorf("GLPI connector does not implement priority writes")
|
||
run.PriorityDecision = "priority_write_unavailable"
|
||
if priorityAnalysisIndex >= 0 {
|
||
run.Analyses[priorityAnalysisIndex].Action.Error = writeErr.Error()
|
||
run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
|
||
}
|
||
finish(writeErr)
|
||
return writeErr
|
||
} else if writeErr := writer.SetPriority(ctx, id, priorityResult.PriorityAfter); writeErr != nil {
|
||
run.PriorityDecision = "priority_write_failed"
|
||
if priorityAnalysisIndex >= 0 {
|
||
run.Analyses[priorityAnalysisIndex].Action.Error = writeErr.Error()
|
||
run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
|
||
}
|
||
finish(writeErr)
|
||
return writeErr
|
||
} else {
|
||
run.PriorityChanged = true
|
||
run.PriorityDecision = "priority_written"
|
||
s.metrics.PriorityChanges.Add(1)
|
||
if priorityAnalysisIndex >= 0 {
|
||
run.Analyses[priorityAnalysisIndex].Action.Executed = true
|
||
run.Analyses[priorityAnalysisIndex].Action.DryRun = false
|
||
run.Analyses[priorityAnalysisIndex].Action.Result = "priority_written"
|
||
}
|
||
}
|
||
}
|
||
if priorityAnalysisIndex >= 0 {
|
||
run.Analyses[priorityAnalysisIndex].Action.Result = run.PriorityDecision
|
||
}
|
||
}
|
||
|
||
if result.Reply && canReply {
|
||
// If category was just changed by this process, date_mod will legitimately
|
||
// differ. Compare the decision-relevant ticket fields instead and require
|
||
// the category we expect before posting a reply.
|
||
if !s.cfg.DryRun {
|
||
fresh, err := s.glpi.GetTicket(ctx, id)
|
||
if err != nil {
|
||
run.Reason = "prereply_ticket_recheck_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
expectedCategory := t.CategoryID
|
||
if result.ChangeCategory {
|
||
expectedCategory = result.CategoryID
|
||
}
|
||
if !sameDecisionSource(t, fresh, expectedCategory) {
|
||
run.ReplyProposed = false
|
||
run.Reason = "ticket_changed_before_reply"
|
||
run.ReplyDecision = "reply_ticket_changed_before_write"
|
||
run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
run.Outcome = "skipped"
|
||
s.metrics.Skipped.Add(1)
|
||
finish(nil)
|
||
return nil
|
||
}
|
||
}
|
||
followups, err = s.glpi.GetFollowups(ctx, id)
|
||
if err != nil {
|
||
run.Reason = "followup_recheck_failed"
|
||
finish(err)
|
||
return err
|
||
}
|
||
if len(followups) > 0 {
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_followup_recheck", Group: "execution", Label: "Unmittelbar vor Antwort ist weiterhin kein Followup vorhanden", Status: "fail", Blocking: true, Actual: fmt.Sprintf("%d Followups", len(followups)), Expected: "0 Followups"})
|
||
run.ReplyProposed = false
|
||
run.Reason = "followup_appeared_before_write"
|
||
run.ReplyDecision = "reply_followup_appeared_before_write"
|
||
if statusEval.Accepted && statusAnalysisIndex >= 0 {
|
||
run.Analyses[statusAnalysisIndex].Action.Proposed = false
|
||
run.Analyses[statusAnalysisIndex].Action.Result = run.ReplyDecision
|
||
} else if replyAnalysisIndex >= 0 {
|
||
run.Analyses[replyAnalysisIndex].Action.Proposed = false
|
||
run.Analyses[replyAnalysisIndex].Action.Result = run.ReplyDecision
|
||
}
|
||
} else if !s.cfg.DryRun {
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_followup_recheck", Group: "execution", Label: "Unmittelbar vor Antwort ist weiterhin kein Followup vorhanden", Status: "pass", Actual: "0 Followups", Expected: "0 Followups"})
|
||
if err := s.glpi.AddFollowup(ctx, id, result.ReplyText, result.ReplyIsHTML); err != nil {
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_write", Group: "execution", Label: "Antwort konnte in GLPI geschrieben werden", Status: "fail", Blocking: true, Actual: err.Error(), Expected: "erfolgreich"})
|
||
run.Reason = "reply_write_failed"
|
||
run.ReplyDecision = "reply_write_failed"
|
||
run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
finish(err)
|
||
return err
|
||
}
|
||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_reply_write", Group: "execution", Label: "Antwort konnte in GLPI geschrieben werden", Status: "pass", Actual: "erfolgreich", Expected: "erfolgreich"})
|
||
run.ReplyWritten = true
|
||
run.ReplyDecision = "reply_written"
|
||
if statusEval.Accepted && statusAnalysisIndex >= 0 {
|
||
run.Analyses[statusAnalysisIndex].Action.Executed = true
|
||
run.Analyses[statusAnalysisIndex].Action.DryRun = false
|
||
run.Analyses[statusAnalysisIndex].Action.Result = run.ReplyDecision
|
||
} else if replyAnalysisIndex >= 0 {
|
||
run.Analyses[replyAnalysisIndex].Action.Executed = true
|
||
run.Analyses[replyAnalysisIndex].Action.DryRun = false
|
||
run.Analyses[replyAnalysisIndex].Action.Result = run.ReplyDecision
|
||
}
|
||
s.metrics.Replies.Add(1)
|
||
} else {
|
||
run.ReplyDecision = "reply_accepted_dry_run"
|
||
if statusEval.Accepted && statusAnalysisIndex >= 0 {
|
||
run.Analyses[statusAnalysisIndex].Action.Result = run.ReplyDecision
|
||
} else if replyAnalysisIndex >= 0 {
|
||
run.Analyses[replyAnalysisIndex].Action.Result = run.ReplyDecision
|
||
}
|
||
}
|
||
run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
}
|
||
|
||
// Persist the final GLPI version after our own write so the next poll does
|
||
// not immediately process the same self-induced modification again.
|
||
if !s.cfg.DryRun && (run.CategoryChanged || run.PriorityChanged || run.ReplyWritten) {
|
||
if finalTicket, e := s.glpi.GetTicket(ctx, id); e == nil {
|
||
run.SourceVersion = sourceVersion(finalTicket)
|
||
}
|
||
}
|
||
run.PolicyReason = policySummary(run.CategoryDecision, run.PriorityDecision, run.ReplyDecision)
|
||
run.Outcome = "processed"
|
||
s.metrics.Processed.Add(1)
|
||
finish(nil)
|
||
return nil
|
||
}
|
||
|
||
// DiagnoseRun returns the persisted, historical decision record. The rule
|
||
// checks stored on the run are the authoritative explanation of the policy at
|
||
// execution time.
|
||
func (s *Service) DiagnoseRun(ctx context.Context, runID string) (model.RunRecord, error) {
|
||
_ = ctx
|
||
r, ok := s.state.FindRun(strings.TrimSpace(runID))
|
||
if !ok {
|
||
return model.RunRecord{}, fmt.Errorf("run %q not found", runID)
|
||
}
|
||
return r, nil
|
||
}
|
||
|
||
// DiagnoseKnowledge recalculates one arbitrary knowledge article against the
|
||
// current ticket/index. This is intentionally marked as a current re-evaluation
|
||
// when the GLPI ticket changed since the historical run.
|
||
func (s *Service) DiagnoseKnowledge(ctx context.Context, runID, knowledgeID, purpose string) (model.KnowledgeDiagnostic, error) {
|
||
run, ok := s.state.FindRun(strings.TrimSpace(runID))
|
||
if !ok {
|
||
return model.KnowledgeDiagnostic{}, fmt.Errorf("run %q not found", runID)
|
||
}
|
||
doc, ok := s.knowledge.ByID(strings.TrimSpace(knowledgeID))
|
||
if !ok {
|
||
return model.KnowledgeDiagnostic{}, fmt.Errorf("knowledge %q not found", knowledgeID)
|
||
}
|
||
purpose = strings.ToLower(strings.TrimSpace(purpose))
|
||
if purpose == "" {
|
||
purpose = "reply"
|
||
}
|
||
if purpose != "category" && purpose != "reply" {
|
||
return model.KnowledgeDiagnostic{}, fmt.Errorf("unknown diagnostic purpose %q", purpose)
|
||
}
|
||
t, err := s.glpi.GetTicket(ctx, run.TicketID)
|
||
if err != nil {
|
||
return model.KnowledgeDiagnostic{}, fmt.Errorf("load current ticket: %w", err)
|
||
}
|
||
cats, err := s.getCategories(ctx)
|
||
if err != nil {
|
||
return model.KnowledgeDiagnostic{}, fmt.Errorf("load categories: %w", err)
|
||
}
|
||
query := t.Name + "\n" + stripHTML(t.Content)
|
||
indexedHits, err := s.knowledge.Search(ctx, query, 0, cats)
|
||
if err != nil {
|
||
return model.KnowledgeDiagnostic{}, err
|
||
}
|
||
|
||
sources := s.cfg.KnowledgeAllowedSources
|
||
if purpose == "category" {
|
||
sources = s.cfg.KnowledgeCategorySources
|
||
}
|
||
filteredHits := knowledge.FilterHitsBySources(indexedHits, sources, 0)
|
||
basisID := run.ReplyBasisCategoryID
|
||
if basisID == 0 {
|
||
basisID = run.AIRecommendedCategoryID
|
||
}
|
||
if purpose == "reply" && basisID != 0 {
|
||
filteredHits = s.knowledge.RerankForCategory(filteredHits, basisID)
|
||
}
|
||
maxCandidates := s.cfg.KnowledgeTopK
|
||
if maxCandidates <= 0 {
|
||
maxCandidates = 6
|
||
}
|
||
llmHits, cutoff := selectKnowledgeCandidates(filteredHits, maxCandidates, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap)
|
||
llmSet := knowledgeHitIDSet(llmHits)
|
||
|
||
var hit *model.KnowledgeHit
|
||
initialRank := 0
|
||
initialScore := 0.0
|
||
for i := range filteredHits {
|
||
if filteredHits[i].Doc.ID == doc.ID {
|
||
hit = &filteredHits[i]
|
||
initialRank = i + 1
|
||
initialScore = filteredHits[i].Score
|
||
break
|
||
}
|
||
}
|
||
if hit == nil {
|
||
for i := range indexedHits {
|
||
if indexedHits[i].Doc.ID == doc.ID {
|
||
hit = &indexedHits[i]
|
||
initialScore = indexedHits[i].Score
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if hit == nil {
|
||
return model.KnowledgeDiagnostic{}, fmt.Errorf("knowledge %q is not in active index", knowledgeID)
|
||
}
|
||
|
||
_, sent := llmSet[doc.ID]
|
||
sourceOK := sourceConfigured(doc.Source, sources)
|
||
reason := candidateSelectionReason(initialRank, initialScore, sent, cutoff, s.cfg.KnowledgeRetrievalFloor, maxCandidates)
|
||
if !sourceOK {
|
||
reason = "source_not_allowed_for_purpose"
|
||
}
|
||
required := s.cfg.KnowledgeMinScore
|
||
if doc.MinScore > required {
|
||
required = doc.MinScore
|
||
}
|
||
checks := []model.RuleCheck{
|
||
{Code: "candidate_in_active_index", Group: "retrieval", Label: "Artikel ist im aktiven Knowledge-Index", Status: "pass", Actual: "ja", Expected: "ja"},
|
||
{Code: "candidate_source_for_purpose", Group: "retrieval", Label: "Quelle ist für diese Analyse freigegeben", Status: passFail(sourceOK), Blocking: !sourceOK, Actual: doc.Source, Expected: strings.Join(sources, ", ")},
|
||
{Code: "candidate_retrieval_floor", Group: "retrieval", Label: "Retrieval-Score erreicht Floor", Status: passFail(sourceOK && initialScore >= s.cfg.KnowledgeRetrievalFloor), Blocking: sourceOK && initialScore < s.cfg.KnowledgeRetrievalFloor, Actual: percentText(initialScore), Expected: ">= " + percentText(s.cfg.KnowledgeRetrievalFloor)},
|
||
{Code: "candidate_dynamic_cutoff", Group: "retrieval", Label: "Artikel liegt innerhalb des dynamischen Top-K-Abstands", Status: passFail(sourceOK && initialScore >= cutoff), Blocking: sourceOK && initialScore < cutoff, Actual: percentText(initialScore), Expected: ">= " + percentText(cutoff), Detail: fmt.Sprintf("Bester Treffer minus %.1f Prozentpunkte, mindestens Retrieval-Floor.", s.cfg.KnowledgeCandidateMaxGap*100)},
|
||
{Code: "candidate_sent_to_ai", Group: "retrieval", Label: "Artikel wurde an die passende KI-Stufe übergeben", Status: passFail(sent), Blocking: sourceOK && !sent, Actual: boolText(sent), Expected: "ja", Detail: reason},
|
||
}
|
||
|
||
evidenceScore := 0.0
|
||
aiSelected := false
|
||
if purpose == "category" {
|
||
matches := len(doc.Categories) == 0 || containsCategory(doc.Categories, run.AIRecommendedCategoryID)
|
||
checks = append(checks, model.RuleCheck{Code: "candidate_category_support", Group: "category", Label: "Artikel unterstützt die empfohlene Kategorie", Status: passFail(matches), Actual: boolText(matches), Expected: fmt.Sprintf("Kategorie #%d", run.AIRecommendedCategoryID), Detail: "Unbeschränkte Artikel gelten als allgemeiner Klassifikationshinweis."})
|
||
} else {
|
||
decision := model.Decision{}
|
||
decision.Category.ID = run.AIRecommendedCategoryID
|
||
decision.Category.Confidence = run.AICategoryConfidence
|
||
decision.Reply.Allowed = run.AIReplyRecommended
|
||
decision.Reply.Confidence = run.AIReplyConfidence
|
||
decision.Reply.KnowledgeID = doc.ID
|
||
decision.Reason = run.ReplyAIReason
|
||
ctxData := model.ContextSnapshot{}
|
||
if s.context != nil && s.cfg.ContextEnabled {
|
||
ctxData = s.context.Collect(ctx, t)
|
||
}
|
||
res, _ := s.policy.Evaluate(t, decision, cats, []model.KnowledgeHit{*hit}, ctxData)
|
||
evidenceScore = res.KnowledgeEvidenceScore
|
||
checks = append(checks, res.ReplyChecks...)
|
||
aiSelected = run.AIKnowledgeID == doc.ID
|
||
}
|
||
|
||
return model.KnowledgeDiagnostic{
|
||
RunID: run.RunID, Purpose: purpose, TicketID: run.TicketID, KnowledgeID: doc.ID, Title: doc.Title, Source: doc.Source,
|
||
CurrentTicketChanged: sourceVersion(t) != run.SourceVersion, RetrievalRank: initialRank, RetrievalScore: initialScore,
|
||
SemanticScore: hit.SemanticScore, TitleScore: hit.TitleScore, LexicalScore: hit.LexicalScore, KeywordScore: hit.KeywordScore, CategoryScore: hit.CategoryScore,
|
||
CandidateCutoff: cutoff, SentToAI: sent, SelectionReason: reason, AISelected: aiSelected,
|
||
EvidenceScore: evidenceScore, RequiredScore: required, BestChunkExcerpt: hit.BestChunkExcerpt, BestQueryExcerpt: hit.BestQueryExcerpt,
|
||
ExternalCategories: append([]string(nil), doc.ExternalCategories...), UnmappedCategories: append([]string(nil), doc.UnmappedExternalCategories...), Checks: checks, Document: doc,
|
||
}, nil
|
||
}
|
||
|
||
func sourceConfigured(source string, sources []string) bool {
|
||
source = strings.ToLower(strings.TrimSpace(source))
|
||
for _, allowed := range sources {
|
||
if source == strings.ToLower(strings.TrimSpace(allowed)) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func containsCategory(categories []int64, id int64) bool {
|
||
for _, categoryID := range categories {
|
||
if categoryID == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func candidateSelectionReason(rank int, score float64, sent bool, cutoff, floor float64, maxCandidates int) string {
|
||
if sent {
|
||
return "sent_to_ai"
|
||
}
|
||
if score < floor {
|
||
return "below_retrieval_floor"
|
||
}
|
||
if score < cutoff {
|
||
return "outside_candidate_gap"
|
||
}
|
||
if maxCandidates > 0 && rank > maxCandidates {
|
||
return "max_candidates_reached"
|
||
}
|
||
return "not_selected"
|
||
}
|
||
|
||
func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limit int, sentToAI map[string]struct{}, cutoff, retrievalFloor float64, maxCandidates int) []model.KnowledgeCandidateAudit {
|
||
if limit <= 0 || limit > len(hits) {
|
||
limit = len(hits)
|
||
}
|
||
out := make([]model.KnowledgeCandidateAudit, 0, limit)
|
||
for idx, h := range hits[:limit] {
|
||
required := globalMin
|
||
if h.Doc.MinScore > required {
|
||
required = h.Doc.MinScore
|
||
}
|
||
_, wasSent := sentToAI[h.Doc.ID]
|
||
reason := candidateSelectionReason(idx+1, h.Score, wasSent, cutoff, retrievalFloor, maxCandidates)
|
||
out = append(out, model.KnowledgeCandidateAudit{
|
||
ID: h.Doc.ID, Title: h.Doc.Title, Source: h.Doc.Source, Score: h.Score,
|
||
SemanticScore: h.SemanticScore, TitleScore: h.TitleScore, LexicalScore: h.LexicalScore, KeywordScore: h.KeywordScore,
|
||
CategoryScore: h.CategoryScore, RequiredScore: required, AutoReply: h.Doc.AutoReply,
|
||
AutoReplyDecision: h.Doc.AutoReplyDecision, AutoReplyDetail: h.Doc.AutoReplyDetail,
|
||
BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt,
|
||
QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, SentToAI: wasSent, RetrievalRank: idx + 1, SelectionReason: reason,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func selectKnowledgeCandidates(hits []model.KnowledgeHit, maxCandidates int, retrievalFloor, maxGap float64) ([]model.KnowledgeHit, float64) {
|
||
if len(hits) == 0 || maxCandidates <= 0 {
|
||
return nil, retrievalFloor
|
||
}
|
||
best := hits[0].Score
|
||
if best < retrievalFloor {
|
||
return nil, retrievalFloor
|
||
}
|
||
cutoff := best - maxGap
|
||
if cutoff < retrievalFloor {
|
||
cutoff = retrievalFloor
|
||
}
|
||
capacity := maxCandidates
|
||
if len(hits) < capacity {
|
||
capacity = len(hits)
|
||
}
|
||
out := make([]model.KnowledgeHit, 0, capacity)
|
||
for _, h := range hits {
|
||
if h.Score < cutoff || h.Score < retrievalFloor {
|
||
break
|
||
}
|
||
out = append(out, h)
|
||
if len(out) >= maxCandidates {
|
||
break
|
||
}
|
||
}
|
||
return out, cutoff
|
||
}
|
||
|
||
func knowledgeHitIDSet(hits []model.KnowledgeHit) map[string]struct{} {
|
||
out := make(map[string]struct{}, len(hits))
|
||
for _, h := range hits {
|
||
out[h.Doc.ID] = struct{}{}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func effectiveReplyCategory(t model.Ticket, d model.Decision, categories []model.Category, autoCategory bool, threshold float64) model.Category {
|
||
effectiveID := t.CategoryID
|
||
known := make(map[int64]model.Category, len(categories))
|
||
for _, category := range categories {
|
||
known[category.ID] = category
|
||
}
|
||
if d.Category.ID == t.CategoryID {
|
||
effectiveID = t.CategoryID
|
||
} else if autoCategory && d.Category.ID != 0 && d.Category.Confidence >= threshold {
|
||
if _, ok := known[d.Category.ID]; ok {
|
||
effectiveID = d.Category.ID
|
||
}
|
||
}
|
||
if category, ok := known[effectiveID]; ok {
|
||
return category
|
||
}
|
||
return model.Category{ID: effectiveID, Name: fmt.Sprintf("Kategorie #%d", effectiveID)}
|
||
}
|
||
|
||
func policySummary(decisions ...string) string {
|
||
parts := make([]string, 0, len(decisions))
|
||
for _, decision := range decisions {
|
||
decision = strings.TrimSpace(decision)
|
||
if decision != "" {
|
||
parts = append(parts, decision)
|
||
}
|
||
}
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
|
||
func joinAIReasons(reasons ...string) string {
|
||
labels := []string{"Kategorie", "Status", "Antwort"}
|
||
parts := make([]string, 0, len(reasons))
|
||
for i, reason := range reasons {
|
||
reason = strings.TrimSpace(reason)
|
||
if reason == "" {
|
||
continue
|
||
}
|
||
label := "Analyse"
|
||
if i < len(labels) {
|
||
label = labels[i]
|
||
}
|
||
parts = append(parts, label+": "+reason)
|
||
}
|
||
return strings.Join(parts, " | ")
|
||
}
|
||
|
||
func auditContextDetails(c model.ContextSnapshot, limit int) []model.ContextAuditItem {
|
||
if limit <= 0 {
|
||
limit = 5
|
||
}
|
||
out := make([]model.ContextAuditItem, 0, limit*4)
|
||
for i, x := range c.Changes {
|
||
if i >= limit {
|
||
break
|
||
}
|
||
out = append(out, model.ContextAuditItem{Kind: "change", ID: x.ID, Name: x.Name, Relevance: x.Relevance, Detail: strings.TrimSpace(x.PlannedBegin + " – " + x.PlannedEnd)})
|
||
}
|
||
for i, x := range c.MajorIncidents {
|
||
if i >= limit {
|
||
break
|
||
}
|
||
out = append(out, model.ContextAuditItem{Kind: "incident", ID: x.ID, Name: x.Name, Relevance: x.Relevance, Status: fmt.Sprint(x.StatusID), Detail: auditExcerpt(x.Content, 320)})
|
||
}
|
||
for i, x := range c.ServiceIssues {
|
||
if i >= limit {
|
||
break
|
||
}
|
||
name := x.MonitorName
|
||
if name == "" {
|
||
name = x.IncidentTitle
|
||
}
|
||
out = append(out, model.ContextAuditItem{Kind: "uptime", ID: x.MonitorID, Name: name, Relevance: x.Relevance, Status: x.Status, Detail: auditExcerpt(x.Message, 320)})
|
||
}
|
||
for i, x := range c.UserDevices {
|
||
if i >= limit {
|
||
break
|
||
}
|
||
name := x.Name
|
||
if name == "" {
|
||
name = fmt.Sprintf("%s #%d", x.ItemType, x.ID)
|
||
}
|
||
parts := make([]string, 0, 3)
|
||
for _, v := range []string{x.Serial, x.InventoryNumber, x.Location} {
|
||
if strings.TrimSpace(v) != "" {
|
||
parts = append(parts, strings.TrimSpace(v))
|
||
}
|
||
}
|
||
detail := strings.Join(parts, " · ")
|
||
out = append(out, model.ContextAuditItem{Kind: "device", ID: x.ID, Name: name, Status: x.Status, Detail: detail})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func auditExcerpt(v string, max int) string {
|
||
v = strings.Join(strings.Fields(v), " ")
|
||
if max <= 0 || len(v) <= max {
|
||
return v
|
||
}
|
||
return strings.TrimSpace(v[:max]) + "…"
|
||
}
|
||
|
||
func (s *Service) getCategories(ctx context.Context) ([]model.Category, error) {
|
||
s.catMu.RLock()
|
||
if len(s.categories) > 0 && time.Since(s.catAt) < 10*time.Minute {
|
||
out := append([]model.Category(nil), s.categories...)
|
||
s.catMu.RUnlock()
|
||
return s.enrichCategories(out), nil
|
||
}
|
||
s.catMu.RUnlock()
|
||
cats, err := s.glpi.GetCategories(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.catMu.Lock()
|
||
s.categories = append([]model.Category(nil), cats...)
|
||
s.catAt = time.Now()
|
||
s.catMu.Unlock()
|
||
return s.enrichCategories(cats), nil
|
||
}
|
||
|
||
func (s *Service) enrichCategories(cats []model.Category) []model.Category {
|
||
out := append([]model.Category(nil), cats...)
|
||
byID := make(map[int64]*model.Category, len(out))
|
||
for i := range out {
|
||
byID[out[i].ID] = &out[i]
|
||
out[i].Hints = append(out[i].Hints, semanticCategoryHints(out[i])...)
|
||
}
|
||
categorySources := sourceSet(s.cfg.KnowledgeCategorySources)
|
||
for _, doc := range s.knowledge.List() {
|
||
if _, allowed := categorySources[strings.ToLower(strings.TrimSpace(doc.Source))]; !allowed {
|
||
continue
|
||
}
|
||
for _, id := range doc.Categories {
|
||
if c := byID[id]; c != nil {
|
||
c.Hints = appendUnique(c.Hints, doc.Title)
|
||
for _, k := range doc.Keywords {
|
||
c.Hints = appendUnique(c.Hints, k)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if s.cfg.LearningEnabled && s.learning != nil {
|
||
for i := range out {
|
||
out[i].Examples = s.learning.ExamplesFor(out[i].ID, s.cfg.LearningExamplesPerCategory)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Categories exposes the same enriched category catalogue that is supplied to
|
||
// Ollama. It is used by the authenticated dashboard for human feedback.
|
||
func (s *Service) Categories(ctx context.Context) ([]model.Category, error) {
|
||
return s.getCategories(ctx)
|
||
}
|
||
|
||
func (s *Service) RecordCategoryFeedback(ctx context.Context, runID string, categoryID int64) (model.LearningExample, error) {
|
||
if !s.cfg.LearningEnabled || s.learning == nil {
|
||
return model.LearningExample{}, fmt.Errorf("learning is disabled")
|
||
}
|
||
run, ok := s.state.FindRun(strings.TrimSpace(runID))
|
||
if !ok {
|
||
return model.LearningExample{}, fmt.Errorf("run not found")
|
||
}
|
||
cats, err := s.getCategories(ctx)
|
||
if err != nil {
|
||
return model.LearningExample{}, err
|
||
}
|
||
name := categoryName(cats, categoryID)
|
||
if categoryID <= 0 || name == "" {
|
||
return model.LearningExample{}, fmt.Errorf("unknown category id %d", categoryID)
|
||
}
|
||
t, err := s.glpi.GetTicket(ctx, run.TicketID)
|
||
if err != nil {
|
||
return model.LearningExample{}, err
|
||
}
|
||
if sourceVersion(t) != run.SourceVersion {
|
||
return model.LearningExample{}, fmt.Errorf("ticket changed since this run; process the current ticket state before teaching it")
|
||
}
|
||
ex := model.LearningExample{RunID: run.RunID, TicketID: t.ID, Subject: strings.TrimSpace(t.Name), Text: compactLearningText(stripHTML(t.Content), 1200), CategoryID: categoryID, CategoryName: name, AIRecommendedCategoryID: run.AIRecommendedCategoryID, AIConfidence: run.AICategoryConfidence, Correction: run.AIRecommendedCategoryID != categoryID, Source: "human-confirmed"}
|
||
return s.learning.Add(ex)
|
||
}
|
||
func (s *Service) LearningExamples() []model.LearningExample {
|
||
if s.learning == nil {
|
||
return nil
|
||
}
|
||
return s.learning.List()
|
||
}
|
||
func (s *Service) DeleteLearning(id string) error {
|
||
if s.learning == nil {
|
||
return fmt.Errorf("learning is disabled")
|
||
}
|
||
return s.learning.Delete(id)
|
||
}
|
||
func (s *Service) LearningCount() int {
|
||
if s.learning == nil {
|
||
return 0
|
||
}
|
||
return s.learning.Count()
|
||
}
|
||
|
||
func appendUnique(in []string, v string) []string {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return in
|
||
}
|
||
for _, x := range in {
|
||
if strings.EqualFold(strings.TrimSpace(x), v) {
|
||
return in
|
||
}
|
||
}
|
||
return append(in, v)
|
||
}
|
||
func compactLearningText(v string, max int) string {
|
||
v = strings.Join(strings.Fields(v), " ")
|
||
r := []rune(v)
|
||
if len(r) <= max {
|
||
return v
|
||
}
|
||
return string(r[:max]) + "…"
|
||
}
|
||
func semanticCategoryHints(c model.Category) []string {
|
||
name := strings.ToLower(c.Name + " " + c.CompleteName)
|
||
var h []string
|
||
add := func(vals ...string) {
|
||
for _, v := range vals {
|
||
h = appendUnique(h, v)
|
||
}
|
||
}
|
||
if strings.Contains(name, "active directory") || strings.Contains(name, "entra") || strings.Contains(name, "identity") || strings.Contains(name, "benutzerkonto") || strings.Contains(name, "account") {
|
||
add("Benutzerkonto", "Anmeldung / Login", "Konto gesperrt", "Passwort", "Domänenkonto", "Gruppen und Berechtigungen", "Authentifizierung")
|
||
}
|
||
if strings.Contains(name, "druck") || strings.Contains(name, "printer") {
|
||
add("Drucker", "Drucken nicht möglich", "Druckwarteschlange", "Netzwerkdrucker", "Toner", "Papierstau")
|
||
}
|
||
if strings.Contains(name, "vpn") {
|
||
add("VPN-Verbindung", "Remote Access", "Gateway", "GlobalProtect", "Tunnel", "Verbindungsaufbau")
|
||
}
|
||
if strings.Contains(name, "mail") || strings.Contains(name, "outlook") || strings.Contains(name, "exchange") {
|
||
add("E-Mail", "Outlook", "Postfach", "E-Mail Versand und Empfang", "Exchange")
|
||
}
|
||
if strings.Contains(name, "netz") || strings.Contains(name, "network") || strings.Contains(name, "wlan") || strings.Contains(name, "wifi") {
|
||
add("Netzwerk", "LAN", "WLAN", "Keine Verbindung", "DNS", "IP-Adresse")
|
||
}
|
||
if strings.Contains(name, "hardware") || strings.Contains(name, "client") || strings.Contains(name, "arbeitsplatz") {
|
||
add("Arbeitsplatzgerät", "Notebook", "PC", "Dockingstation", "Peripherie")
|
||
}
|
||
return h
|
||
}
|
||
|
||
func categoryName(categories []model.Category, id int64) string {
|
||
if id == 0 {
|
||
return "Nicht gesetzt"
|
||
}
|
||
for _, c := range categories {
|
||
if c.ID == id {
|
||
return categoryDisplayName(c)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (s *Service) statusAllowed(id int64) bool {
|
||
for _, allowed := range s.cfg.GLPIAllowedStatusIDs {
|
||
if id == allowed {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func sourceVersion(t model.Ticket) string {
|
||
// Do not rely on date_mod alone: two changes can happen within the same
|
||
// timestamp resolution and some API projections may omit it. Requesters and
|
||
// linked items are decision-relevant because they feed the context collector.
|
||
payload := fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%d\x00%s\x00%v\x00%v\x00%v\x00%v", t.ID, t.DateCreation, t.DateMod, t.Name, t.Content, t.StatusID, t.CategoryID, t.Priority, t.Impact, t.Urgency, t.EntityID, t.LocationID, t.TimeToResolve, t.RequesterIDs, t.AssignedGroups, t.AssignedUsers, t.Items)
|
||
h := sha256.Sum256([]byte(payload))
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
func sameDecisionSource(original, fresh model.Ticket, expectedCategory int64) bool {
|
||
if fresh.Name != original.Name || fresh.Content != original.Content || fresh.StatusID != original.StatusID || fresh.CategoryID != expectedCategory || fresh.Impact != original.Impact || fresh.Urgency != original.Urgency || fresh.EntityID != original.EntityID || fresh.LocationID != original.LocationID || fresh.TimeToResolve != original.TimeToResolve {
|
||
return false
|
||
}
|
||
if fmt.Sprint(fresh.RequesterIDs) != fmt.Sprint(original.RequesterIDs) || fmt.Sprint(fresh.AssignedGroups) != fmt.Sprint(original.AssignedGroups) || fmt.Sprint(fresh.AssignedUsers) != fmt.Sprint(original.AssignedUsers) || fmt.Sprint(fresh.Items) != fmt.Sprint(original.Items) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
func newRunID() string { b := make([]byte, 8); _, _ = rand.Read(b); return hex.EncodeToString(b) }
|
||
|
||
func categoryKnowledgeMappingChecks(hits []model.KnowledgeHit, categories []model.Category, selectedID int64) []model.RuleCheck {
|
||
if selectedID <= 0 || len(hits) == 0 {
|
||
return nil
|
||
}
|
||
byID := make(map[int64]model.Category, len(categories))
|
||
for _, c := range categories {
|
||
byID[c.ID] = c
|
||
}
|
||
selected, ok := byID[selectedID]
|
||
if !ok {
|
||
return nil
|
||
}
|
||
selectedName := selected.CompleteName
|
||
if strings.TrimSpace(selectedName) == "" {
|
||
selectedName = selected.Name
|
||
}
|
||
selectedLeaf := normalizeCategoryLeaf(selectedName)
|
||
if selectedLeaf == "" {
|
||
return nil
|
||
}
|
||
var mismatches []string
|
||
for _, hit := range hits {
|
||
mapped := false
|
||
for _, id := range hit.Doc.Categories {
|
||
if id == selectedID {
|
||
mapped = true
|
||
break
|
||
}
|
||
}
|
||
if !mapped || len(hit.Doc.ExternalCategories) == 0 {
|
||
continue
|
||
}
|
||
matches := false
|
||
for _, label := range hit.Doc.ExternalCategories {
|
||
if normalizeCategoryLeaf(label) == selectedLeaf {
|
||
matches = true
|
||
break
|
||
}
|
||
}
|
||
if !matches {
|
||
mismatches = append(mismatches, fmt.Sprintf("%s: %s → #%d %s", hit.Doc.ID, strings.Join(hit.Doc.ExternalCategories, " | "), selectedID, selectedName))
|
||
}
|
||
}
|
||
if len(mismatches) == 0 {
|
||
return nil
|
||
}
|
||
return []model.RuleCheck{{
|
||
Code: "category_external_mapping_review",
|
||
Group: "category",
|
||
Label: "Externe Knowledge-Kategorie passt namentlich zum GLPI-Ziel",
|
||
Status: "warn",
|
||
Blocking: false,
|
||
Actual: strings.Join(mismatches, "; "),
|
||
Expected: "Mapping fachlich geprüft",
|
||
Detail: "Nicht blockierend: Externe Taxonomien dürfen bewusst zusammengeführt werden. Die Abweichung sollte aber geprüft werden, weil sie Hints und KI-Begründung beeinflusst.",
|
||
}}
|
||
}
|
||
|
||
func normalizeCategoryLeaf(v string) string {
|
||
v = strings.TrimSpace(v)
|
||
if i := strings.LastIndex(v, ">"); i >= 0 {
|
||
v = v[i+1:]
|
||
}
|
||
if i := strings.LastIndex(v, "/"); i >= 0 {
|
||
v = v[i+1:]
|
||
}
|
||
v = strings.ToLower(strings.TrimSpace(v))
|
||
v = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", " und ", " ", "-", " ", "_", " ").Replace(v)
|
||
return strings.Join(strings.Fields(v), "")
|
||
}
|
||
|
||
func stripHTML(s string) string {
|
||
r := strings.NewReplacer("<br>", "\n", "<br/>", "\n", "<br />", "\n", "</p>", "\n")
|
||
s = r.Replace(s)
|
||
var b strings.Builder
|
||
inside := false
|
||
for _, ch := range s {
|
||
if ch == '<' {
|
||
inside = true
|
||
continue
|
||
}
|
||
if ch == '>' {
|
||
inside = false
|
||
continue
|
||
}
|
||
if !inside {
|
||
b.WriteRune(ch)
|
||
}
|
||
}
|
||
return strings.TrimSpace(b.String())
|
||
}
|
||
func shortlistCategories(t model.Ticket, cats []model.Category, limit int) []model.Category {
|
||
if limit <= 0 || len(cats) <= limit {
|
||
return cats
|
||
}
|
||
q := strings.Fields(strings.ToLower(t.Name + " " + stripHTML(t.Content)))
|
||
type scored struct {
|
||
c model.Category
|
||
s int
|
||
}
|
||
ss := make([]scored, 0, len(cats))
|
||
for _, c := range cats {
|
||
name := strings.ToLower(c.Name + " " + c.CompleteName + " " + strings.Join(c.Hints, " ") + " " + strings.Join(c.Examples, " "))
|
||
score := 0
|
||
for _, w := range q {
|
||
if len(w) >= 3 && strings.Contains(name, w) {
|
||
score++
|
||
}
|
||
}
|
||
if c.ID == t.CategoryID {
|
||
score += 100
|
||
}
|
||
ss = append(ss, scored{c, score})
|
||
}
|
||
sort.SliceStable(ss, func(i, j int) bool { return ss[i].s > ss[j].s })
|
||
out := make([]model.Category, 0, limit)
|
||
for i := 0; i < limit && i < len(ss); i++ {
|
||
out = append(out, ss[i].c)
|
||
}
|
||
return out
|
||
}
|