This commit is contained in:
+139
-6
@@ -157,14 +157,19 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
run.TicketName = t.Name
|
||||
run.SourceVersion = sourceVersion(t)
|
||||
run.CategoryBefore = t.CategoryID
|
||||
if s.state.Seen(t.ID, run.SourceVersion) {
|
||||
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)
|
||||
run.ExecutionChecks = append(run.ExecutionChecks, model.RuleCheck{Code: "execution_not_already_processed", Group: "eligibility", Label: "Diese Ticketversion wurde noch nicht verarbeitet", Status: passFail(!alreadySeen), Blocking: alreadySeen, Actual: boolText(!alreadySeen), Expected: "ja"})
|
||||
if alreadySeen {
|
||||
run.Outcome = "skipped"
|
||||
run.Reason = "already_processed"
|
||||
s.metrics.Skipped.Add(1)
|
||||
finish(nil)
|
||||
return nil
|
||||
}
|
||||
if !s.statusAllowed(t.StatusID) {
|
||||
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)
|
||||
@@ -178,6 +183,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
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"
|
||||
@@ -234,7 +240,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
// alignment signal before the final policy gate.
|
||||
hits := s.knowledge.RerankForCategory(retrievalHits, decision.Category.ID)
|
||||
if len(hits) > 0 {
|
||||
run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, auditTopK, llmCandidateIDs)
|
||||
run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, auditTopK, llmCandidateIDs, candidateCutoff, s.cfg.KnowledgeRetrievalFloor, llmTopK)
|
||||
run.KnowledgeTopID = hits[0].Doc.ID
|
||||
run.KnowledgeTopTitle = hits[0].Doc.Title
|
||||
run.KnowledgeScore = hits[0].Score
|
||||
@@ -280,6 +286,9 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
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...)
|
||||
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 = result.CategoryDecision + "; " + result.ReplyDecision
|
||||
if !canReply && result.Reply {
|
||||
run.ReplyProposed = false
|
||||
@@ -297,6 +306,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
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 {
|
||||
@@ -310,16 +320,19 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
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 = run.CategoryDecision + "; " + 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)
|
||||
@@ -361,17 +374,21 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
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"
|
||||
} 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 = run.CategoryDecision + "; " + 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"
|
||||
s.metrics.Replies.Add(1)
|
||||
@@ -394,23 +411,139 @@ func (s *Service) Process(ctx context.Context, id int64) error {
|
||||
finish(nil)
|
||||
return nil
|
||||
}
|
||||
func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limit int, sentToAI map[string]struct{}) []model.KnowledgeCandidateAudit {
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
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)
|
||||
allHits, err := s.knowledge.Search(ctx, query, 0, cats)
|
||||
if err != nil {
|
||||
return model.KnowledgeDiagnostic{}, err
|
||||
}
|
||||
maxCandidates := s.cfg.KnowledgeTopK
|
||||
if maxCandidates <= 0 {
|
||||
maxCandidates = 6
|
||||
}
|
||||
llmHits, cutoff := selectKnowledgeCandidates(allHits, maxCandidates, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap)
|
||||
llmSet := knowledgeHitIDSet(llmHits)
|
||||
initialRank := 0
|
||||
initialScore := 0.0
|
||||
for i, h := range allHits {
|
||||
if h.Doc.ID == doc.ID {
|
||||
initialRank = i + 1
|
||||
initialScore = h.Score
|
||||
break
|
||||
}
|
||||
}
|
||||
post := s.knowledge.RerankForCategory(allHits, run.AIRecommendedCategoryID)
|
||||
var hit *model.KnowledgeHit
|
||||
for i := range post {
|
||||
if post[i].Doc.ID == doc.ID {
|
||||
hit = &post[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hit == nil {
|
||||
return model.KnowledgeDiagnostic{}, fmt.Errorf("knowledge %q is not in active index", knowledgeID)
|
||||
}
|
||||
_, sent := llmSet[doc.ID]
|
||||
reason := candidateSelectionReason(initialRank, initialScore, sent, cutoff, s.cfg.KnowledgeRetrievalFloor, maxCandidates)
|
||||
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.AIReason
|
||||
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)
|
||||
required := s.cfg.KnowledgeMinScore
|
||||
if doc.MinScore > required {
|
||||
required = doc.MinScore
|
||||
}
|
||||
checks := append([]model.RuleCheck(nil), res.ReplyChecks...)
|
||||
checks = append([]model.RuleCheck{
|
||||
{Code: "candidate_in_active_index", Group: "retrieval", Label: "Artikel ist im aktiven Knowledge-Index", Status: "pass", Actual: "ja", Expected: "ja"},
|
||||
{Code: "candidate_retrieval_floor", Group: "retrieval", Label: "Retrieval-Score erreicht Floor", Status: passFail(initialScore >= s.cfg.KnowledgeRetrievalFloor), Blocking: 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(initialScore >= cutoff), Blocking: 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 KI übergeben", Status: passFail(sent), Blocking: !sent, Actual: boolText(sent), Expected: "ja", Detail: reason},
|
||||
}, checks...)
|
||||
return model.KnowledgeDiagnostic{
|
||||
RunID: run.RunID, 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: run.AIKnowledgeID == doc.ID,
|
||||
EvidenceScore: res.KnowledgeEvidenceScore, 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 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 _, h := range hits[: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,
|
||||
BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt,
|
||||
QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, SentToAI: wasSent,
|
||||
QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, SentToAI: wasSent, RetrievalRank: idx + 1, SelectionReason: reason,
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -164,3 +164,55 @@ func TestSemanticHintsImproveActiveDirectoryCategory(t *testing.T) {
|
||||
t.Fatalf("expected identity hints, got %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStoresStructuredPolicyChecks(t *testing.T) {
|
||||
g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "konto", Content: "anmeldung", DateMod: "v1", StatusID: 1, CategoryID: 1}, cats: []model.Category{{ID: 1, Name: "Allgemein"}, {ID: 2, Name: "Active Directory"}}}
|
||||
var d model.Decision
|
||||
d.Category.ID, d.Category.Confidence = 2, .82
|
||||
d.Reply.Allowed = false
|
||||
svc := newTestService(t, g, d, false)
|
||||
if err := svc.Process(context.Background(), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := svc.state.Recent(1)[0]
|
||||
if len(r.CategoryChecks) < 4 {
|
||||
t.Fatalf("category checks=%d", len(r.CategoryChecks))
|
||||
}
|
||||
if len(r.ReplyChecks) < 8 {
|
||||
t.Fatalf("reply checks=%d", len(r.ReplyChecks))
|
||||
}
|
||||
found := false
|
||||
for _, c := range r.CategoryChecks {
|
||||
if c.Code == "category_confidence" && c.Status == "fail" && c.Blocking {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing blocking confidence rule: %+v", r.CategoryChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseKnowledgeExplainsCandidate(t *testing.T) {
|
||||
g := &fakeGLPI{ticket: model.Ticket{ID: 1, Name: "VPN gateway", Content: "gateway nicht erreichbar", DateMod: "v1", StatusID: 1, CategoryID: 2}, cats: []model.Category{{ID: 2, Name: "VPN"}}}
|
||||
var d model.Decision
|
||||
d.Category.ID, d.Category.Confidence = 2, 1
|
||||
d.Reply.Allowed, d.Reply.Confidence, d.Reply.KnowledgeID = true, 1, "KB1"
|
||||
svc := newTestService(t, g, d, true)
|
||||
if err := svc.Process(context.Background(), 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := svc.state.Recent(1)[0]
|
||||
diag, err := svc.DiagnoseKnowledge(context.Background(), r.RunID, "KB1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diag.KnowledgeID != "KB1" || diag.RetrievalRank != 1 {
|
||||
t.Fatalf("unexpected diagnostic: %+v", diag)
|
||||
}
|
||||
if len(diag.Checks) == 0 {
|
||||
t.Fatal("expected diagnostic checks")
|
||||
}
|
||||
if diag.CurrentTicketChanged {
|
||||
t.Fatal("ticket should not be marked changed")
|
||||
}
|
||||
}
|
||||
|
||||
+237
-122
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
@@ -72,8 +73,34 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
|
||||
res.CategoryRecommendationName = categoryDisplayName(c)
|
||||
}
|
||||
|
||||
// The model only recommends a category and a confidence score. Whether a
|
||||
// write is allowed is entirely deterministic and owned by this policy.
|
||||
// Category rules. "already correct" is not a failure; it means that no
|
||||
// write action is necessary even though the recommendation is valid.
|
||||
res.CategoryChecks = append(res.CategoryChecks,
|
||||
check("category", "category_auto_enabled", "Automatische Kategorisierung aktiviert", boolStatus(p.AutoCategory), !p.AutoCategory, boolText(p.AutoCategory), "true", "Globale Schreibfreigabe für Kategorien."),
|
||||
)
|
||||
if d.Category.ID == 0 {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_recommendation_present", "KI hat eine Kategorie empfohlen", "fail", true, "#0", "gültige GLPI-Kategorie", "Kategorie 0 bedeutet: keine fachlich vertretbare Empfehlung."))
|
||||
} else {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_recommendation_present", "KI hat eine Kategorie empfohlen", "pass", false, fmt.Sprintf("#%d", d.Category.ID), "gültige GLPI-Kategorie", ""))
|
||||
}
|
||||
_, categoryKnown := known[d.Category.ID]
|
||||
if d.Category.ID == 0 {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_known", "Empfohlene Kategorie existiert in GLPI", "na", false, "–", "bekannte Kategorie", "Keine Kategorie empfohlen."))
|
||||
} else if categoryKnown {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_known", "Empfohlene Kategorie existiert in GLPI", "pass", false, fmt.Sprintf("#%d %s", d.Category.ID, res.CategoryRecommendationName), "bekannte Kategorie", ""))
|
||||
} else {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_known", "Empfohlene Kategorie existiert in GLPI", "fail", true, fmt.Sprintf("#%d", d.Category.ID), "bekannte Kategorie", "Die KI darf nur IDs aus der bereitgestellten GLPI-Kategorieliste verwenden."))
|
||||
}
|
||||
if d.Category.ID != 0 && d.Category.ID == t.CategoryID {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_change_required", "Kategorieänderung ist notwendig", "info", false, fmt.Sprintf("bereits #%d", t.CategoryID), "andere Kategorie", "Die aktuelle Kategorie entspricht bereits der KI-Empfehlung."))
|
||||
} else if d.Category.ID != 0 {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_change_required", "Kategorieänderung ist notwendig", "pass", false, fmt.Sprintf("#%d → #%d", t.CategoryID, d.Category.ID), "abweichende Kategorie", ""))
|
||||
} else {
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_change_required", "Kategorieänderung ist notwendig", "na", false, "–", "abweichende Kategorie", "Keine Empfehlung vorhanden."))
|
||||
}
|
||||
catConfidenceOK := d.Category.Confidence >= p.CategoryConfidence
|
||||
res.CategoryChecks = append(res.CategoryChecks, check("category", "category_confidence", "KI-Confidence erreicht Schwellwert", passFail(catConfidenceOK), !catConfidenceOK, percentText(d.Category.Confidence), ">= "+percentText(p.CategoryConfidence), ""))
|
||||
|
||||
switch {
|
||||
case !p.AutoCategory:
|
||||
res.CategoryDecision = "category_auto_disabled"
|
||||
@@ -81,9 +108,9 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
|
||||
res.CategoryDecision = "category_no_recommendation"
|
||||
case d.Category.ID == t.CategoryID:
|
||||
res.CategoryDecision = "category_already_correct"
|
||||
case known[d.Category.ID].ID == 0:
|
||||
case !categoryKnown:
|
||||
res.CategoryDecision = "category_unknown"
|
||||
case d.Category.Confidence < p.CategoryConfidence:
|
||||
case !catConfidenceOK:
|
||||
res.CategoryDecision = "category_confidence_below_threshold"
|
||||
default:
|
||||
res.ChangeCategory = true
|
||||
@@ -91,141 +118,229 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
|
||||
res.CategoryDecision = "category_accepted"
|
||||
}
|
||||
|
||||
// Auto-reply uses the same explainable, fail-closed approach. The first
|
||||
// failed gate becomes the audit reason shown in the dashboard.
|
||||
switch {
|
||||
case !p.AutoReply:
|
||||
res.ReplyDecision = "reply_auto_disabled"
|
||||
return res, nil
|
||||
case len(hits) == 0:
|
||||
res.ReplyDecision = "reply_no_knowledge_candidates"
|
||||
return res, nil
|
||||
case !d.Reply.Allowed:
|
||||
res.ReplyDecision = "reply_model_not_recommended"
|
||||
return res, nil
|
||||
case d.Reply.Confidence < p.ReplyConfidence:
|
||||
res.ReplyDecision = "reply_confidence_below_threshold"
|
||||
return res, nil
|
||||
case res.ReplyKnowledgeID == "":
|
||||
res.ReplyDecision = "reply_no_knowledge_selected"
|
||||
return res, nil
|
||||
case p.BlockReplyOnContextError && contextData.Incomplete:
|
||||
res.ReplyDecision = "reply_context_incomplete"
|
||||
return res, nil
|
||||
case p.BlockReplyOnIncident && contextData.HasRelevantIncident(p.ContextRelevanceMinScore):
|
||||
res.ReplyDecision = "reply_relevant_incident"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var hit *model.KnowledgeHit
|
||||
// Resolve the selected knowledge article once. All gates below are evaluated
|
||||
// even when an earlier gate failed, so diagnostics can show the complete rule
|
||||
// picture instead of only the first short-circuit reason.
|
||||
var selected *model.KnowledgeHit
|
||||
for i := range hits {
|
||||
if hits[i].Doc.ID == res.ReplyKnowledgeID {
|
||||
hit = &hits[i]
|
||||
selected = &hits[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if hit == nil {
|
||||
res.ReplyDecision = "reply_knowledge_not_found"
|
||||
return res, nil
|
||||
}
|
||||
if !p.sourceAllowed(hit.Doc.Source) {
|
||||
res.ReplyDecision = "reply_source_not_allowed"
|
||||
return res, nil
|
||||
}
|
||||
if !p.sourceAllowedForReply(hit.Doc.Source) {
|
||||
res.ReplyDecision = "reply_source_not_allowed_for_auto_reply"
|
||||
return res, nil
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(hit.Doc.Language), p.CommunicationLanguage) {
|
||||
res.ReplyDecision = "reply_language_mismatch"
|
||||
return res, nil
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(hit.Doc.CommunicationStyle), p.CommunicationStyle) {
|
||||
res.ReplyDecision = "reply_style_mismatch"
|
||||
return res, nil
|
||||
}
|
||||
threshold := p.KnowledgeMinScore
|
||||
if hit.Doc.MinScore > threshold {
|
||||
threshold = hit.Doc.MinScore
|
||||
}
|
||||
res.KnowledgeThreshold = threshold
|
||||
res.KnowledgeRetrievalScore = hit.Score
|
||||
res.KnowledgeRetrievalFloor = p.KnowledgeRetrievalFloor
|
||||
if !hit.Doc.AutoReply {
|
||||
res.ReplyDecision = "reply_knowledge_auto_reply_disabled"
|
||||
return res, nil
|
||||
}
|
||||
if hit.Score < p.KnowledgeRetrievalFloor {
|
||||
res.ReplyDecision = "reply_knowledge_retrieval_below_floor"
|
||||
return res, nil
|
||||
}
|
||||
catIDForEvidence := t.CategoryID
|
||||
if res.ChangeCategory {
|
||||
catIDForEvidence = res.CategoryID
|
||||
} else if d.Category.ID != 0 && d.Category.ID == t.CategoryID {
|
||||
catIDForEvidence = d.Category.ID
|
||||
}
|
||||
categoryEvidence, categoryAvailable := 0.0, false
|
||||
if len(hit.Doc.Categories) > 0 && catIDForEvidence != 0 {
|
||||
categoryAvailable = true
|
||||
for _, id := range hit.Doc.Categories {
|
||||
if id == catIDForEvidence {
|
||||
categoryEvidence = 1
|
||||
res.KnowledgeCategoryAligned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
res.KnowledgeEvidenceScore = evidenceScore(
|
||||
hit.Score, d.Reply.Confidence, categoryEvidence, categoryAvailable,
|
||||
p.KnowledgeEvidenceRetrievalWeight, p.KnowledgeEvidenceAIWeight, p.KnowledgeEvidenceCategoryWeight,
|
||||
|
||||
relevantIncident := contextData.HasRelevantIncident(p.ContextRelevanceMinScore)
|
||||
res.ReplyChecks = append(res.ReplyChecks,
|
||||
check("reply", "reply_auto_enabled", "Auto-Reply global aktiviert", boolStatus(p.AutoReply), !p.AutoReply, boolText(p.AutoReply), "true", ""),
|
||||
check("reply", "reply_candidates_available", "Mindestens ein Knowledge-Kandidat vorhanden", passFail(len(hits) > 0), len(hits) == 0, fmt.Sprintf("%d Kandidaten", len(hits)), "> 0", ""),
|
||||
check("reply", "reply_model_recommended", "KI empfiehlt eine Antwort", passFail(d.Reply.Allowed), !d.Reply.Allowed, boolText(d.Reply.Allowed), "true", ""),
|
||||
check("reply", "reply_confidence", "KI-Reply-Confidence erreicht Schwellwert", passFail(d.Reply.Confidence >= p.ReplyConfidence), d.Reply.Confidence < p.ReplyConfidence, percentText(d.Reply.Confidence), ">= "+percentText(p.ReplyConfidence), ""),
|
||||
)
|
||||
if res.KnowledgeEvidenceScore < threshold {
|
||||
res.ReplyDecision = "reply_knowledge_evidence_below_threshold"
|
||||
return res, nil
|
||||
if res.ReplyKnowledgeID == "" {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("reply", "reply_knowledge_selected", "KI hat einen Knowledge-Artikel ausgewählt", "fail", true, "keine ID", "ID eines bereitgestellten Kandidaten", ""))
|
||||
} else {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("reply", "reply_knowledge_selected", "KI hat einen Knowledge-Artikel ausgewählt", "pass", false, res.ReplyKnowledgeID, "ID eines bereitgestellten Kandidaten", ""))
|
||||
}
|
||||
if strings.TrimSpace(hit.Doc.Answer) == "" {
|
||||
res.ReplyDecision = "reply_knowledge_answer_empty"
|
||||
return res, nil
|
||||
if p.BlockReplyOnContextError {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("context", "reply_context_complete", "Kontextquellen vollständig", passFail(!contextData.Incomplete), contextData.Incomplete, boolText(!contextData.Incomplete), "true", strings.Join(contextData.Warnings, "; ")))
|
||||
} else {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("context", "reply_context_complete", "Kontextquellen vollständig", "na", false, boolText(!contextData.Incomplete), "nicht blockierend", "CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS ist deaktiviert."))
|
||||
}
|
||||
catID := t.CategoryID
|
||||
if res.ChangeCategory {
|
||||
catID = res.CategoryID
|
||||
if p.BlockReplyOnIncident {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("context", "reply_no_relevant_incident", "Keine relevante zentrale Störung", passFail(!relevantIncident), relevantIncident, boolText(!relevantIncident), "true", "Major Incidents und Uptime-Kuma-Störungen werden berücksichtigt."))
|
||||
} else {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("context", "reply_no_relevant_incident", "Keine relevante zentrale Störung", "na", false, boolText(!relevantIncident), "nicht blockierend", "Incident-Blockierung ist deaktiviert."))
|
||||
}
|
||||
if len(hit.Doc.Categories) > 0 {
|
||||
allowed := false
|
||||
for _, id := range hit.Doc.Categories {
|
||||
if id == catID {
|
||||
allowed = true
|
||||
break
|
||||
if res.ReplyKnowledgeID == "" {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_exists", "Ausgewählter Knowledge-Artikel ist verfügbar", "na", false, "–", "vorhandener Artikel", "Keine Knowledge-ID ausgewählt."))
|
||||
} else if selected == nil {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_exists", "Ausgewählter Knowledge-Artikel ist verfügbar", "fail", true, res.ReplyKnowledgeID, "Kandidat im übergebenen Set", "Die KI hat eine ID ausgewählt, die nicht im Kandidatenset vorhanden ist."))
|
||||
} else {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_exists", "Ausgewählter Knowledge-Artikel ist verfügbar", "pass", false, selected.Doc.ID, "Kandidat im übergebenen Set", selected.Doc.Title))
|
||||
}
|
||||
|
||||
if selected != nil {
|
||||
sourceAllowed := p.sourceAllowed(selected.Doc.Source)
|
||||
sourceReplyAllowed := p.sourceAllowedForReply(selected.Doc.Source)
|
||||
langOK := strings.EqualFold(strings.TrimSpace(selected.Doc.Language), p.CommunicationLanguage)
|
||||
styleOK := strings.EqualFold(strings.TrimSpace(selected.Doc.CommunicationStyle), p.CommunicationStyle)
|
||||
res.ReplyChecks = append(res.ReplyChecks,
|
||||
check("knowledge", "reply_source_allowed", "Knowledge-Quelle ist für Retrieval erlaubt", passFail(sourceAllowed), !sourceAllowed, selected.Doc.Source, "KNOWLEDGE_ALLOWED_SOURCES", ""),
|
||||
check("knowledge", "reply_source_auto_allowed", "Knowledge-Quelle ist für Auto-Reply erlaubt", passFail(sourceReplyAllowed), !sourceReplyAllowed, selected.Doc.Source, "KNOWLEDGE_AUTO_REPLY_SOURCES", ""),
|
||||
check("communication", "reply_language_match", "Sprache des Artikels passt zur Kommunikationspolicy", passFail(langOK), !langOK, selected.Doc.Language, p.CommunicationLanguage, ""),
|
||||
check("communication", "reply_style_match", "Stil des Artikels passt zur Kommunikationspolicy", passFail(styleOK), !styleOK, selected.Doc.CommunicationStyle, p.CommunicationStyle, ""),
|
||||
)
|
||||
autoDetail := ""
|
||||
if len(selected.Doc.UnmappedExternalCategories) > 0 {
|
||||
autoDetail = "Nicht zugeordnete externe Kategorien: " + strings.Join(selected.Doc.UnmappedExternalCategories, ", ")
|
||||
}
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_auto_reply", "Artikel ist für Auto-Reply freigegeben", passFail(selected.Doc.AutoReply), !selected.Doc.AutoReply, boolText(selected.Doc.AutoReply), "true", autoDetail))
|
||||
|
||||
threshold := p.KnowledgeMinScore
|
||||
if selected.Doc.MinScore > threshold {
|
||||
threshold = selected.Doc.MinScore
|
||||
}
|
||||
res.KnowledgeThreshold = threshold
|
||||
res.KnowledgeRetrievalScore = selected.Score
|
||||
res.KnowledgeRetrievalFloor = p.KnowledgeRetrievalFloor
|
||||
retrievalOK := selected.Score >= p.KnowledgeRetrievalFloor
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_retrieval_floor", "Retrieval-Score erreicht Mindestfloor", passFail(retrievalOK), !retrievalOK, percentText(selected.Score), ">= "+percentText(p.KnowledgeRetrievalFloor), "Der Retrieval-Floor entscheidet, ob ein Artikel überhaupt als plausibler Kandidat gilt."))
|
||||
|
||||
catIDForEvidence := t.CategoryID
|
||||
if res.ChangeCategory {
|
||||
catIDForEvidence = res.CategoryID
|
||||
} else if d.Category.ID != 0 && d.Category.ID == t.CategoryID {
|
||||
catIDForEvidence = d.Category.ID
|
||||
}
|
||||
categoryEvidence, categoryAvailable := 0.0, false
|
||||
if len(selected.Doc.Categories) > 0 && catIDForEvidence != 0 {
|
||||
categoryAvailable = true
|
||||
for _, id := range selected.Doc.Categories {
|
||||
if id == catIDForEvidence {
|
||||
categoryEvidence = 1
|
||||
res.KnowledgeCategoryAligned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
res.ReplyDecision = "reply_category_not_allowed"
|
||||
return res, nil
|
||||
res.KnowledgeEvidenceScore = evidenceScore(selected.Score, d.Reply.Confidence, categoryEvidence, categoryAvailable, p.KnowledgeEvidenceRetrievalWeight, p.KnowledgeEvidenceAIWeight, p.KnowledgeEvidenceCategoryWeight)
|
||||
evidenceOK := res.KnowledgeEvidenceScore >= threshold
|
||||
catDetail := "Kategorie nicht als Evidenz verfügbar; Gewichte werden normalisiert."
|
||||
if categoryAvailable {
|
||||
catDetail = "Kategorie-Evidenz: " + percentText(categoryEvidence)
|
||||
}
|
||||
}
|
||||
res.Reply = true
|
||||
if p.AIContentLabelEnabled {
|
||||
// The disclosure is HTML, therefore every AI-selected automatic reply is
|
||||
// sent as HTML. Plain-text KB answers are escaped before wrapping.
|
||||
if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
|
||||
res.ReplyText = p.formatRichReply(hit.Doc.AnswerHTML)
|
||||
} else {
|
||||
res.ReplyText = p.formatRichReply(p.plainTextToHTML(hit.Doc.Answer))
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_evidence_threshold", "Finale Knowledge-Evidenz erreicht Schwellwert", passFail(evidenceOK), !evidenceOK, percentText(res.KnowledgeEvidenceScore), ">= "+percentText(threshold), catDetail))
|
||||
|
||||
answerPresent := strings.TrimSpace(selected.Doc.Answer) != "" || strings.TrimSpace(selected.Doc.AnswerHTML) != ""
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_answer_present", "Freigegebener Antwortinhalt vorhanden", passFail(answerPresent), !answerPresent, boolText(answerPresent), "true", ""))
|
||||
|
||||
categoryAllowed := true
|
||||
categoryDetail := "Artikel gilt für alle ITIL-Kategorien."
|
||||
catID := t.CategoryID
|
||||
if res.ChangeCategory {
|
||||
catID = res.CategoryID
|
||||
}
|
||||
res.ReplyIsHTML = true
|
||||
} else if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
|
||||
res.ReplyText = p.formatRichReply(hit.Doc.AnswerHTML)
|
||||
res.ReplyIsHTML = true
|
||||
if len(selected.Doc.Categories) > 0 {
|
||||
categoryAllowed = false
|
||||
for _, id := range selected.Doc.Categories {
|
||||
if id == catID {
|
||||
categoryAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
categoryDetail = fmt.Sprintf("Artikel-Kategorien: %v; effektive Ticketkategorie: #%d", selected.Doc.Categories, catID)
|
||||
}
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_category_scope", "Artikel ist für die effektive Ticketkategorie freigegeben", passFail(categoryAllowed), !categoryAllowed, boolText(categoryAllowed), "true", categoryDetail))
|
||||
} else {
|
||||
res.ReplyText = p.formatReply(hit.Doc.Answer)
|
||||
for _, spec := range []struct{ code, label string }{
|
||||
{"reply_source_allowed", "Knowledge-Quelle ist für Retrieval erlaubt"},
|
||||
{"reply_source_auto_allowed", "Knowledge-Quelle ist für Auto-Reply erlaubt"},
|
||||
{"reply_language_match", "Sprache des Artikels passt zur Kommunikationspolicy"},
|
||||
{"reply_style_match", "Stil des Artikels passt zur Kommunikationspolicy"},
|
||||
{"reply_knowledge_auto_reply", "Artikel ist für Auto-Reply freigegeben"},
|
||||
{"reply_retrieval_floor", "Retrieval-Score erreicht Mindestfloor"},
|
||||
{"reply_evidence_threshold", "Finale Knowledge-Evidenz erreicht Schwellwert"},
|
||||
{"reply_answer_present", "Freigegebener Antwortinhalt vorhanden"},
|
||||
{"reply_category_scope", "Artikel ist für die effektive Ticketkategorie freigegeben"},
|
||||
} {
|
||||
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", spec.code, spec.label, "na", false, "–", "Knowledge-Artikel erforderlich", "Kein gültiger Knowledge-Artikel ausgewählt."))
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the historical decision codes stable. The first failed gate in this
|
||||
// ordered list is the actual blocking reason.
|
||||
switch {
|
||||
case !p.AutoReply:
|
||||
res.ReplyDecision = "reply_auto_disabled"
|
||||
case len(hits) == 0:
|
||||
res.ReplyDecision = "reply_no_knowledge_candidates"
|
||||
case !d.Reply.Allowed:
|
||||
res.ReplyDecision = "reply_model_not_recommended"
|
||||
case d.Reply.Confidence < p.ReplyConfidence:
|
||||
res.ReplyDecision = "reply_confidence_below_threshold"
|
||||
case res.ReplyKnowledgeID == "":
|
||||
res.ReplyDecision = "reply_no_knowledge_selected"
|
||||
case p.BlockReplyOnContextError && contextData.Incomplete:
|
||||
res.ReplyDecision = "reply_context_incomplete"
|
||||
case p.BlockReplyOnIncident && relevantIncident:
|
||||
res.ReplyDecision = "reply_relevant_incident"
|
||||
case selected == nil:
|
||||
res.ReplyDecision = "reply_knowledge_not_found"
|
||||
case !p.sourceAllowed(selected.Doc.Source):
|
||||
res.ReplyDecision = "reply_source_not_allowed"
|
||||
case !p.sourceAllowedForReply(selected.Doc.Source):
|
||||
res.ReplyDecision = "reply_source_not_allowed_for_auto_reply"
|
||||
case !strings.EqualFold(strings.TrimSpace(selected.Doc.Language), p.CommunicationLanguage):
|
||||
res.ReplyDecision = "reply_language_mismatch"
|
||||
case !strings.EqualFold(strings.TrimSpace(selected.Doc.CommunicationStyle), p.CommunicationStyle):
|
||||
res.ReplyDecision = "reply_style_mismatch"
|
||||
case !selected.Doc.AutoReply:
|
||||
res.ReplyDecision = "reply_knowledge_auto_reply_disabled"
|
||||
case selected.Score < p.KnowledgeRetrievalFloor:
|
||||
res.ReplyDecision = "reply_knowledge_retrieval_below_floor"
|
||||
case res.KnowledgeEvidenceScore < res.KnowledgeThreshold:
|
||||
res.ReplyDecision = "reply_knowledge_evidence_below_threshold"
|
||||
case strings.TrimSpace(selected.Doc.Answer) == "" && strings.TrimSpace(selected.Doc.AnswerHTML) == "":
|
||||
res.ReplyDecision = "reply_knowledge_answer_empty"
|
||||
case !knowledgeCategoryAllowed(selected.Doc, t.CategoryID, res.ChangeCategory, res.CategoryID):
|
||||
res.ReplyDecision = "reply_category_not_allowed"
|
||||
default:
|
||||
res.Reply = true
|
||||
if p.AIContentLabelEnabled {
|
||||
if strings.TrimSpace(selected.Doc.AnswerHTML) != "" {
|
||||
res.ReplyText = p.formatRichReply(selected.Doc.AnswerHTML)
|
||||
} else {
|
||||
res.ReplyText = p.formatRichReply(p.plainTextToHTML(selected.Doc.Answer))
|
||||
}
|
||||
res.ReplyIsHTML = true
|
||||
} else if strings.TrimSpace(selected.Doc.AnswerHTML) != "" {
|
||||
res.ReplyText = p.formatRichReply(selected.Doc.AnswerHTML)
|
||||
res.ReplyIsHTML = true
|
||||
} else {
|
||||
res.ReplyText = p.formatReply(selected.Doc.Answer)
|
||||
}
|
||||
res.KnowledgeID = selected.Doc.ID
|
||||
res.ReplyDecision = "reply_accepted"
|
||||
}
|
||||
res.KnowledgeID = hit.Doc.ID
|
||||
res.ReplyDecision = "reply_accepted"
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func check(group, code, label, status string, blocking bool, actual, expected, detail string) model.RuleCheck {
|
||||
return model.RuleCheck{Group: group, Code: code, Label: label, Status: status, Blocking: blocking && status == "fail", Actual: actual, Expected: expected, Detail: detail}
|
||||
}
|
||||
|
||||
func passFail(ok bool) string {
|
||||
if ok {
|
||||
return "pass"
|
||||
}
|
||||
return "fail"
|
||||
}
|
||||
|
||||
func boolStatus(ok bool) string { return passFail(ok) }
|
||||
func boolText(v bool) string {
|
||||
if v {
|
||||
return "ja"
|
||||
}
|
||||
return "nein"
|
||||
}
|
||||
func percentText(v float64) string { return fmt.Sprintf("%.1f %%", clampPolicy01(v)*100) }
|
||||
|
||||
func knowledgeCategoryAllowed(d model.KnowledgeDoc, currentCategory int64, change bool, target int64) bool {
|
||||
catID := currentCategory
|
||||
if change {
|
||||
catID = target
|
||||
}
|
||||
if len(d.Categories) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, id := range d.Categories {
|
||||
if id == catID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func categoryDisplayName(c model.Category) string {
|
||||
if strings.TrimSpace(c.CompleteName) != "" {
|
||||
return strings.TrimSpace(c.CompleteName)
|
||||
@@ -283,7 +398,7 @@ func (p Policy) plainTextToHTML(body string) string {
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
paragraphs := strings.Split(normalized, "\\n\\n")
|
||||
paragraphs := strings.Split(normalized, "\n\n")
|
||||
out := make([]string, 0, len(paragraphs))
|
||||
for _, paragraph := range paragraphs {
|
||||
paragraph = strings.TrimSpace(paragraph)
|
||||
|
||||
@@ -1020,6 +1020,32 @@ func (s *Store) persistVectorCache() error {
|
||||
return s.persistSnapshot()
|
||||
}
|
||||
|
||||
// FindMetadata searches knowledge metadata without copying the complete corpus.
|
||||
// It is intended for interactive UI lookups on very large knowledge stores.
|
||||
func (s *Store) FindMetadata(query string, limit int) []model.KnowledgeDoc {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]model.KnowledgeDoc, 0, limit)
|
||||
for _, d := range s.docs {
|
||||
hay := strings.ToLower(d.ID + " " + d.Title + " " + strings.Join(d.Keywords, " ") + " " + strings.Join(d.ExternalCategories, " "))
|
||||
if q != "" && !strings.Contains(hay, q) {
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) ManagedDir() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
|
||||
+74
-22
@@ -194,29 +194,76 @@ type Decision struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// RuleCheck is a machine-readable explanation of one deterministic decision gate.
|
||||
// Status is one of: pass, fail, warn, info, na.
|
||||
type RuleCheck struct {
|
||||
Code string `json:"code"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"`
|
||||
Blocking bool `json:"blocking,omitempty"`
|
||||
Actual string `json:"actual,omitempty"`
|
||||
Expected string `json:"expected,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// KnowledgeDiagnostic explains why one knowledge article was or was not
|
||||
// considered for a specific ticket run. The score is recalculated against the
|
||||
// current knowledge index; CurrentTicketChanged tells the UI when the ticket has
|
||||
// changed since the historical run.
|
||||
type KnowledgeDiagnostic struct {
|
||||
RunID string `json:"run_id"`
|
||||
TicketID int64 `json:"ticket_id"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
Title string `json:"title"`
|
||||
Source string `json:"source"`
|
||||
CurrentTicketChanged bool `json:"current_ticket_changed"`
|
||||
RetrievalRank int `json:"retrieval_rank"`
|
||||
RetrievalScore float64 `json:"retrieval_score"`
|
||||
SemanticScore float64 `json:"semantic_score,omitempty"`
|
||||
TitleScore float64 `json:"title_score,omitempty"`
|
||||
LexicalScore float64 `json:"lexical_score,omitempty"`
|
||||
KeywordScore float64 `json:"keyword_score,omitempty"`
|
||||
CategoryScore float64 `json:"category_score,omitempty"`
|
||||
CandidateCutoff float64 `json:"candidate_cutoff,omitempty"`
|
||||
SentToAI bool `json:"sent_to_ai"`
|
||||
SelectionReason string `json:"selection_reason"`
|
||||
AISelected bool `json:"ai_selected"`
|
||||
EvidenceScore float64 `json:"evidence_score,omitempty"`
|
||||
RequiredScore float64 `json:"required_score,omitempty"`
|
||||
BestChunkExcerpt string `json:"best_chunk_excerpt,omitempty"`
|
||||
BestQueryExcerpt string `json:"best_query_excerpt,omitempty"`
|
||||
ExternalCategories []string `json:"external_categories,omitempty"`
|
||||
UnmappedCategories []string `json:"unmapped_categories,omitempty"`
|
||||
Checks []RuleCheck `json:"checks"`
|
||||
Document KnowledgeDoc `json:"document"`
|
||||
}
|
||||
|
||||
type PolicyResult struct {
|
||||
ChangeCategory bool `json:"change_category"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
CategoryRecommendationID int64 `json:"category_recommendation_id"`
|
||||
CategoryRecommendationName string `json:"category_recommendation_name,omitempty"`
|
||||
CategoryConfidence float64 `json:"category_confidence"`
|
||||
CategoryThreshold float64 `json:"category_threshold"`
|
||||
CategoryDecision string `json:"category_decision"`
|
||||
Reply bool `json:"reply"`
|
||||
ReplyText string `json:"reply_text,omitempty"`
|
||||
ReplyIsHTML bool `json:"reply_is_html,omitempty"`
|
||||
KnowledgeID string `json:"knowledge_id,omitempty"`
|
||||
ReplyRecommendation bool `json:"reply_recommendation"`
|
||||
ReplyConfidence float64 `json:"reply_confidence"`
|
||||
ReplyThreshold float64 `json:"reply_threshold"`
|
||||
ReplyKnowledgeID string `json:"reply_knowledge_id,omitempty"`
|
||||
ReplyDecision string `json:"reply_decision"`
|
||||
KnowledgeThreshold float64 `json:"knowledge_threshold,omitempty"`
|
||||
KnowledgeRetrievalScore float64 `json:"knowledge_retrieval_score,omitempty"`
|
||||
KnowledgeEvidenceScore float64 `json:"knowledge_evidence_score,omitempty"`
|
||||
KnowledgeRetrievalFloor float64 `json:"knowledge_retrieval_floor,omitempty"`
|
||||
KnowledgeCategoryAligned bool `json:"knowledge_category_aligned,omitempty"`
|
||||
AIReason string `json:"ai_reason,omitempty"`
|
||||
ChangeCategory bool `json:"change_category"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
CategoryRecommendationID int64 `json:"category_recommendation_id"`
|
||||
CategoryRecommendationName string `json:"category_recommendation_name,omitempty"`
|
||||
CategoryConfidence float64 `json:"category_confidence"`
|
||||
CategoryThreshold float64 `json:"category_threshold"`
|
||||
CategoryDecision string `json:"category_decision"`
|
||||
Reply bool `json:"reply"`
|
||||
ReplyText string `json:"reply_text,omitempty"`
|
||||
ReplyIsHTML bool `json:"reply_is_html,omitempty"`
|
||||
KnowledgeID string `json:"knowledge_id,omitempty"`
|
||||
ReplyRecommendation bool `json:"reply_recommendation"`
|
||||
ReplyConfidence float64 `json:"reply_confidence"`
|
||||
ReplyThreshold float64 `json:"reply_threshold"`
|
||||
ReplyKnowledgeID string `json:"reply_knowledge_id,omitempty"`
|
||||
ReplyDecision string `json:"reply_decision"`
|
||||
KnowledgeThreshold float64 `json:"knowledge_threshold,omitempty"`
|
||||
KnowledgeRetrievalScore float64 `json:"knowledge_retrieval_score,omitempty"`
|
||||
KnowledgeEvidenceScore float64 `json:"knowledge_evidence_score,omitempty"`
|
||||
KnowledgeRetrievalFloor float64 `json:"knowledge_retrieval_floor,omitempty"`
|
||||
KnowledgeCategoryAligned bool `json:"knowledge_category_aligned,omitempty"`
|
||||
AIReason string `json:"ai_reason,omitempty"`
|
||||
CategoryChecks []RuleCheck `json:"category_checks,omitempty"`
|
||||
ReplyChecks []RuleCheck `json:"reply_checks,omitempty"`
|
||||
}
|
||||
|
||||
// KnowledgeCandidateAudit captures the top retrieval candidates used for a run.
|
||||
@@ -238,6 +285,8 @@ type KnowledgeCandidateAudit struct {
|
||||
QueryChunkCount int `json:"query_chunk_count,omitempty"`
|
||||
DocumentChunkCount int `json:"document_chunk_count,omitempty"`
|
||||
SentToAI bool `json:"sent_to_ai,omitempty"`
|
||||
RetrievalRank int `json:"retrieval_rank,omitempty"`
|
||||
SelectionReason string `json:"selection_reason,omitempty"`
|
||||
}
|
||||
|
||||
// ContextAuditItem is a compact snapshot of context that influenced a run.
|
||||
@@ -261,6 +310,9 @@ type RunRecord struct {
|
||||
Reason string `json:"reason"`
|
||||
AIReason string `json:"ai_reason,omitempty"`
|
||||
PolicyReason string `json:"policy_reason,omitempty"`
|
||||
CategoryChecks []RuleCheck `json:"category_checks,omitempty"`
|
||||
ReplyChecks []RuleCheck `json:"reply_checks,omitempty"`
|
||||
ExecutionChecks []RuleCheck `json:"execution_checks,omitempty"`
|
||||
CategoryBefore int64 `json:"category_before"`
|
||||
CategoryBeforeName string `json:"category_before_name,omitempty"`
|
||||
AIRecommendedCategoryID int64 `json:"ai_recommended_category_id,omitempty"`
|
||||
|
||||
+92
-10
@@ -25,7 +25,7 @@ import (
|
||||
"github.com/example/glpi-ai-agent/internal/state"
|
||||
)
|
||||
|
||||
//go:embed templates/dashboard.html
|
||||
//go:embed templates/dashboard.html templates/diagnostics.html
|
||||
var files embed.FS
|
||||
|
||||
type KnowledgeManager interface {
|
||||
@@ -38,6 +38,7 @@ type KnowledgeManager interface {
|
||||
LoadStats() knowledgepkg.LoadStats
|
||||
InitStatus() knowledgepkg.InitStatus
|
||||
Ready() bool
|
||||
FindMetadata(string, int) []model.KnowledgeDoc
|
||||
}
|
||||
type FeedbackManager interface {
|
||||
Categories(context.Context) ([]model.Category, error)
|
||||
@@ -47,22 +48,32 @@ type FeedbackManager interface {
|
||||
LearningCount() int
|
||||
}
|
||||
|
||||
type DiagnosticsManager interface {
|
||||
DiagnoseRun(context.Context, string) (model.RunRecord, error)
|
||||
DiagnoseKnowledge(context.Context, string, string) (model.KnowledgeDiagnostic, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
metrics *metrics.Metrics
|
||||
state *state.Store
|
||||
q *queue.Queue
|
||||
knowledge KnowledgeManager
|
||||
feedback FeedbackManager
|
||||
tpl *template.Template
|
||||
cfg config.Config
|
||||
metrics *metrics.Metrics
|
||||
state *state.Store
|
||||
q *queue.Queue
|
||||
knowledge KnowledgeManager
|
||||
feedback FeedbackManager
|
||||
diagnostics DiagnosticsManager
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
func New(cfg config.Config, m *metrics.Metrics, s *state.Store, q *queue.Queue, k KnowledgeManager, f FeedbackManager) (*Server, error) {
|
||||
t, err := template.ParseFS(files, "templates/dashboard.html")
|
||||
t, err := template.ParseFS(files, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{cfg: cfg, metrics: m, state: s, q: q, knowledge: k, feedback: f, tpl: t}, nil
|
||||
srv := &Server{cfg: cfg, metrics: m, state: s, q: q, knowledge: k, feedback: f, tpl: t}
|
||||
if d, ok := f.(DiagnosticsManager); ok {
|
||||
srv.diagnostics = d
|
||||
}
|
||||
return srv, nil
|
||||
}
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
@@ -70,6 +81,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /readyz", s.ready)
|
||||
mux.HandleFunc("GET /metrics", s.prom)
|
||||
mux.Handle("GET /", s.auth(http.HandlerFunc(s.dashboard)))
|
||||
mux.Handle("GET /diagnostics", s.auth(http.HandlerFunc(s.diagnosticsPage)))
|
||||
mux.Handle("GET /api/diagnostics/run/{id}", s.auth(http.HandlerFunc(s.diagnosticRun)))
|
||||
mux.Handle("GET /api/diagnostics/run/{id}/knowledge", s.auth(http.HandlerFunc(s.diagnosticKnowledge)))
|
||||
mux.Handle("GET /api/diagnostics/knowledge", s.auth(http.HandlerFunc(s.diagnosticKnowledgeSearch)))
|
||||
mux.Handle("GET /api/status", s.auth(http.HandlerFunc(s.status)))
|
||||
mux.Handle("GET /api/runs", s.auth(http.HandlerFunc(s.runs)))
|
||||
mux.Handle("GET /api/categories", s.auth(http.HandlerFunc(s.categories)))
|
||||
@@ -110,6 +125,73 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = s.tpl.ExecuteTemplate(w, "dashboard.html", map[string]any{"DryRun": s.cfg.DryRun, "AutoReply": s.cfg.AutoReply, "AutoCategory": s.cfg.AutoCategory, "CommunicationLanguage": s.cfg.CommunicationLanguage, "CommunicationStyle": s.cfg.CommunicationStyle})
|
||||
}
|
||||
func (s *Server) diagnosticsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/diagnostics" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_ = s.tpl.ExecuteTemplate(w, "diagnostics.html", nil)
|
||||
}
|
||||
|
||||
func (s *Server) diagnosticRun(w http.ResponseWriter, r *http.Request) {
|
||||
if s.diagnostics == nil {
|
||||
http.Error(w, "diagnostics unavailable", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
run, err := s.diagnostics.DiagnoseRun(r.Context(), strings.TrimSpace(r.PathValue("id")))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
respondJSON(w, run)
|
||||
}
|
||||
|
||||
func (s *Server) diagnosticKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
if s.diagnostics == nil {
|
||||
http.Error(w, "diagnostics unavailable", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
kbID := strings.TrimSpace(r.URL.Query().Get("knowledge_id"))
|
||||
if kbID == "" {
|
||||
http.Error(w, "knowledge_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
d, err := s.diagnostics.DiagnoseKnowledge(r.Context(), strings.TrimSpace(r.PathValue("id")), kbID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
respondJSON(w, d)
|
||||
}
|
||||
|
||||
func (s *Server) diagnosticKnowledgeSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
|
||||
limit := 30
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && n > 0 && n <= 100 {
|
||||
limit = n
|
||||
}
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Source string `json:"source"`
|
||||
AutoReply bool `json:"auto_reply"`
|
||||
Unmapped []string `json:"unmapped_categories,omitempty"`
|
||||
}
|
||||
out := make([]item, 0, limit)
|
||||
for _, d := range s.knowledge.List() {
|
||||
hay := strings.ToLower(d.ID + " " + d.Title + " " + strings.Join(d.Keywords, " ") + " " + strings.Join(d.ExternalCategories, " "))
|
||||
if q != "" && !strings.Contains(hay, q) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item{ID: d.ID, Title: d.Title, Source: d.Source, AutoReply: d.AutoReply, Unmapped: append([]string(nil), d.UnmappedExternalCategories...)})
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
respondJSON(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) status(w http.ResponseWriter, r *http.Request) {
|
||||
g, o := s.metrics.Health()
|
||||
kbOK, kbDocs, kbLastSync, kbLastErr := s.metrics.GLPIKBStatus()
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestExtractTicketID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDashboardTemplateParses(t *testing.T) {
|
||||
if _, err := template.ParseFS(files, "templates/dashboard.html"); err != nil {
|
||||
if _, err := template.ParseFS(files, "templates/*.html"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -171,3 +171,15 @@ func TestDashboardContainsControlCenterSections(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosticsTemplateAndKnowledgeSearch(t *testing.T) {
|
||||
h, _ := newKnowledgeTestServer(t)
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/diagnostics", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("diagnostics = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rr.Body.Bytes(), []byte("Entscheidungsdiagnose")) {
|
||||
t.Fatal("diagnostics page missing title")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ button,input,textarea,select{font:inherit}button{color:inherit}.app{display:grid
|
||||
<button class="nav-btn" data-view="knowledge"><span class="nav-icon">▤</span>Knowledge Base</button>
|
||||
<button class="nav-btn" data-view="learning"><span class="nav-icon">◇</span>Lernen</button>
|
||||
<button class="nav-btn" data-view="settings"><span class="nav-icon">⚙</span>Konfiguration</button>
|
||||
<a class="nav-btn" href="/diagnostics" style="text-decoration:none"><span class="nav-icon">⌁</span>Diagnose</a>
|
||||
</nav>
|
||||
<div class="side-bottom"><div class="modebox" id="sideMode">Status wird geladen…</div></div>
|
||||
</aside>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user