Files
glpi-ai-agent/internal/agent/policy.go
T
jbergner 1c12e3ed60
release-tag / release-image (push) Successful in 1m41s
Update
2026-07-27 21:27:42 +02:00

222 lines
7.1 KiB
Go

package agent
import (
"strings"
"github.com/example/glpi-ai-agent/internal/model"
)
type Policy struct {
AutoCategory, AutoReply bool
CategoryConfidence, ReplyConfidence, KnowledgeMinScore float64
AllowedSources, AutoReplySources map[string]struct{}
CommunicationLanguage, CommunicationStyle string
CommunicationSalutation, CommunicationClosing string
CommunicationSignature string
BlockReplyOnContextError, BlockReplyOnIncident bool
ContextRelevanceMinScore float64
}
func NewPolicy(autoCategory, autoReply bool, categoryConfidence, replyConfidence, knowledgeMinScore float64, allowedSources, autoReplySources []string, language, style, salutation, closing, signature string, blockReplyOnContextError, blockReplyOnIncident bool, contextRelevanceMinScore float64) Policy {
return Policy{
AutoCategory: autoCategory,
AutoReply: autoReply,
CategoryConfidence: categoryConfidence,
ReplyConfidence: replyConfidence,
KnowledgeMinScore: knowledgeMinScore,
AllowedSources: sourceSet(allowedSources),
AutoReplySources: sourceSet(autoReplySources),
CommunicationLanguage: strings.TrimSpace(language),
CommunicationStyle: strings.ToLower(strings.TrimSpace(style)),
CommunicationSalutation: strings.TrimSpace(salutation),
CommunicationClosing: strings.TrimSpace(closing),
CommunicationSignature: strings.TrimSpace(signature),
BlockReplyOnContextError: blockReplyOnContextError,
BlockReplyOnIncident: blockReplyOnIncident,
ContextRelevanceMinScore: contextRelevanceMinScore,
}
}
func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Category, hits []model.KnowledgeHit, contextData model.ContextSnapshot) (model.PolicyResult, error) {
res := model.PolicyResult{
CategoryRecommendationID: d.Category.ID,
CategoryConfidence: d.Category.Confidence,
CategoryThreshold: p.CategoryConfidence,
ReplyRecommendation: d.Reply.Allowed,
ReplyConfidence: d.Reply.Confidence,
ReplyThreshold: p.ReplyConfidence,
ReplyKnowledgeID: strings.TrimSpace(d.Reply.KnowledgeID),
AIReason: strings.TrimSpace(d.Reason),
}
known := make(map[int64]model.Category, len(categories))
for _, c := range categories {
known[c.ID] = c
}
if c, ok := known[d.Category.ID]; ok {
res.CategoryRecommendationName = categoryDisplayName(c)
}
// The model only recommends a category and a confidence score. Whether a
// write is allowed is entirely deterministic and owned by this policy.
switch {
case !p.AutoCategory:
res.CategoryDecision = "category_auto_disabled"
case d.Category.ID == 0:
res.CategoryDecision = "category_no_recommendation"
case d.Category.ID == t.CategoryID:
res.CategoryDecision = "category_already_correct"
case known[d.Category.ID].ID == 0:
res.CategoryDecision = "category_unknown"
case d.Category.Confidence < p.CategoryConfidence:
res.CategoryDecision = "category_confidence_below_threshold"
default:
res.ChangeCategory = true
res.CategoryID = d.Category.ID
res.CategoryDecision = "category_accepted"
}
// Auto-reply uses the same explainable, fail-closed approach. The first
// failed gate becomes the audit reason shown in the dashboard.
switch {
case !p.AutoReply:
res.ReplyDecision = "reply_auto_disabled"
return res, nil
case len(hits) == 0:
res.ReplyDecision = "reply_no_knowledge_candidates"
return res, nil
case !d.Reply.Allowed:
res.ReplyDecision = "reply_model_not_recommended"
return res, nil
case d.Reply.Confidence < p.ReplyConfidence:
res.ReplyDecision = "reply_confidence_below_threshold"
return res, nil
case res.ReplyKnowledgeID == "":
res.ReplyDecision = "reply_no_knowledge_selected"
return res, nil
case p.BlockReplyOnContextError && contextData.Incomplete:
res.ReplyDecision = "reply_context_incomplete"
return res, nil
case p.BlockReplyOnIncident && contextData.HasRelevantIncident(p.ContextRelevanceMinScore):
res.ReplyDecision = "reply_relevant_incident"
return res, nil
}
var hit *model.KnowledgeHit
for i := range hits {
if hits[i].Doc.ID == res.ReplyKnowledgeID {
hit = &hits[i]
break
}
}
if hit == nil {
res.ReplyDecision = "reply_knowledge_not_found"
return res, nil
}
if !p.sourceAllowed(hit.Doc.Source) {
res.ReplyDecision = "reply_source_not_allowed"
return res, nil
}
if !p.sourceAllowedForReply(hit.Doc.Source) {
res.ReplyDecision = "reply_source_not_allowed_for_auto_reply"
return res, nil
}
if !strings.EqualFold(strings.TrimSpace(hit.Doc.Language), p.CommunicationLanguage) {
res.ReplyDecision = "reply_language_mismatch"
return res, nil
}
if !strings.EqualFold(strings.TrimSpace(hit.Doc.CommunicationStyle), p.CommunicationStyle) {
res.ReplyDecision = "reply_style_mismatch"
return res, nil
}
threshold := p.KnowledgeMinScore
if hit.Doc.MinScore > threshold {
threshold = hit.Doc.MinScore
}
if !hit.Doc.AutoReply {
res.ReplyDecision = "reply_knowledge_auto_reply_disabled"
return res, nil
}
if hit.Score < threshold {
res.ReplyDecision = "reply_knowledge_score_below_threshold"
return res, nil
}
if strings.TrimSpace(hit.Doc.Answer) == "" {
res.ReplyDecision = "reply_knowledge_answer_empty"
return res, nil
}
catID := t.CategoryID
if res.ChangeCategory {
catID = res.CategoryID
}
if len(hit.Doc.Categories) > 0 {
allowed := false
for _, id := range hit.Doc.Categories {
if id == catID {
allowed = true
break
}
}
if !allowed {
res.ReplyDecision = "reply_category_not_allowed"
return res, nil
}
}
res.Reply = true
res.ReplyText = p.formatReply(hit.Doc.Answer)
res.KnowledgeID = hit.Doc.ID
res.ReplyDecision = "reply_accepted"
return res, nil
}
func categoryDisplayName(c model.Category) string {
if strings.TrimSpace(c.CompleteName) != "" {
return strings.TrimSpace(c.CompleteName)
}
return strings.TrimSpace(c.Name)
}
func (p Policy) sourceAllowed(source string) bool {
_, ok := p.AllowedSources[strings.ToLower(strings.TrimSpace(source))]
return ok
}
func (p Policy) sourceAllowedForReply(source string) bool {
_, ok := p.AutoReplySources[strings.ToLower(strings.TrimSpace(source))]
return ok
}
func (p Policy) formatReply(body string) string {
parts := make([]string, 0, 4)
if p.CommunicationSalutation != "" {
parts = append(parts, p.CommunicationSalutation)
}
parts = append(parts, strings.TrimSpace(body))
footer := strings.TrimSpace(strings.Join(nonEmpty(p.CommunicationClosing, p.CommunicationSignature), "\n"))
if footer != "" {
parts = append(parts, footer)
}
return strings.Join(parts, "\n\n")
}
func sourceSet(values []string) map[string]struct{} {
out := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.ToLower(strings.TrimSpace(v))
if v != "" {
out[v] = struct{}{}
}
}
return out
}
func nonEmpty(values ...string) []string {
out := make([]string, 0, len(values))
for _, v := range values {
if strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
}
return out
}