Update am Dashboard
release-tag / release-image (push) Successful in 1m37s

This commit is contained in:
2026-07-28 05:33:10 +02:00
parent 1c12e3ed60
commit 40a98e0f57
21 changed files with 1165 additions and 183 deletions
+133 -5
View File
@@ -14,6 +14,7 @@ import (
"github.com/example/glpi-ai-agent/internal/config"
"github.com/example/glpi-ai-agent/internal/knowledge"
"github.com/example/glpi-ai-agent/internal/learning"
"github.com/example/glpi-ai-agent/internal/metrics"
"github.com/example/glpi-ai-agent/internal/model"
"github.com/example/glpi-ai-agent/internal/queue"
@@ -42,6 +43,7 @@ type Service struct {
glpi GLPI
ai AI
knowledge *knowledge.Store
learning *learning.Store
state *state.Store
q *queue.Queue
metrics *metrics.Metrics
@@ -53,8 +55,8 @@ type Service struct {
catAt time.Time
}
func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service {
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, l *learning.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service {
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
}
func (s *Service) Queue() *queue.Queue { return s.q }
func (s *Service) Start(ctx context.Context) {
@@ -355,7 +357,7 @@ func (s *Service) getCategories(ctx context.Context) ([]model.Category, error) {
if len(s.categories) > 0 && time.Since(s.catAt) < 10*time.Minute {
out := append([]model.Category(nil), s.categories...)
s.catMu.RUnlock()
return out, nil
return s.enrichCategories(out), nil
}
s.catMu.RUnlock()
cats, err := s.glpi.GetCategories(ctx)
@@ -366,8 +368,134 @@ func (s *Service) getCategories(ctx context.Context) ([]model.Category, error) {
s.categories = append([]model.Category(nil), cats...)
s.catAt = time.Now()
s.catMu.Unlock()
return cats, nil
return s.enrichCategories(cats), nil
}
func (s *Service) enrichCategories(cats []model.Category) []model.Category {
out := append([]model.Category(nil), cats...)
byID := make(map[int64]*model.Category, len(out))
for i := range out {
byID[out[i].ID] = &out[i]
out[i].Hints = append(out[i].Hints, semanticCategoryHints(out[i])...)
}
for _, doc := range s.knowledge.List() {
for _, id := range doc.Categories {
if c := byID[id]; c != nil {
c.Hints = appendUnique(c.Hints, doc.Title)
for _, k := range doc.Keywords {
c.Hints = appendUnique(c.Hints, k)
}
}
}
}
if s.cfg.LearningEnabled && s.learning != nil {
for i := range out {
out[i].Examples = s.learning.ExamplesFor(out[i].ID, s.cfg.LearningExamplesPerCategory)
}
}
return out
}
// Categories exposes the same enriched category catalogue that is supplied to
// Ollama. It is used by the authenticated dashboard for human feedback.
func (s *Service) Categories(ctx context.Context) ([]model.Category, error) {
return s.getCategories(ctx)
}
func (s *Service) RecordCategoryFeedback(ctx context.Context, runID string, categoryID int64) (model.LearningExample, error) {
if !s.cfg.LearningEnabled || s.learning == nil {
return model.LearningExample{}, fmt.Errorf("learning is disabled")
}
run, ok := s.state.FindRun(strings.TrimSpace(runID))
if !ok {
return model.LearningExample{}, fmt.Errorf("run not found")
}
cats, err := s.getCategories(ctx)
if err != nil {
return model.LearningExample{}, err
}
name := categoryName(cats, categoryID)
if categoryID <= 0 || name == "" {
return model.LearningExample{}, fmt.Errorf("unknown category id %d", categoryID)
}
t, err := s.glpi.GetTicket(ctx, run.TicketID)
if err != nil {
return model.LearningExample{}, err
}
if sourceVersion(t) != run.SourceVersion {
return model.LearningExample{}, fmt.Errorf("ticket changed since this run; process the current ticket state before teaching it")
}
ex := model.LearningExample{RunID: run.RunID, TicketID: t.ID, Subject: strings.TrimSpace(t.Name), Text: compactLearningText(stripHTML(t.Content), 1200), CategoryID: categoryID, CategoryName: name, AIRecommendedCategoryID: run.AIRecommendedCategoryID, AIConfidence: run.AICategoryConfidence, Correction: run.AIRecommendedCategoryID != categoryID, Source: "human-confirmed"}
return s.learning.Add(ex)
}
func (s *Service) LearningExamples() []model.LearningExample {
if s.learning == nil {
return nil
}
return s.learning.List()
}
func (s *Service) DeleteLearning(id string) error {
if s.learning == nil {
return fmt.Errorf("learning is disabled")
}
return s.learning.Delete(id)
}
func (s *Service) LearningCount() int {
if s.learning == nil {
return 0
}
return s.learning.Count()
}
func appendUnique(in []string, v string) []string {
v = strings.TrimSpace(v)
if v == "" {
return in
}
for _, x := range in {
if strings.EqualFold(strings.TrimSpace(x), v) {
return in
}
}
return append(in, v)
}
func compactLearningText(v string, max int) string {
v = strings.Join(strings.Fields(v), " ")
r := []rune(v)
if len(r) <= max {
return v
}
return string(r[:max]) + "…"
}
func semanticCategoryHints(c model.Category) []string {
name := strings.ToLower(c.Name + " " + c.CompleteName)
var h []string
add := func(vals ...string) {
for _, v := range vals {
h = appendUnique(h, v)
}
}
if strings.Contains(name, "active directory") || strings.Contains(name, "entra") || strings.Contains(name, "identity") || strings.Contains(name, "benutzerkonto") || strings.Contains(name, "account") {
add("Benutzerkonto", "Anmeldung / Login", "Konto gesperrt", "Passwort", "Domänenkonto", "Gruppen und Berechtigungen", "Authentifizierung")
}
if strings.Contains(name, "druck") || strings.Contains(name, "printer") {
add("Drucker", "Drucken nicht möglich", "Druckwarteschlange", "Netzwerkdrucker", "Toner", "Papierstau")
}
if strings.Contains(name, "vpn") {
add("VPN-Verbindung", "Remote Access", "Gateway", "GlobalProtect", "Tunnel", "Verbindungsaufbau")
}
if strings.Contains(name, "mail") || strings.Contains(name, "outlook") || strings.Contains(name, "exchange") {
add("E-Mail", "Outlook", "Postfach", "E-Mail Versand und Empfang", "Exchange")
}
if strings.Contains(name, "netz") || strings.Contains(name, "network") || strings.Contains(name, "wlan") || strings.Contains(name, "wifi") {
add("Netzwerk", "LAN", "WLAN", "Keine Verbindung", "DNS", "IP-Adresse")
}
if strings.Contains(name, "hardware") || strings.Contains(name, "client") || strings.Contains(name, "arbeitsplatz") {
add("Arbeitsplatzgerät", "Notebook", "PC", "Dockingstation", "Peripherie")
}
return h
}
func categoryName(categories []model.Category, id int64) string {
if id == 0 {
return "Nicht gesetzt"
@@ -439,7 +567,7 @@ func shortlistCategories(t model.Ticket, cats []model.Category, limit int) []mod
}
ss := make([]scored, 0, len(cats))
for _, c := range cats {
name := strings.ToLower(c.Name + " " + c.CompleteName)
name := strings.ToLower(c.Name + " " + c.CompleteName + " " + strings.Join(c.Hints, " ") + " " + strings.Join(c.Examples, " "))
score := 0
for _, w := range q {
if len(w) >= 3 && strings.Contains(name, w) {
+9 -1
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"os"
"strings"
"testing"
"time"
@@ -81,7 +82,7 @@ func newTestService(t *testing.T, g *fakeGLPI, d model.Decision, autoReply bool)
t.Fatal(err)
}
cfg := config.Config{DryRun: false, AutoCategory: true, AutoReply: autoReply, CategoryConfidence: .9, ReplyConfidence: .9, KnowledgeMinScore: 0, KnowledgeTopK: 1, CategoryPromptLimit: 20, Workers: 1, GLPIAllowedStatusIDs: []int64{1}, KnowledgeAllowedSources: []string{"internal-kb"}, KnowledgeAutoReplySources: []string{"internal-kb"}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", CommunicationSalutation: "Guten Tag,", CommunicationClosing: "Mit freundlichen Grüßen", CommunicationSignature: "IT-Service"}
return New(cfg, g, fakeAI{d: d}, k, st, queue.New(8), metrics.New(), nil)
return New(cfg, g, fakeAI{d: d}, k, nil, st, queue.New(8), metrics.New(), nil)
}
func TestExistingFollowupBlocksReplyButNotCategory(t *testing.T) {
@@ -156,3 +157,10 @@ func TestRunAuditExplainsCategoryBelowThreshold(t *testing.T) {
t.Fatalf("missing reason audit: %+v", r)
}
}
func TestSemanticHintsImproveActiveDirectoryCategory(t *testing.T) {
h := strings.Join(semanticCategoryHints(model.Category{ID: 2, Name: "Active Directory"}), " ")
if !strings.Contains(strings.ToLower(h), "konto gesperrt") || !strings.Contains(strings.ToLower(h), "anmeldung") {
t.Fatalf("expected identity hints, got %q", h)
}
}