290 lines
9.8 KiB
Go
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
|
|
}
|