Update Klassifizierung - Teil 1

This commit is contained in:
2026-08-01 21:53:16 +02:00
parent bca5c1335c
commit a5cc4f881c
119 changed files with 3710 additions and 194 deletions
+25 -7
View File
@@ -33,7 +33,7 @@ type GLPI interface {
}
type AI interface {
Ping(context.Context) error
Analyse(context.Context, model.Ticket, []model.Category, []model.KnowledgeHit, model.ContextSnapshot) (model.Decision, error)
Analyse(context.Context, model.Ticket, []model.Category, []model.KnowledgeHit, []model.KnowledgeHit, model.ContextSnapshot) (model.Decision, error)
}
type ContextCollector interface {
Collect(context.Context, model.Ticket) model.ContextSnapshot
@@ -203,18 +203,29 @@ func (s *Service) Process(ctx context.Context, id int64) error {
if llmTopK <= 0 {
llmTopK = 6
}
retrievalHits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), auditTopK, categories)
allRetrievalHits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), 0, categories)
if err != nil {
run.Reason = "knowledge_search_failed"
finish(err)
return err
}
categoryRetrievalHits := knowledge.FilterHitsBySources(allRetrievalHits, s.cfg.KnowledgeCategorySources, auditTopK)
retrievalHits := knowledge.FilterHitsBySources(allRetrievalHits, s.cfg.KnowledgeAllowedSources, auditTopK)
categoryLLMHits, _ := selectKnowledgeCandidates(categoryRetrievalHits, llmTopK, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap)
llmHits, candidateCutoff := selectKnowledgeCandidates(retrievalHits, llmTopK, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap)
run.KnowledgeLLMCandidates = len(llmHits)
// Reply selection is independent from category classification. Do not expose
// reply candidates when a reply is already impossible (for example because
// a followup exists or AUTO_REPLY is disabled). This also gives the Ollama
// client an explicit category-only mode.
replyLLMHits := llmHits
if !canReply || !s.cfg.AutoReply {
replyLLMHits = nil
}
run.KnowledgeLLMCandidates = len(replyLLMHits)
run.KnowledgeCandidateCutoff = candidateCutoff
run.KnowledgeCandidateMaxGap = s.cfg.KnowledgeCandidateMaxGap
run.KnowledgeAuditTopK = auditTopK
llmCandidateIDs := knowledgeHitIDSet(llmHits)
llmCandidateIDs := knowledgeHitIDSet(replyLLMHits)
contextData := model.ContextSnapshot{}
if s.context != nil && s.cfg.ContextEnabled {
s.metrics.ContextFetches.Add(1)
@@ -229,7 +240,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
s.metrics.ContextErrors.Add(1)
}
}
decision, err := s.ai.Analyse(ctx, t, promptCats, llmHits, contextData)
decision, err := s.ai.Analyse(ctx, t, promptCats, categoryLLMHits, replyLLMHits, contextData)
if err != nil {
run.Reason = "ai_failed"
finish(err)
@@ -290,7 +301,9 @@ func (s *Service) Process(ctx context.Context, id int64) error {
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 {
if !canReply {
// An existing followup is the authoritative execution-level reason why
// no reply can be proposed, regardless of the model/policy recommendation.
run.ReplyProposed = false
run.ReplyDecision = "reply_existing_followup"
run.PolicyReason = result.CategoryDecision + "; " + run.ReplyDecision
@@ -445,10 +458,11 @@ func (s *Service) DiagnoseKnowledge(ctx context.Context, runID, knowledgeID stri
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)
indexedHits, err := s.knowledge.Search(ctx, query, 0, cats)
if err != nil {
return model.KnowledgeDiagnostic{}, err
}
allHits := knowledge.FilterHitsBySources(indexedHits, s.cfg.KnowledgeAllowedSources, 0)
maxCandidates := s.cfg.KnowledgeTopK
if maxCandidates <= 0 {
maxCandidates = 6
@@ -667,7 +681,11 @@ func (s *Service) enrichCategories(cats []model.Category) []model.Category {
byID[out[i].ID] = &out[i]
out[i].Hints = append(out[i].Hints, semanticCategoryHints(out[i])...)
}
categorySources := sourceSet(s.cfg.KnowledgeCategorySources)
for _, doc := range s.knowledge.List() {
if _, allowed := categorySources[strings.ToLower(strings.TrimSpace(doc.Source))]; !allowed {
continue
}
for _, id := range doc.Categories {
if c := byID[id]; c != nil {
c.Hints = appendUnique(c.Hints, doc.Title)
+46 -3
View File
@@ -55,10 +55,16 @@ func (f *fakeGLPI) AddFollowup(context.Context, int64, string, bool) error {
}
func (f *fakeGLPI) GetCategories(context.Context) ([]model.Category, error) { return f.cats, nil }
type fakeAI struct{ d model.Decision }
type fakeAI struct {
d model.Decision
replyHitCount *int
}
func (f fakeAI) Ping(context.Context) error { return nil }
func (f fakeAI) Analyse(context.Context, model.Ticket, []model.Category, []model.KnowledgeHit, model.ContextSnapshot) (model.Decision, error) {
func (f fakeAI) Analyse(_ context.Context, _ model.Ticket, _ []model.Category, _ []model.KnowledgeHit, replyHits []model.KnowledgeHit, _ model.ContextSnapshot) (model.Decision, error) {
if f.replyHitCount != nil {
*f.replyHitCount = len(replyHits)
}
return f.d, nil
}
@@ -81,7 +87,7 @@ func newTestService(t *testing.T, g *fakeGLPI, d model.Decision, autoReply bool)
if err != nil {
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", AIContentLabelEnabled: true}
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"}, KnowledgeCategorySources: []string{"internal-kb"}, KnowledgeAutoReplySources: []string{"internal-kb"}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", CommunicationSalutation: "Guten Tag,", CommunicationClosing: "Mit freundlichen Grüßen", CommunicationSignature: "IT-Service", AIContentLabelEnabled: true}
return New(cfg, g, fakeAI{d: d}, k, nil, st, queue.New(8), metrics.New(), nil)
}
@@ -91,6 +97,8 @@ func TestExistingFollowupBlocksReplyButNotCategory(t *testing.T) {
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)
replyHitCount := -1
svc.ai = fakeAI{d: d, replyHitCount: &replyHitCount}
if err := svc.Process(context.Background(), 1); err != nil {
t.Fatal(err)
}
@@ -100,6 +108,13 @@ func TestExistingFollowupBlocksReplyButNotCategory(t *testing.T) {
if g.addReply != 0 {
t.Fatalf("reply writes=%d", g.addReply)
}
if replyHitCount != 0 {
t.Fatalf("reply candidates sent to AI=%d, want 0", replyHitCount)
}
runs := svc.state.Recent(1)
if len(runs) != 1 || runs[0].ReplyDecision != "reply_existing_followup" || runs[0].Outcome != "processed" {
t.Fatalf("unexpected run audit: %+v", runs)
}
}
func TestRaceFollowupBlocksReply(t *testing.T) {
@@ -216,3 +231,31 @@ func TestDiagnoseKnowledgeExplainsCandidate(t *testing.T) {
t.Fatal("ticket should not be marked changed")
}
}
func TestCategoryHintsUseOnlyConfiguredCategorySources(t *testing.T) {
dir := t.TempDir()
kDir := dir + "/knowledge"
if err := os.MkdirAll(kDir, 0o755); err != nil {
t.Fatal(err)
}
categoryDoc := `{"id":"CAT","title":"Category selector","text":"category evidence","categories":[2],"keywords":["category-only-hint"],"source":"internal-category","language":"de-DE","communication_style":"formal"}`
replyDoc := `{"id":"REPLY","title":"Reply article","text":"reply evidence","answer":"answer","categories":[1],"keywords":["reply-only-hint"],"source":"internal-kb","language":"de-DE","communication_style":"formal"}`
if err := os.WriteFile(kDir+"/cat.json", []byte(categoryDoc), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(kDir+"/reply.json", []byte(replyDoc), 0o644); err != nil {
t.Fatal(err)
}
k, err := knowledge.Load(context.Background(), kDir, dir, nil, false, []string{"internal-category", "internal-kb"})
if err != nil {
t.Fatal(err)
}
svc := &Service{cfg: config.Config{KnowledgeCategorySources: []string{"internal-category"}}, knowledge: k}
cats := svc.enrichCategories([]model.Category{{ID: 1, Name: "One"}, {ID: 2, Name: "Two"}})
if strings.Contains(strings.Join(cats[0].Hints, " "), "reply-only-hint") {
t.Fatalf("normal reply source influenced category hints: %+v", cats[0].Hints)
}
if !strings.Contains(strings.Join(cats[1].Hints, " "), "category-only-hint") {
t.Fatalf("category source hint missing: %+v", cats[1].Hints)
}
}