240 lines
8.5 KiB
Go
240 lines
8.5 KiB
Go
package prioritysignals
|
|
|
|
import (
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/example/glpi-ai-agent/internal/model"
|
|
)
|
|
|
|
// Signal is deterministic evidence extracted from the user-provided ticket.
|
|
// It does not decide the priority. It only makes explicit statements available
|
|
// to the dedicated AI run and to consistency checks around its output.
|
|
type Signal struct {
|
|
Code string `json:"code"`
|
|
Source string `json:"source"`
|
|
Excerpt string `json:"excerpt"`
|
|
}
|
|
|
|
// Evidence contains only signals that are stated explicitly enough to be used
|
|
// as a consistency guard. Absence of a signal is never interpreted as proof of
|
|
// the opposite condition.
|
|
type Evidence struct {
|
|
Signals []Signal `json:"signals,omitempty"`
|
|
}
|
|
|
|
type rule struct {
|
|
code string
|
|
re *regexp.Regexp
|
|
}
|
|
|
|
var rules = []rule{
|
|
{code: "multiple_users_affected", re: regexp.MustCompile(`(?i)\b(?:meine|unsere)\s+(?:kolleg(?:e|en|innen)|mitarbeiter(?:innen)?|teammitglieder)\s+und\s+ich\b`)},
|
|
{code: "multiple_users_affected", re: regexp.MustCompile(`(?i)\b(?:kolleg(?:e|en|innen)|mitarbeiter(?:innen)?|benutzer|nutzer|personen)\s+und\s+ich\b`)},
|
|
{code: "multiple_users_affected", re: regexp.MustCompile(`(?i)\b(?:mehrere|viele|alle|sämtliche)\s+(?:kolleg(?:en|innen)?|mitarbeiter(?:innen)?|benutzer|nutzer|personen|arbeitsplätze|rechner|pcs|geräte)\b`)},
|
|
{code: "multiple_users_affected", re: regexp.MustCompile(`(?i)\bwir\s+alle\b`)},
|
|
{code: "site_affected", re: regexp.MustCompile(`(?i)\b(?:gesamter|ganzer|kompletter)\s+(?:standort|gebäude|liegenschaft|campus)\b`)},
|
|
{code: "organization_affected", re: regexp.MustCompile(`(?i)\b(?:gesamte|ganze|komplette)\s+(?:organisation|verwaltung|behörde|firma|unternehmen)\b`)},
|
|
{code: "workaround_available", re: regexp.MustCompile(`(?i)\b(?:workaround|ausweichmöglichkeit|alternative(?:r|n)?\s+weg|ersatzlösung)\b`)},
|
|
{code: "workaround_available", re: regexp.MustCompile(`(?i)\b(?:andere|weitere|alternative)\s+(?:drucker|geräte|systeme|anwendungen?)\b.{0,60}\b(?:funktionier(?:t|en)|lauf(?:en|t)|geh(?:t|en))\b`)},
|
|
{code: "workaround_available", re: regexp.MustCompile(`(?i)\b(?:büro|arbeitsplatz)[- ]?drucker\b.{0,50}\b(?:funktionier(?:t|en)|lauf(?:en|t)|geh(?:t|en)|noch\s+verfügbar)\b`)},
|
|
{code: "no_workaround", re: regexp.MustCompile(`(?i)\b(?:kein(?:e|en)?\s+(?:workaround|alternative|ausweichmöglichkeit)|ohne\s+ausweichmöglichkeit|überhaupt\s+nicht\s+weiterarbeiten)\b`)},
|
|
{code: "business_deadline", re: regexp.MustCompile(`(?i)\b(?:frist|deadline|abgabetermin|termin)\b.{0,60}\b(?:heute|morgen|bis\s+\d{1,2}[.\/-]\d{1,2}|dringend|läuft\s+ab)\b`)},
|
|
{code: "security_incident_suspected", re: regexp.MustCompile(`(?i)\b(?:phishing|ransomware|schadsoftware|malware|konto\s+übernommen|unbefugter\s+zugriff|datenabfluss)\b`)},
|
|
{code: "data_loss_possible", re: regexp.MustCompile(`(?i)\b(?:datenverlust|daten\s+verloren|dateien\s+gelöscht|nicht\s+gespeichert|überschrieben)\b`)},
|
|
}
|
|
|
|
var tagRE = regexp.MustCompile(`(?s)<[^>]*>`)
|
|
var whitespaceRE = regexp.MustCompile(`\s+`)
|
|
|
|
// Extract returns deterministic evidence from subject and body. It intentionally
|
|
// uses conservative patterns: false negatives are acceptable; false positives
|
|
// must not become automatic priority changes.
|
|
func Extract(t model.Ticket) Evidence {
|
|
text := normalize(t.Name + "\n" + t.Content)
|
|
seen := map[string]struct{}{}
|
|
out := Evidence{}
|
|
for _, r := range rules {
|
|
loc := r.re.FindStringIndex(text)
|
|
if loc == nil {
|
|
continue
|
|
}
|
|
if _, ok := seen[r.code]; ok {
|
|
continue
|
|
}
|
|
seen[r.code] = struct{}{}
|
|
out.Signals = append(out.Signals, Signal{Code: r.code, Source: "ticket_text", Excerpt: excerpt(text, loc[0], loc[1], 90)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (e Evidence) Has(code string) bool {
|
|
code = strings.TrimSpace(strings.ToLower(code))
|
|
for _, s := range e.Signals {
|
|
if strings.EqualFold(strings.TrimSpace(s.Code), code) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e Evidence) Codes() []string {
|
|
out := make([]string, 0, len(e.Signals))
|
|
for _, s := range e.Signals {
|
|
if strings.TrimSpace(s.Code) != "" {
|
|
out = append(out, s.Code)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalize(v string) string {
|
|
v = strings.NewReplacer("<br>", "\n", "<br/>", "\n", "<br />", "\n", "</p>", "\n", "</li>", "\n").Replace(v)
|
|
v = tagRE.ReplaceAllString(v, " ")
|
|
v = html.UnescapeString(v)
|
|
v = whitespaceRE.ReplaceAllString(v, " ")
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
func excerpt(s string, start, end, radius int) string {
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if end > len(s) {
|
|
end = len(s)
|
|
}
|
|
left := start - radius
|
|
if left < 0 {
|
|
left = 0
|
|
}
|
|
right := end + radius
|
|
if right > len(s) {
|
|
right = len(s)
|
|
}
|
|
// Move to UTF-8 boundaries because regexp indices are byte offsets.
|
|
for left > 0 && !utf8.RuneStart(s[left]) {
|
|
left--
|
|
}
|
|
for right < len(s) && !utf8.RuneStart(s[right]) {
|
|
right++
|
|
}
|
|
v := strings.TrimSpace(s[left:right])
|
|
if left > 0 {
|
|
v = "…" + v
|
|
}
|
|
if right < len(s) {
|
|
v += "…"
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Reconcile makes a model result consistent with explicit ticket evidence
|
|
// without performing another AI request. It is deliberately conservative:
|
|
// it never raises confidence and never invents a priority increase.
|
|
func Reconcile(t model.Ticket, evidence Evidence, out model.PriorityDecision) model.PriorityDecision {
|
|
reasons := model.NormalizeReasonCodes(out.ReasonCodes)
|
|
if len(evidence.Signals) > 0 {
|
|
reasons = removeCode(reasons, "insufficient_information")
|
|
}
|
|
|
|
// Explicit, deterministic signals take precedence over contradictory model
|
|
// metadata. They describe facts, not the final priority decision.
|
|
for _, code := range []string{"organization_affected", "site_affected", "multiple_users_affected", "workaround_available", "no_workaround", "business_deadline", "security_incident_suspected", "data_loss_possible"} {
|
|
if evidence.Has(code) {
|
|
reasons = prependUnique(reasons, code)
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case evidence.Has("organization_affected"):
|
|
out.AffectedScope = "organization"
|
|
case evidence.Has("site_affected"):
|
|
if out.AffectedScope != "organization" {
|
|
out.AffectedScope = "site"
|
|
}
|
|
case evidence.Has("multiple_users_affected"):
|
|
if out.AffectedScope != "organization" && out.AffectedScope != "site" {
|
|
out.AffectedScope = "multiple_users"
|
|
}
|
|
}
|
|
|
|
if len(reasons) == 0 {
|
|
reasons = []string{"insufficient_information"}
|
|
out.RecommendedPriority = t.Priority
|
|
if strings.TrimSpace(out.AffectedScope) == "" {
|
|
out.AffectedScope = "unknown"
|
|
}
|
|
}
|
|
if model.HasReasonCode(reasons, "insufficient_information") {
|
|
reasons = []string{"insufficient_information"}
|
|
out.RecommendedPriority = t.Priority
|
|
}
|
|
if len(reasons) > 3 {
|
|
reasons = reasons[:3]
|
|
}
|
|
out.ReasonCodes = reasons
|
|
|
|
reason := strings.TrimSpace(out.Reason)
|
|
if reason == "" || isBareReasonCode(reason, reasons) || strings.EqualFold(reason, "insufficient_information") && len(evidence.Signals) > 0 {
|
|
out.Reason = evidenceExplanation(evidence)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func prependUnique(values []string, code string) []string {
|
|
code = strings.TrimSpace(strings.ToLower(code))
|
|
if code == "" {
|
|
return values
|
|
}
|
|
out := []string{code}
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(strings.ToLower(value))
|
|
if value != "" && value != code {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func removeCode(values []string, code string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if !strings.EqualFold(strings.TrimSpace(value), code) {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isBareReasonCode(reason string, codes []string) bool {
|
|
for _, code := range codes {
|
|
if strings.EqualFold(strings.TrimSpace(reason), strings.TrimSpace(code)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func evidenceExplanation(e Evidence) string {
|
|
switch {
|
|
case e.Has("multiple_users_affected") && e.Has("workaround_available"):
|
|
return "Mehrere Personen sind betroffen; zugleich ist laut Ticket eine funktionierende Ausweichmöglichkeit vorhanden."
|
|
case e.Has("multiple_users_affected") && e.Has("no_workaround"):
|
|
return "Mehrere Personen sind betroffen und laut Ticket besteht keine Ausweichmöglichkeit."
|
|
case e.Has("multiple_users_affected"):
|
|
return "Laut Ticket sind mehrere Personen betroffen."
|
|
case e.Has("site_affected"):
|
|
return "Laut Ticket ist ein ganzer Standort betroffen."
|
|
case e.Has("organization_affected"):
|
|
return "Laut Ticket ist die gesamte Organisation betroffen."
|
|
case e.Has("workaround_available"):
|
|
return "Laut Ticket ist eine funktionierende Ausweichmöglichkeit vorhanden."
|
|
case e.Has("no_workaround"):
|
|
return "Laut Ticket besteht keine Ausweichmöglichkeit."
|
|
default:
|
|
return "Die Ticketangaben reichen nicht für eine belastbare Höherstufung aus."
|
|
}
|
|
}
|