Files
jbergner 9b3227348d
All checks were successful
release-tag / release-image (push) Successful in 1m33s
init
2026-07-27 17:32:35 +02:00

283 lines
7.9 KiB
Go

package contextdata
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"unicode"
"github.com/example/glpi-ai-agent/internal/config"
"github.com/example/glpi-ai-agent/internal/model"
)
type GLPIReader interface {
ListChanges(context.Context, string, int, string) ([]model.ChangeContext, error)
ListMajorIncidents(context.Context, int, string) ([]model.MajorIncidentContext, error)
ListUserDevices(context.Context, int64, []string, string, int) ([]model.UserDeviceContext, error)
}
type UptimeKumaReader interface {
FetchIssues(context.Context, []string, bool, int) ([]model.ServiceIssueContext, error)
}
type Collector struct {
cfg config.Config
glpi GLPIReader
kuma UptimeKumaReader
now func() time.Time
}
func New(cfg config.Config, glpi GLPIReader, kuma UptimeKumaReader) *Collector {
return &Collector{cfg: cfg, glpi: glpi, kuma: kuma, now: time.Now}
}
func (c *Collector) Collect(parent context.Context, ticket model.Ticket) model.ContextSnapshot {
snapshot := model.ContextSnapshot{FetchedAt: c.now()}
if !c.cfg.ContextEnabled {
return snapshot
}
ctx, cancel := context.WithTimeout(parent, c.cfg.ContextTimeout)
defer cancel()
// Device context is collected first because device names improve relevance
// matching for changes and incidents.
if c.cfg.UserDeviceContextEnabled {
devices, err := c.collectDevices(ctx, ticket)
if err != nil {
snapshot.Warnings = append(snapshot.Warnings, "user_devices: "+err.Error())
snapshot.Incomplete = true
} else {
snapshot.UserDevices = devices
}
}
query := ticket.Name + "\n" + ticket.Content
for _, d := range snapshot.UserDevices {
query += "\n" + d.Name + " " + d.ItemType + " " + d.Location
}
var mu sync.Mutex
var wg sync.WaitGroup
addWarning := func(prefix string, err error) {
mu.Lock()
snapshot.Warnings = append(snapshot.Warnings, prefix+": "+err.Error())
snapshot.Incomplete = true
mu.Unlock()
}
if c.cfg.ChangeCalendarEnabled {
wg.Add(1)
go func() {
defer wg.Done()
changes, err := c.glpi.ListChanges(ctx, c.cfg.GLPIChangePath, c.cfg.GLPIChangeLimit, c.cfg.GLPIChangeFilter)
if err != nil {
addWarning("change_calendar", err)
return
}
changes = c.filterChanges(query, changes)
mu.Lock()
snapshot.Changes = changes
mu.Unlock()
}()
}
if c.cfg.MajorIncidentsEnabled {
wg.Add(1)
go func() {
defer wg.Done()
incidents, err := c.glpi.ListMajorIncidents(ctx, c.cfg.GLPIMajorIncidentLimit, c.cfg.GLPIMajorIncidentFilter)
if err != nil {
addWarning("major_incidents", err)
return
}
for i := range incidents {
incidents[i].Relevance = relevance(query, incidents[i].Name+" "+incidents[i].Content)
}
sort.SliceStable(incidents, func(i, j int) bool { return incidents[i].Relevance > incidents[j].Relevance })
mu.Lock()
snapshot.MajorIncidents = trimIncidents(incidents, 10)
mu.Unlock()
}()
}
if c.cfg.UptimeKumaEnabled && c.kuma != nil {
wg.Add(1)
go func() {
defer wg.Done()
issues, err := c.kuma.FetchIssues(ctx, c.cfg.UptimeKumaStatusPages, c.cfg.UptimeKumaIncludeMaintenance, c.cfg.UptimeKumaMaxIssues)
if err != nil {
addWarning("uptime_kuma", err)
return
}
for i := range issues {
candidate := issues[i].MonitorName + " " + issues[i].Message + " " + issues[i].IncidentTitle + " " + issues[i].IncidentContent + " " + issues[i].StatusPage
issues[i].Relevance = relevance(query, candidate)
}
sort.SliceStable(issues, func(i, j int) bool { return issues[i].Relevance > issues[j].Relevance })
mu.Lock()
snapshot.ServiceIssues = issues
mu.Unlock()
}()
}
wg.Wait()
if err := ctx.Err(); err != nil && parent.Err() == nil {
snapshot.Incomplete = true
if !containsPrefix(snapshot.Warnings, "context_timeout:") {
snapshot.Warnings = append(snapshot.Warnings, "context_timeout: "+err.Error())
}
}
return snapshot
}
func (c *Collector) collectDevices(ctx context.Context, ticket model.Ticket) ([]model.UserDeviceContext, error) {
seen := map[string]struct{}{}
out := make([]model.UserDeviceContext, 0, len(ticket.Items)+4)
for _, item := range ticket.Items {
if item.ID <= 0 || strings.TrimSpace(item.ItemType) == "" {
continue
}
key := strings.ToLower(item.ItemType) + ":" + fmt.Sprint(item.ID)
seen[key] = struct{}{}
out = append(out, model.UserDeviceContext{ItemType: item.ItemType, ID: item.ID, Name: item.Name, Source: "glpi-ticket-link"})
}
for _, userID := range ticket.RequesterIDs {
devices, err := c.glpi.ListUserDevices(ctx, userID, c.cfg.GLPIUserDevicePaths, c.cfg.GLPIUserDeviceFilterTemplate, c.cfg.GLPIUserDeviceLimit)
if err != nil {
return out, err
}
for _, d := range devices {
key := strings.ToLower(d.ItemType) + ":" + fmt.Sprint(d.ID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, d)
if len(out) >= c.cfg.GLPIUserDeviceLimit {
return out, nil
}
}
}
return out, nil
}
func (c *Collector) filterChanges(query string, changes []model.ChangeContext) []model.ChangeContext {
now := c.now()
from := now.Add(-c.cfg.ChangeLookback)
to := now.Add(c.cfg.ChangeLookahead)
out := make([]model.ChangeContext, 0, len(changes))
for _, ch := range changes {
if !changeOverlaps(ch, from, to) {
continue
}
ch.Relevance = relevance(query, ch.Name+" "+ch.Content)
out = append(out, ch)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Relevance > out[j].Relevance })
if len(out) > 10 {
out = out[:10]
}
return out
}
func changeOverlaps(ch model.ChangeContext, from, to time.Time) bool {
begin, bok := parseGLPITime(ch.PlannedBegin)
end, eok := parseGLPITime(ch.PlannedEnd)
if bok || eok {
if !bok {
begin = end
}
if !eok {
end = begin
}
return !end.Before(from) && !begin.After(to)
}
if mod, ok := parseGLPITime(ch.DateMod); ok {
return !mod.Before(from) && !mod.After(to)
}
// If the installed schema omits all date fields, keep the record. The
// relevance score still limits its usefulness to the model.
return true
}
func parseGLPITime(v string) (time.Time, bool) {
v = strings.TrimSpace(v)
if v == "" {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, v); err == nil {
return t, true
}
}
return time.Time{}, false
}
func relevance(a, b string) float64 {
aTokens := tokens(a)
bTokens := tokens(b)
if len(aTokens) == 0 || len(bTokens) == 0 {
return 0
}
matches := 0
for tok := range bTokens {
if _, ok := aTokens[tok]; ok {
matches++
}
}
den := len(bTokens)
if len(aTokens) < den {
den = len(aTokens)
}
if den == 0 {
return 0
}
score := float64(matches) / float64(den)
al, bl := strings.ToLower(a), strings.ToLower(b)
if len(strings.TrimSpace(bl)) >= 4 && strings.Contains(al, strings.TrimSpace(bl)) {
score += 0.25
}
if score > 1 {
return 1
}
return score
}
var stop = map[string]struct{}{
"der": {}, "die": {}, "das": {}, "den": {}, "dem": {}, "des": {}, "ein": {}, "eine": {}, "einer": {}, "und": {}, "oder": {}, "ist": {}, "sind": {}, "nicht": {}, "mit": {}, "von": {}, "für": {}, "fuer": {}, "auf": {}, "im": {}, "in": {}, "am": {}, "an": {}, "zu": {}, "zur": {}, "zum": {}, "seit": {}, "heute": {}, "aktuell": {}, "stoerung": {}, "störung": {}, "problem": {}, "fehler": {}, "service": {}, "ticket": {},
}
func tokens(s string) map[string]struct{} {
parts := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' })
out := map[string]struct{}{}
for _, p := range parts {
p = strings.Trim(p, "-_ ")
if len([]rune(p)) < 3 {
continue
}
if _, skip := stop[p]; skip {
continue
}
out[p] = struct{}{}
}
return out
}
func trimIncidents(v []model.MajorIncidentContext, n int) []model.MajorIncidentContext {
if len(v) > n {
return v[:n]
}
return v
}
func containsPrefix(v []string, prefix string) bool {
for _, s := range v {
if strings.HasPrefix(s, prefix) {
return true
}
}
return false
}