@@ -0,0 +1,282 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package contextdata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/example/glpi-ai-agent/internal/config"
|
||||
"github.com/example/glpi-ai-agent/internal/model"
|
||||
)
|
||||
|
||||
type fakeGLPI struct {
|
||||
changes []model.ChangeContext
|
||||
inc []model.MajorIncidentContext
|
||||
devices []model.UserDeviceContext
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeGLPI) ListChanges(context.Context, string, int, string) ([]model.ChangeContext, error) {
|
||||
return f.changes, f.err
|
||||
}
|
||||
func (f fakeGLPI) ListMajorIncidents(context.Context, int, string) ([]model.MajorIncidentContext, error) {
|
||||
return f.inc, f.err
|
||||
}
|
||||
func (f fakeGLPI) ListUserDevices(context.Context, int64, []string, string, int) ([]model.UserDeviceContext, error) {
|
||||
return f.devices, f.err
|
||||
}
|
||||
|
||||
type fakeKuma struct {
|
||||
issues []model.ServiceIssueContext
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeKuma) FetchIssues(context.Context, []string, bool, int) ([]model.ServiceIssueContext, error) {
|
||||
return f.issues, f.err
|
||||
}
|
||||
|
||||
func baseCfg() config.Config {
|
||||
return config.Config{ContextEnabled: true, ContextTimeout: time.Second, ChangeCalendarEnabled: true, GLPIChangePath: "/Assistance/Change", GLPIChangeLimit: 100, ChangeLookback: 48 * time.Hour, ChangeLookahead: 24 * time.Hour, MajorIncidentsEnabled: true, GLPIMajorIncidentFilter: "x", GLPIMajorIncidentLimit: 10, UserDeviceContextEnabled: true, GLPIUserDevicePaths: []string{"/Assets/Computer"}, GLPIUserDeviceFilterTemplate: "user.id=={{user_id}}", GLPIUserDeviceLimit: 10, UptimeKumaEnabled: true, UptimeKumaStatusPages: []string{"it"}, UptimeKumaMaxIssues: 10}
|
||||
}
|
||||
|
||||
func TestCollectScoresRelevantIncident(t *testing.T) {
|
||||
cfg := baseCfg()
|
||||
g := fakeGLPI{inc: []model.MajorIncidentContext{{ID: 1, Name: "VPN Gateway Ausfall"}}, devices: []model.UserDeviceContext{{ID: 2, ItemType: "Computer", Name: "NB-1"}}}
|
||||
k := fakeKuma{issues: []model.ServiceIssueContext{{Kind: "monitor", MonitorID: 7, MonitorName: "VPN Gateway", Status: "down"}}}
|
||||
c := New(cfg, g, k)
|
||||
c.now = func() time.Time { return time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) }
|
||||
s := c.Collect(context.Background(), model.Ticket{ID: 1, Name: "VPN funktioniert nicht", RequesterIDs: []int64{5}})
|
||||
if len(s.MajorIncidents) != 1 || s.MajorIncidents[0].Relevance <= 0 {
|
||||
t.Fatalf("incidents=%+v", s.MajorIncidents)
|
||||
}
|
||||
if len(s.ServiceIssues) != 1 || s.ServiceIssues[0].Relevance <= 0 {
|
||||
t.Fatalf("issues=%+v", s.ServiceIssues)
|
||||
}
|
||||
if len(s.UserDevices) != 1 {
|
||||
t.Fatalf("devices=%+v", s.UserDevices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFailureMarksSnapshotIncomplete(t *testing.T) {
|
||||
cfg := baseCfg()
|
||||
cfg.ChangeCalendarEnabled = false
|
||||
cfg.MajorIncidentsEnabled = false
|
||||
cfg.UserDeviceContextEnabled = false
|
||||
c := New(cfg, fakeGLPI{}, fakeKuma{err: errors.New("boom")})
|
||||
s := c.Collect(context.Background(), model.Ticket{ID: 1})
|
||||
if !s.Incomplete || len(s.Warnings) == 0 {
|
||||
t.Fatalf("snapshot=%+v", s)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user