All checks were successful
release-tag / release-image (push) Successful in 1m37s
589 lines
19 KiB
Go
589 lines
19 KiB
Go
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.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)
|
|
if err != nil {
|
|
run.Reason = "knowledge_search_failed"
|
|
finish(err)
|
|
return err
|
|
}
|
|
if len(hits) > 0 {
|
|
run.KnowledgeTopID = hits[0].Doc.ID
|
|
run.KnowledgeTopTitle = hits[0].Doc.Title
|
|
run.KnowledgeScore = hits[0].Score
|
|
}
|
|
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...)
|
|
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
|
|
}
|
|
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
|
|
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 (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
|
|
}
|