Update Klassifizierung - Teil 1
This commit is contained in:
+25
-7
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user