Diagnose impllementiert
release-tag / release-image (push) Successful in 1m34s

This commit is contained in:
2026-07-29 22:19:43 +02:00
parent a10225d74c
commit 7ec10c490b
74 changed files with 1992 additions and 164 deletions
+139 -6
View File
@@ -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
+52
View File
@@ -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
View File
@@ -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)
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+13 -1
View File
@@ -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")
}
}
+1
View File
@@ -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