Files
glpi-ai-agent/internal/agent/agent.go
T
jbergner a01b53097a
release-tag / release-image (push) Successful in 1m41s
RC-1
2026-07-28 20:02:03 +02:00

690 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"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"
"github.com/example/glpi-ai-agent/internal/state"
)
type GLPI interface {
Ping(context.Context) error
ValidateContract(context.Context) error
ListRecentTickets(context.Context, int, string) ([]model.Ticket, error)
GetTicket(context.Context, int64) (model.Ticket, error)
GetFollowups(context.Context, int64) ([]model.Followup, error)
SetCategory(context.Context, int64, int64) error
AddFollowup(context.Context, int64, string) error
GetCategories(context.Context) ([]model.Category, error)
}
type AI interface {
Ping(context.Context) error
Analyse(context.Context, model.Ticket, []model.Category, []model.KnowledgeHit, model.ContextSnapshot) (model.Decision, error)
}
type ContextCollector interface {
Collect(context.Context, model.Ticket) model.ContextSnapshot
}
type Service struct {
cfg config.Config
glpi GLPI
ai AI
knowledge *knowledge.Store
learning *learning.Store
state *state.Store
q *queue.Queue
metrics *metrics.Metrics
policy Policy
context ContextCollector
locks sync.Map
catMu sync.RWMutex
categories []model.Category
catAt time.Time
}
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.KnowledgeRetrievalFloor, cfg.KnowledgeEvidenceRetrievalWeight, cfg.KnowledgeEvidenceAIWeight, cfg.KnowledgeEvidenceCategoryWeight, 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) {
go s.healthLoop(ctx)
go s.pollLoop(ctx)
for i := 0; i < s.cfg.Workers; i++ {
go s.worker(ctx, i)
}
}
func (s *Service) pollLoop(ctx context.Context) {
ticker := time.NewTicker(s.cfg.GLPIPollInterval)
defer ticker.Stop()
s.poll(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.poll(ctx)
}
}
}
func (s *Service) poll(ctx context.Context) {
tickets, err := s.glpi.ListRecentTickets(ctx, s.cfg.GLPIPollLimit, s.cfg.GLPITicketFilter)
s.metrics.Polls.Add(1)
s.metrics.SetLastPoll(time.Now())
if err != nil {
s.metrics.Errors.Add(1)
slog.Error("GLPI poll failed", "error", err)
return
}
for _, t := range tickets {
version := sourceVersion(t)
if !s.state.Seen(t.ID, version) {
if s.q.Enqueue(t.ID) {
s.metrics.QueueDepth.Store(int64(s.q.Len()))
}
}
}
}
func (s *Service) healthLoop(ctx context.Context) {
check := func() {
c, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
gerr := s.glpi.Ping(c)
oerr := s.ai.Ping(c)
s.metrics.SetHealth(gerr == nil, oerr == nil)
}
check()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
check()
}
}
}
func (s *Service) worker(ctx context.Context, n int) {
for {
id, ok := s.q.Next(ctx)
if !ok {
return
}
s.metrics.QueueDepth.Store(int64(s.q.Len()))
if err := s.Process(ctx, id); err != nil {
slog.Error("ticket processing failed", "worker", n, "ticket_id", id, "error", err)
}
s.q.Done(id)
s.metrics.QueueDepth.Store(int64(s.q.Len()))
}
}
func (s *Service) Process(ctx context.Context, id int64) error {
muAny, _ := s.locks.LoadOrStore(id, &sync.Mutex{})
mu := muAny.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
start := time.Now()
run := model.RunRecord{RunID: newRunID(), TicketID: id, StartedAt: start, DryRun: s.cfg.DryRun, Outcome: "error"}
finish := func(err error) {
run.FinishedAt = time.Now()
if err != nil {
run.Error = err.Error()
s.metrics.Errors.Add(1)
}
if e := s.state.Append(run); e != nil {
slog.Error("persist run failed", "error", e)
}
}
t, err := s.glpi.GetTicket(ctx, id)
if err != nil {
run.Reason = "ticket_load_failed"
finish(err)
return err
}
run.TicketName = t.Name
run.SourceVersion = sourceVersion(t)
run.CategoryBefore = t.CategoryID
if s.state.Seen(t.ID, run.SourceVersion) {
run.Outcome = "skipped"
run.Reason = "already_processed"
s.metrics.Skipped.Add(1)
finish(nil)
return nil
}
if !s.statusAllowed(t.StatusID) {
run.Outcome = "skipped"
run.Reason = "status_not_allowed"
s.metrics.Skipped.Add(1)
finish(nil)
return nil
}
followups, err := s.glpi.GetFollowups(ctx, id)
if err != nil {
run.Reason = "followup_check_failed"
finish(err)
return err
}
canReply := len(followups) == 0
categories, err := s.getCategories(ctx)
if err != nil {
run.Reason = "categories_failed"
finish(err)
return err
}
run.CategoryBeforeName = categoryName(categories, t.CategoryID)
promptCats := shortlistCategories(t, categories, s.cfg.CategoryPromptLimit)
hits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), s.cfg.KnowledgeTopK, categories)
if err != nil {
run.Reason = "knowledge_search_failed"
finish(err)
return err
}
contextData := model.ContextSnapshot{}
if s.context != nil && s.cfg.ContextEnabled {
s.metrics.ContextFetches.Add(1)
contextData = s.context.Collect(ctx, t)
run.ContextChanges = len(contextData.Changes)
run.ContextIncidents = len(contextData.MajorIncidents)
run.ContextIssues = len(contextData.ServiceIssues)
run.ContextDevices = len(contextData.UserDevices)
run.ContextWarnings = append([]string(nil), contextData.Warnings...)
run.ContextDetails = auditContextDetails(contextData, 5)
if contextData.Incomplete {
s.metrics.ContextErrors.Add(1)
}
}
decision, err := s.ai.Analyse(ctx, t, promptCats, hits, contextData)
if err != nil {
run.Reason = "ai_failed"
finish(err)
return err
}
// The classifier provides an independent category recommendation. Knowledge
// explicitly mapped to that category receives a deterministic post-retrieval
// alignment signal before the final policy gate.
hits = s.knowledge.RerankForCategory(hits, decision.Category.ID)
if len(hits) > 0 {
run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, 5)
run.KnowledgeTopID = hits[0].Doc.ID
run.KnowledgeTopTitle = hits[0].Doc.Title
run.KnowledgeScore = hits[0].Score
run.KnowledgeSemanticScore = hits[0].SemanticScore
run.KnowledgeTitleScore = hits[0].TitleScore
run.KnowledgeLexicalScore = hits[0].LexicalScore
run.KnowledgeKeywordScore = hits[0].KeywordScore
run.KnowledgeCategoryScore = hits[0].CategoryScore
run.KnowledgeBestChunk = hits[0].BestChunkExcerpt
run.KnowledgeBestQueryChunk = hits[0].BestQueryExcerpt
run.KnowledgeQueryChunks = hits[0].QueryChunkCount
run.KnowledgeDocumentChunks = hits[0].DocumentChunkCount
run.KnowledgeThreshold = s.cfg.KnowledgeMinScore
if hits[0].Doc.MinScore > run.KnowledgeThreshold {
run.KnowledgeThreshold = hits[0].Doc.MinScore
}
}
result, err := s.policy.Evaluate(t, decision, categories, hits, contextData)
if err != nil {
run.Reason = "policy_rejected"
finish(err)
return err
}
run.AIReason = result.AIReason
run.Reason = result.AIReason // backwards compatible audit field
run.AIRecommendedCategoryID = result.CategoryRecommendationID
run.AIRecommendedCategoryName = result.CategoryRecommendationName
run.AICategoryConfidence = result.CategoryConfidence
run.CategoryThreshold = result.CategoryThreshold
run.CategoryDecision = result.CategoryDecision
run.CategoryProposed = result.CategoryID
run.CategoryWouldChange = result.ChangeCategory
run.AIReplyRecommended = result.ReplyRecommendation
run.AIReplyConfidence = result.ReplyConfidence
run.ReplyThreshold = result.ReplyThreshold
run.AIKnowledgeID = result.ReplyKnowledgeID
run.ReplyDecision = result.ReplyDecision
run.ReplyProposed = result.Reply
run.KnowledgeID = result.KnowledgeID
if result.KnowledgeThreshold > 0 {
run.KnowledgeThreshold = result.KnowledgeThreshold
}
run.KnowledgeEvidenceScore = result.KnowledgeEvidenceScore
run.KnowledgeRetrievalFloor = result.KnowledgeRetrievalFloor
run.KnowledgeCategoryAligned = result.KnowledgeCategoryAligned
run.PolicyReason = result.CategoryDecision + "; " + result.ReplyDecision
if !canReply && result.Reply {
run.ReplyProposed = false
run.ReplyDecision = "reply_existing_followup"
run.PolicyReason = result.CategoryDecision + "; " + run.ReplyDecision
}
// Re-read the ticket immediately before any write. This prevents a stale
// model decision from overwriting a human change made during inference.
if (result.ChangeCategory || (result.Reply && canReply)) && !s.cfg.DryRun {
fresh, err := s.glpi.GetTicket(ctx, id)
if err != nil {
run.Reason = "prewrite_ticket_recheck_failed"
finish(err)
return err
}
if sourceVersion(fresh) != run.SourceVersion {
run.Outcome = "skipped"
run.Reason = "ticket_changed_before_write"
if result.ChangeCategory {
run.CategoryDecision = "category_ticket_changed_before_write"
}
if result.Reply && canReply {
run.ReplyDecision = "reply_ticket_changed_before_write"
}
run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
s.metrics.Skipped.Add(1)
finish(nil)
return nil
}
}
if result.ChangeCategory && !s.cfg.DryRun {
if err := s.glpi.SetCategory(ctx, id, result.CategoryID); err != nil {
run.Reason = "category_write_failed"
run.CategoryDecision = "category_write_failed"
run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
finish(err)
return err
}
run.CategoryChanged = true
run.CategoryDecision = "category_written"
s.metrics.CategoryChanged.Add(1)
} else if result.ChangeCategory {
run.CategoryDecision = "category_accepted_dry_run"
}
run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
if result.Reply && canReply {
// If category was just changed by this process, date_mod will legitimately
// differ. Compare the decision-relevant ticket fields instead and require
// the category we expect before posting a reply.
if !s.cfg.DryRun {
fresh, err := s.glpi.GetTicket(ctx, id)
if err != nil {
run.Reason = "prereply_ticket_recheck_failed"
finish(err)
return err
}
expectedCategory := t.CategoryID
if result.ChangeCategory {
expectedCategory = result.CategoryID
}
if !sameDecisionSource(t, fresh, expectedCategory) {
run.ReplyProposed = false
run.Reason = "ticket_changed_before_reply"
run.ReplyDecision = "reply_ticket_changed_before_write"
run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
run.Outcome = "skipped"
s.metrics.Skipped.Add(1)
finish(nil)
return nil
}
}
followups, err = s.glpi.GetFollowups(ctx, id)
if err != nil {
run.Reason = "followup_recheck_failed"
finish(err)
return err
}
if len(followups) > 0 {
run.ReplyProposed = false
run.Reason = "followup_appeared_before_write"
run.ReplyDecision = "reply_followup_appeared_before_write"
} else if !s.cfg.DryRun {
if err := s.glpi.AddFollowup(ctx, id, result.ReplyText); err != nil {
run.Reason = "reply_write_failed"
run.ReplyDecision = "reply_write_failed"
run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
finish(err)
return err
}
run.ReplyWritten = true
run.ReplyDecision = "reply_written"
s.metrics.Replies.Add(1)
} else {
run.ReplyDecision = "reply_accepted_dry_run"
}
run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
}
// Persist the final GLPI version after our own write so the next poll does
// not immediately process the same self-induced modification again.
if !s.cfg.DryRun && (run.CategoryChanged || run.ReplyWritten) {
if finalTicket, e := s.glpi.GetTicket(ctx, id); e == nil {
run.SourceVersion = sourceVersion(finalTicket)
}
}
run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision
run.Outcome = "processed"
s.metrics.Processed.Add(1)
finish(nil)
return nil
}
func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limit int) []model.KnowledgeCandidateAudit {
if limit <= 0 || limit > len(hits) {
limit = len(hits)
}
out := make([]model.KnowledgeCandidateAudit, 0, limit)
for _, h := range hits[:limit] {
required := globalMin
if h.Doc.MinScore > required {
required = h.Doc.MinScore
}
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,
})
}
return out
}
func auditContextDetails(c model.ContextSnapshot, limit int) []model.ContextAuditItem {
if limit <= 0 {
limit = 5
}
out := make([]model.ContextAuditItem, 0, limit*4)
for i, x := range c.Changes {
if i >= limit {
break
}
out = append(out, model.ContextAuditItem{Kind: "change", ID: x.ID, Name: x.Name, Relevance: x.Relevance, Detail: strings.TrimSpace(x.PlannedBegin + " – " + x.PlannedEnd)})
}
for i, x := range c.MajorIncidents {
if i >= limit {
break
}
out = append(out, model.ContextAuditItem{Kind: "incident", ID: x.ID, Name: x.Name, Relevance: x.Relevance, Status: fmt.Sprint(x.StatusID), Detail: auditExcerpt(x.Content, 320)})
}
for i, x := range c.ServiceIssues {
if i >= limit {
break
}
name := x.MonitorName
if name == "" {
name = x.IncidentTitle
}
out = append(out, model.ContextAuditItem{Kind: "uptime", ID: x.MonitorID, Name: name, Relevance: x.Relevance, Status: x.Status, Detail: auditExcerpt(x.Message, 320)})
}
for i, x := range c.UserDevices {
if i >= limit {
break
}
name := x.Name
if name == "" {
name = fmt.Sprintf("%s #%d", x.ItemType, x.ID)
}
parts := make([]string, 0, 3)
for _, v := range []string{x.Serial, x.InventoryNumber, x.Location} {
if strings.TrimSpace(v) != "" {
parts = append(parts, strings.TrimSpace(v))
}
}
detail := strings.Join(parts, " · ")
out = append(out, model.ContextAuditItem{Kind: "device", ID: x.ID, Name: name, Status: x.Status, Detail: detail})
}
return out
}
func auditExcerpt(v string, max int) string {
v = strings.Join(strings.Fields(v), " ")
if max <= 0 || len(v) <= max {
return v
}
return strings.TrimSpace(v[:max]) + "…"
}
func (s *Service) getCategories(ctx context.Context) ([]model.Category, error) {
s.catMu.RLock()
if len(s.categories) > 0 && time.Since(s.catAt) < 10*time.Minute {
out := append([]model.Category(nil), s.categories...)
s.catMu.RUnlock()
return s.enrichCategories(out), nil
}
s.catMu.RUnlock()
cats, err := s.glpi.GetCategories(ctx)
if err != nil {
return nil, err
}
s.catMu.Lock()
s.categories = append([]model.Category(nil), cats...)
s.catAt = time.Now()
s.catMu.Unlock()
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"
}
for _, c := range categories {
if c.ID == id {
return categoryDisplayName(c)
}
}
return ""
}
func (s *Service) statusAllowed(id int64) bool {
for _, allowed := range s.cfg.GLPIAllowedStatusIDs {
if id == allowed {
return true
}
}
return false
}
func sourceVersion(t model.Ticket) string {
// Do not rely on date_mod alone: two changes can happen within the same
// timestamp resolution and some API projections may omit it. Requesters and
// linked items are decision-relevant because they feed the context collector.
payload := fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%d\x00%d\x00%v\x00%v", t.ID, t.DateMod, t.Name, t.Content, t.StatusID, t.CategoryID, t.RequesterIDs, t.Items)
h := sha256.Sum256([]byte(payload))
return hex.EncodeToString(h[:])
}
func sameDecisionSource(original, fresh model.Ticket, expectedCategory int64) bool {
if fresh.Name != original.Name || fresh.Content != original.Content || fresh.StatusID != original.StatusID || fresh.CategoryID != expectedCategory {
return false
}
if fmt.Sprint(fresh.RequesterIDs) != fmt.Sprint(original.RequesterIDs) || fmt.Sprint(fresh.Items) != fmt.Sprint(original.Items) {
return false
}
return true
}
func newRunID() string { b := make([]byte, 8); _, _ = rand.Read(b); return hex.EncodeToString(b) }
func stripHTML(s string) string {
r := strings.NewReplacer("<br>", "\n", "<br/>", "\n", "<br />", "\n", "</p>", "\n")
s = r.Replace(s)
var b strings.Builder
inside := false
for _, ch := range s {
if ch == '<' {
inside = true
continue
}
if ch == '>' {
inside = false
continue
}
if !inside {
b.WriteRune(ch)
}
}
return strings.TrimSpace(b.String())
}
func shortlistCategories(t model.Ticket, cats []model.Category, limit int) []model.Category {
if limit <= 0 || len(cats) <= limit {
return cats
}
q := strings.Fields(strings.ToLower(t.Name + " " + stripHTML(t.Content)))
type scored struct {
c model.Category
s int
}
ss := make([]scored, 0, len(cats))
for _, c := range cats {
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) {
score++
}
}
if c.ID == t.CategoryID {
score += 100
}
ss = append(ss, scored{c, score})
}
sort.SliceStable(ss, func(i, j int) bool { return ss[i].s > ss[j].s })
out := make([]model.Category, 0, limit)
for i := 0; i < limit && i < len(ss); i++ {
out = append(out, ss[i].c)
}
return out
}