Files
glpi-ai-agent/internal/agent/policy.go
T
jbergner a01b53097a
release-tag / release-image (push) Successful in 1m41s
RC-1
2026-07-28 20:02:03 +02:00

290 lines
9.8 KiB
Go

package agent
import (
"strings"
"github.com/example/glpi-ai-agent/internal/model"
)
type Policy struct {
AutoCategory, AutoReply bool
CategoryConfidence, ReplyConfidence, KnowledgeMinScore float64
KnowledgeRetrievalFloor float64
KnowledgeEvidenceRetrievalWeight, KnowledgeEvidenceAIWeight, KnowledgeEvidenceCategoryWeight 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, knowledgeRetrievalFloor, evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight float64, allowedSources, autoReplySources []string, language, style, salutation, closing, signature string, blockReplyOnContextError, blockReplyOnIncident bool, contextRelevanceMinScore float64) Policy {
if evidenceRetrievalWeight+evidenceAIWeight+evidenceCategoryWeight <= 0 {
evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight = .45, .35, .20
}
return Policy{
AutoCategory: autoCategory,
AutoReply: autoReply,
CategoryConfidence: categoryConfidence,
ReplyConfidence: replyConfidence,
KnowledgeMinScore: knowledgeMinScore,
KnowledgeRetrievalFloor: knowledgeRetrievalFloor,
KnowledgeEvidenceRetrievalWeight: evidenceRetrievalWeight,
KnowledgeEvidenceAIWeight: evidenceAIWeight,
KnowledgeEvidenceCategoryWeight: evidenceCategoryWeight,
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
}
res.KnowledgeThreshold = threshold
res.KnowledgeRetrievalScore = hit.Score
res.KnowledgeRetrievalFloor = p.KnowledgeRetrievalFloor
if !hit.Doc.AutoReply {
res.ReplyDecision = "reply_knowledge_auto_reply_disabled"
return res, nil
}
if hit.Score < p.KnowledgeRetrievalFloor {
res.ReplyDecision = "reply_knowledge_retrieval_below_floor"
return res, nil
}
catIDForEvidence := t.CategoryID
if res.ChangeCategory {
catIDForEvidence = res.CategoryID
} else if d.Category.ID != 0 && d.Category.ID == t.CategoryID {
catIDForEvidence = d.Category.ID
}
categoryEvidence, categoryAvailable := 0.0, false
if len(hit.Doc.Categories) > 0 && catIDForEvidence != 0 {
categoryAvailable = true
for _, id := range hit.Doc.Categories {
if id == catIDForEvidence {
categoryEvidence = 1
res.KnowledgeCategoryAligned = true
break
}
}
}
res.KnowledgeEvidenceScore = evidenceScore(
hit.Score, d.Reply.Confidence, categoryEvidence, categoryAvailable,
p.KnowledgeEvidenceRetrievalWeight, p.KnowledgeEvidenceAIWeight, p.KnowledgeEvidenceCategoryWeight,
)
if res.KnowledgeEvidenceScore < threshold {
res.ReplyDecision = "reply_knowledge_evidence_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
}
func evidenceScore(retrieval, ai, category float64, categoryAvailable bool, retrievalWeight, aiWeight, categoryWeight float64) float64 {
sum, weights := 0.0, 0.0
if retrievalWeight > 0 {
sum += clampPolicy01(retrieval) * retrievalWeight
weights += retrievalWeight
}
if aiWeight > 0 {
sum += clampPolicy01(ai) * aiWeight
weights += aiWeight
}
if categoryAvailable && categoryWeight > 0 {
sum += clampPolicy01(category) * categoryWeight
weights += categoryWeight
}
if weights == 0 {
return 0
}
return clampPolicy01(sum / weights)
}
func clampPolicy01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}