This commit is contained in:
+133
-5
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user