Files
ai-agent/internal/ai/application/runtime/evaluation.go
T
t 2bbf42b741 refactor(auth): delegate access control to be-system
Remove Agent Desk users, roles, login sessions, tokens, and local permission persistence. Expose the backend as an embeddable ai-agent module with host-provided subject lookup and operation authorization callbacks, and complete the frontend/backend repository split.
2026-08-21 00:41:07 +08:00

146 lines
4.8 KiB
Go

package runtime
import (
"context"
"encoding/csv"
"strconv"
"strings"
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/toolx"
)
// OfflineEvaluationCase is an isolated customer-service evaluation sample.
// Expectations are intentionally declarative so the same baseline can evolve
// without changing the runner's request contract.
type OfflineEvaluationCase struct {
ID string `json:"id"`
Category string `json:"category"`
Message string `json:"message"`
History []string `json:"history,omitempty"`
Expect map[string]any `json:"expect,omitempty"`
}
type OfflineEvaluationResult struct {
CaseID string `json:"caseId"`
Category string `json:"category"`
Passed bool `json:"passed"`
ReplyText string `json:"replyText"`
Interrupted bool `json:"interrupted"`
Error string `json:"error,omitempty"`
Finding string `json:"finding,omitempty"`
}
type OfflineEvaluationReport struct {
Total int `json:"total"`
Passed int `json:"passed"`
Results []OfflineEvaluationResult `json:"results"`
}
// OfflineEvaluationRunner executes only isolated Debug requests. The supplied
// runner makes it testable without a real model and lets callers choose an
// explicit Engine implementation for mode comparison.
type OfflineEvaluationRunner struct {
run func(context.Context, RunInput) (*RunResult, error)
}
func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult, error)) *OfflineEvaluationRunner {
return &OfflineEvaluationRunner{run: run}
}
func (r *OfflineEvaluationRunner) Run(ctx context.Context, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
report := OfflineEvaluationReport{Results: make([]OfflineEvaluationResult, 0, len(cases))}
for _, item := range cases {
result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category)}
if r == nil || r.run == nil {
result.Error, result.Finding = "evaluation runner is not configured", "runner_missing"
report.Results = append(report.Results, result)
continue
}
summary, err := r.run(ctx, RunInput{
Conversation: models.Conversation{AIAgentID: agent.ID, LastMessageSummary: strings.Join(item.History, "\n")},
UserMessage: models.Message{SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: strings.TrimSpace(item.Message), RequestID: "offline-eval:" + strings.TrimSpace(item.ID)},
AIAgent: agent,
AIConfig: config,
Debug: true,
})
if err != nil {
result.Error, result.Finding = err.Error(), "engine_error"
report.Results = append(report.Results, result)
continue
}
if summary != nil {
result.ReplyText = strings.TrimSpace(summary.ReplyText)
result.Interrupted = summary.Interrupted
}
result.Passed, result.Finding = evaluateOfflineCase(item.Expect, summary)
if result.Passed {
report.Passed++
}
report.Results = append(report.Results, result)
}
report.Total = len(report.Results)
return report
}
func (r OfflineEvaluationReport) CSV() (string, error) {
var output strings.Builder
writer := csv.NewWriter(&output)
if err := writer.Write([]string{"caseId", "category", "passed", "interrupted", "finding", "error", "replyText"}); err != nil {
return "", err
}
for _, item := range r.Results {
if err := writer.Write([]string{item.CaseID, item.Category, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil {
return "", err
}
}
writer.Flush()
return output.String(), writer.Error()
}
func evaluateOfflineCase(expect map[string]any, summary *RunResult) (bool, string) {
if summary == nil || strings.TrimSpace(summary.ReplyText) == "" {
return false, "empty_reply"
}
if requiresConfirmation, _ := expect["requiresConfirmation"].(bool); requiresConfirmation && !summary.Interrupted {
return false, "confirmation_not_reached"
}
if maxWrites, ok := evaluationExpectationInt(expect["maxWriteToolCalls"]); ok {
if maxWrites < 0 {
return false, "invalid_expectation"
}
if writeToolCalls(summary) > maxWrites {
return false, "write_tool_limit_exceeded"
}
}
return true, ""
}
func evaluationExpectationInt(value any) (int, bool) {
switch item := value.(type) {
case int:
return item, true
case int64:
return int(item), true
case float64:
return int(item), item == float64(int(item))
default:
return 0, false
}
}
func writeToolCalls(summary *RunResult) int {
if summary == nil {
return 0
}
count := 0
for _, code := range summary.InvokedToolCodes {
switch toolx.NormalizeToolCodeAlias(code) {
case toolx.GraphCreateTicketConfirm.Code, toolx.GraphHandoffConversation.Code:
count++
}
}
return count
}