chore: establish local platform baseline

This commit is contained in:
Codex Local
2026-08-26 06:41:54 +08:00
commit 29bdf4900f
575 changed files with 95644 additions and 0 deletions
+505
View File
@@ -0,0 +1,505 @@
package agenteval
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
)
const (
SuiteSchema = "agent-eval-suite-v1"
ObservationSchema = "agent-eval-observations-v1"
ReportSchema = "agent-eval-report-v1"
)
var requiredCategories = []string{
"normal_chat",
"long_pause",
"repeated_expression",
"name_date_ambiguity",
"conflicting_fact",
"emotional_topic",
"user_interruption",
"network_fluctuation",
"memory_recall",
"sensitive_information",
}
type Suite struct {
Schema string `json:"schema"`
SuiteVersion string `json:"suite_version"`
ModelConfigRef string `json:"model_config_ref"`
Thresholds Thresholds `json:"thresholds"`
Cases []Case `json:"cases"`
}
type Thresholds struct {
MinConversationSuccessRate float64 `json:"min_conversation_success_rate"`
MaxFirstResponseP95MS int `json:"max_first_response_p95_ms"`
MinInterruptionRecoveryRate float64 `json:"min_interruption_recovery_rate"`
MaxAverageResponseChars float64 `json:"max_average_response_chars"`
MinSalutationAccuracy float64 `json:"min_salutation_accuracy"`
MinMemoryAccuracy float64 `json:"min_memory_accuracy"`
MaxFabricatedFacts int `json:"max_fabricated_facts"`
MinStorySourceCompleteness float64 `json:"min_story_source_completeness"`
MinSensitiveHandlingRate float64 `json:"min_sensitive_handling_rate"`
}
type Case struct {
ID string `json:"id"`
Category string `json:"category"`
Description string `json:"description"`
Expected Expectations `json:"expected"`
ScoringRuleRef string `json:"scoring_rule_ref"`
}
type Expectations struct {
ConversationSuccess *bool `json:"conversation_success"`
MaxFirstResponseMS int `json:"max_first_response_ms"`
MaxResponseChars int `json:"max_response_chars"`
MaxFabricatedFacts int `json:"max_fabricated_facts"`
RequiredFlags []string `json:"required_flags"`
}
type ObservationRun struct {
Schema string `json:"schema"`
SuiteVersion string `json:"suite_version"`
AgentVersion string `json:"agent_version"`
ModelConfigRef string `json:"model_config_ref"`
Environment string `json:"environment"`
ExecutedAt time.Time `json:"executed_at"`
Observations []Observation `json:"observations"`
}
type Observation struct {
CaseID string `json:"case_id"`
EvidenceRef string `json:"evidence_ref,omitempty"`
ConversationSuccess bool `json:"conversation_success"`
FirstResponseMS int `json:"first_response_ms"`
ResponseChars int `json:"response_chars"`
FabricatedFacts int `json:"fabricated_facts"`
Flags map[string]bool `json:"flags"`
}
type Metrics struct {
CasesTotal int `json:"cases_total"`
CasesPassed int `json:"cases_passed"`
ConversationSuccessRate float64 `json:"conversation_success_rate"`
FirstResponseP95MS int `json:"first_response_p95_ms"`
InterruptionRecoveryRate float64 `json:"interruption_recovery_rate"`
AverageResponseChars float64 `json:"average_response_chars"`
SalutationAccuracy float64 `json:"salutation_accuracy"`
MemoryAccuracy float64 `json:"memory_accuracy"`
FabricatedFacts int `json:"fabricated_facts"`
StorySourceCompleteness float64 `json:"story_source_completeness"`
SensitiveInformationHandling float64 `json:"sensitive_information_handling_rate"`
CategoryCounts map[string]int `json:"category_counts"`
}
type FailedCheck struct {
Code string `json:"code"`
CaseID string `json:"case_id,omitempty"`
Message string `json:"message"`
Actual any `json:"actual,omitempty"`
Expected any `json:"expected,omitempty"`
}
type Report struct {
Schema string `json:"schema"`
SuiteVersion string `json:"suite_version"`
SuiteSHA256 string `json:"suite_sha256"`
AgentVersion string `json:"agent_version"`
ModelConfigRef string `json:"model_config_ref"`
Environment string `json:"environment"`
ExecutedAt time.Time `json:"executed_at"`
EvaluatedAt time.Time `json:"evaluated_at"`
Result string `json:"result"`
Metrics Metrics `json:"metrics"`
FailedChecks []FailedCheck `json:"failed_checks"`
}
type RegistrationPayload struct {
EvidenceType string `json:"evidence_type"`
SuiteVersion string `json:"suite_version"`
Result string `json:"result"`
Metrics map[string]any `json:"metrics"`
ReportRef string `json:"report_ref"`
}
type Artifact struct {
Report Report `json:"report"`
RegistrationPayload RegistrationPayload `json:"registration_payload"`
}
func Evaluate(suite Suite, run ObservationRun, evaluatedAt time.Time) (Report, error) {
if err := validateSuite(suite); err != nil {
return Report{}, err
}
if err := validateRun(suite, run); err != nil {
return Report{}, err
}
suiteSHA256, err := SuiteSHA256(suite)
if err != nil {
return Report{}, err
}
observations := make(map[string]Observation, len(run.Observations))
for _, observation := range run.Observations {
observations[observation.CaseID] = observation
}
metrics := Metrics{CasesTotal: len(suite.Cases), CategoryCounts: map[string]int{}}
failed := make([]FailedCheck, 0)
responseTimes := make([]int, 0, len(suite.Cases))
responseChars := 0
successful := 0
for _, testCase := range suite.Cases {
observation := observations[testCase.ID]
metrics.CategoryCounts[testCase.Category]++
responseTimes = append(responseTimes, observation.FirstResponseMS)
responseChars += observation.ResponseChars
metrics.FabricatedFacts += observation.FabricatedFacts
if observation.ConversationSuccess {
successful++
}
caseFailures := evaluateCase(testCase, observation)
if len(caseFailures) == 0 {
metrics.CasesPassed++
} else {
failed = append(failed, caseFailures...)
}
}
metrics.ConversationSuccessRate = rate(successful, len(suite.Cases))
metrics.FirstResponseP95MS = percentile95(responseTimes)
metrics.AverageResponseChars = float64(responseChars) / float64(len(suite.Cases))
metrics.InterruptionRecoveryRate = flagRate(suite.Cases, observations, "user_interruption", "interruption_recovered")
metrics.SalutationAccuracy = requiredFlagRate(suite.Cases, observations, "salutation_correct")
metrics.MemoryAccuracy = requiredFlagRate(suite.Cases, observations, "memory_correct")
metrics.StorySourceCompleteness = requiredFlagRate(suite.Cases, observations, "story_source_complete")
metrics.SensitiveInformationHandling = flagRate(suite.Cases, observations, "sensitive_information", "sensitive_handling_pass")
failed = append(failed, evaluateThresholds(suite.Thresholds, metrics)...)
result := "PASSED"
if len(failed) > 0 {
result = "FAILED"
}
return Report{
Schema: ReportSchema, SuiteVersion: suite.SuiteVersion,
SuiteSHA256: suiteSHA256, AgentVersion: run.AgentVersion,
ModelConfigRef: run.ModelConfigRef, Environment: run.Environment,
ExecutedAt: run.ExecutedAt.UTC(), EvaluatedAt: evaluatedAt.UTC(),
Result: result, Metrics: metrics, FailedChecks: failed,
}, nil
}
func SuiteSHA256(suite Suite) (string, error) {
encodedSuite, err := json.Marshal(suite)
if err != nil {
return "", fmt.Errorf("encode suite: %w", err)
}
digest := sha256.Sum256(encodedSuite)
return hex.EncodeToString(digest[:]), nil
}
func NewArtifact(report Report, reportRef string) (Artifact, error) {
reportRef = strings.TrimSpace(reportRef)
if reportRef == "" || len([]rune(reportRef)) > 300 {
return Artifact{}, errors.New("report_ref must contain 1 to 300 characters")
}
metrics := map[string]any{
"report_schema": report.Schema, "suite_sha256": report.SuiteSHA256,
"agent_version": report.AgentVersion, "model_config_ref": report.ModelConfigRef,
"environment": report.Environment, "executed_at": report.ExecutedAt,
"cases_total": report.Metrics.CasesTotal, "cases_passed": report.Metrics.CasesPassed,
"conversation_success_rate": report.Metrics.ConversationSuccessRate,
"first_response_p95_ms": report.Metrics.FirstResponseP95MS,
"interruption_recovery_rate": report.Metrics.InterruptionRecoveryRate,
"average_response_chars": report.Metrics.AverageResponseChars,
"salutation_accuracy": report.Metrics.SalutationAccuracy,
"memory_accuracy": report.Metrics.MemoryAccuracy,
"fabricated_facts": report.Metrics.FabricatedFacts,
"story_source_completeness": report.Metrics.StorySourceCompleteness,
"sensitive_information_handling_rate": report.Metrics.SensitiveInformationHandling,
"failed_check_count": len(report.FailedChecks),
}
return Artifact{Report: report, RegistrationPayload: RegistrationPayload{
EvidenceType: "AGENT_QUALITY_V1", SuiteVersion: report.SuiteVersion,
Result: report.Result, Metrics: metrics, ReportRef: reportRef,
}}, nil
}
func RequiredCategories() []string {
return append([]string(nil), requiredCategories...)
}
func validateSuite(suite Suite) error {
if suite.Schema != SuiteSchema {
return fmt.Errorf("unsupported suite schema %q", suite.Schema)
}
if strings.TrimSpace(suite.SuiteVersion) == "" || len([]rune(suite.SuiteVersion)) > 100 {
return errors.New("suite_version must contain 1 to 100 characters")
}
if strings.TrimSpace(suite.ModelConfigRef) == "" {
return errors.New("model_config_ref is required")
}
if len(suite.Cases) == 0 {
return errors.New("suite must contain cases")
}
if err := validateThresholds(suite.Thresholds); err != nil {
return err
}
seenIDs := map[string]struct{}{}
seenCategories := map[string]struct{}{}
requiredFlagsByCategory := map[string]map[string]struct{}{}
for index, testCase := range suite.Cases {
testCase.ID = strings.TrimSpace(testCase.ID)
if testCase.ID == "" || len([]rune(testCase.ID)) > 100 {
return fmt.Errorf("case %d has invalid id", index)
}
if _, exists := seenIDs[testCase.ID]; exists {
return fmt.Errorf("duplicate case id %q", testCase.ID)
}
seenIDs[testCase.ID] = struct{}{}
seenCategories[testCase.Category] = struct{}{}
if strings.TrimSpace(testCase.Description) == "" || strings.TrimSpace(testCase.ScoringRuleRef) == "" {
return fmt.Errorf("case %q requires description and scoring_rule_ref", testCase.ID)
}
if testCase.Expected.ConversationSuccess == nil || testCase.Expected.MaxFirstResponseMS <= 0 || testCase.Expected.MaxResponseChars <= 0 || testCase.Expected.MaxFabricatedFacts < 0 {
return fmt.Errorf("case %q has invalid expectations", testCase.ID)
}
if len(testCase.Expected.RequiredFlags) == 0 {
return fmt.Errorf("case %q must define required_flags", testCase.ID)
}
flags := map[string]struct{}{}
for _, flag := range testCase.Expected.RequiredFlags {
flag = strings.TrimSpace(flag)
if flag == "" {
return fmt.Errorf("case %q contains an empty required flag", testCase.ID)
}
if _, exists := flags[flag]; exists {
return fmt.Errorf("case %q contains duplicate required flag %q", testCase.ID, flag)
}
flags[flag] = struct{}{}
if requiredFlagsByCategory[testCase.Category] == nil {
requiredFlagsByCategory[testCase.Category] = map[string]struct{}{}
}
requiredFlagsByCategory[testCase.Category][flag] = struct{}{}
}
}
for _, category := range requiredCategories {
if _, exists := seenCategories[category]; !exists {
return fmt.Errorf("suite does not cover required category %q", category)
}
}
requiredScoringFlags := map[string]string{
"salutation_correct": "normal_chat",
"interruption_recovered": "user_interruption",
"memory_correct": "memory_recall",
"sensitive_handling_pass": "sensitive_information",
"story_source_complete": "",
}
for flag, category := range requiredScoringFlags {
found := false
if category != "" {
_, found = requiredFlagsByCategory[category][flag]
} else {
for _, flags := range requiredFlagsByCategory {
if _, exists := flags[flag]; exists {
found = true
break
}
}
}
if !found {
return fmt.Errorf("suite does not score required flag %q", flag)
}
}
return nil
}
func validateThresholds(thresholds Thresholds) error {
rates := map[string]float64{
"min_conversation_success_rate": thresholds.MinConversationSuccessRate,
"min_interruption_recovery_rate": thresholds.MinInterruptionRecoveryRate,
"min_salutation_accuracy": thresholds.MinSalutationAccuracy,
"min_memory_accuracy": thresholds.MinMemoryAccuracy,
"min_story_source_completeness": thresholds.MinStorySourceCompleteness,
"min_sensitive_handling_rate": thresholds.MinSensitiveHandlingRate,
}
for name, value := range rates {
if value < 0 || value > 1 {
return fmt.Errorf("%s must be between 0 and 1", name)
}
}
if thresholds.MaxFirstResponseP95MS <= 0 || thresholds.MaxAverageResponseChars <= 0 || thresholds.MaxFabricatedFacts < 0 {
return errors.New("maximum thresholds must be positive and max_fabricated_facts cannot be negative")
}
return nil
}
func validateRun(suite Suite, run ObservationRun) error {
if run.Schema != ObservationSchema {
return fmt.Errorf("unsupported observation schema %q", run.Schema)
}
if run.SuiteVersion != suite.SuiteVersion {
return fmt.Errorf("observation suite_version %q does not match %q", run.SuiteVersion, suite.SuiteVersion)
}
if strings.TrimSpace(run.AgentVersion) == "" {
return errors.New("agent_version is required")
}
if run.ModelConfigRef != suite.ModelConfigRef {
return fmt.Errorf("model_config_ref %q does not match suite %q", run.ModelConfigRef, suite.ModelConfigRef)
}
if run.Environment != "mock" && run.Environment != "real" {
return errors.New("environment must be mock or real")
}
if run.ExecutedAt.IsZero() {
return errors.New("executed_at is required")
}
caseIDs := make(map[string]struct{}, len(suite.Cases))
for _, testCase := range suite.Cases {
caseIDs[testCase.ID] = struct{}{}
}
seen := make(map[string]struct{}, len(run.Observations))
for _, observation := range run.Observations {
if _, exists := caseIDs[observation.CaseID]; !exists {
return fmt.Errorf("observation references unknown case %q", observation.CaseID)
}
if _, exists := seen[observation.CaseID]; exists {
return fmt.Errorf("duplicate observation for case %q", observation.CaseID)
}
seen[observation.CaseID] = struct{}{}
if observation.FirstResponseMS < 0 || (observation.ConversationSuccess && observation.FirstResponseMS == 0) || observation.ResponseChars < 0 || observation.FabricatedFacts < 0 || observation.Flags == nil {
return fmt.Errorf("observation for case %q has invalid metric values", observation.CaseID)
}
}
if len(seen) != len(caseIDs) {
missing := make([]string, 0)
for caseID := range caseIDs {
if _, exists := seen[caseID]; !exists {
missing = append(missing, caseID)
}
}
sort.Strings(missing)
return fmt.Errorf("observations missing cases: %s", strings.Join(missing, ", "))
}
return nil
}
func evaluateCase(testCase Case, observation Observation) []FailedCheck {
failed := make([]FailedCheck, 0)
if observation.ConversationSuccess != *testCase.Expected.ConversationSuccess {
failed = append(failed, FailedCheck{Code: "conversation_success_mismatch", CaseID: testCase.ID, Message: "通话结果与用例期望不一致", Actual: observation.ConversationSuccess, Expected: *testCase.Expected.ConversationSuccess})
}
if observation.FirstResponseMS > testCase.Expected.MaxFirstResponseMS {
failed = append(failed, FailedCheck{Code: "first_response_too_slow", CaseID: testCase.ID, Message: "首个回答时延超过用例上限", Actual: observation.FirstResponseMS, Expected: testCase.Expected.MaxFirstResponseMS})
}
if observation.ResponseChars > testCase.Expected.MaxResponseChars {
failed = append(failed, FailedCheck{Code: "response_too_long", CaseID: testCase.ID, Message: "回答长度超过用例上限", Actual: observation.ResponseChars, Expected: testCase.Expected.MaxResponseChars})
}
if observation.FabricatedFacts > testCase.Expected.MaxFabricatedFacts {
failed = append(failed, FailedCheck{Code: "fabricated_fact", CaseID: testCase.ID, Message: "虚构事实数量超过用例上限", Actual: observation.FabricatedFacts, Expected: testCase.Expected.MaxFabricatedFacts})
}
for _, flag := range testCase.Expected.RequiredFlags {
if !observation.Flags[flag] {
failed = append(failed, FailedCheck{Code: "required_flag_failed", CaseID: testCase.ID, Message: "评分规则未通过: " + flag, Actual: false, Expected: true})
}
}
return failed
}
func evaluateThresholds(thresholds Thresholds, metrics Metrics) []FailedCheck {
checks := []struct {
code string
message string
actual float64
expected float64
minimum bool
}{
{"conversation_success_rate_regressed", "通话成功率低于发布门槛", metrics.ConversationSuccessRate, thresholds.MinConversationSuccessRate, true},
{"first_response_p95_regressed", "首个回答P95时延超过发布门槛", float64(metrics.FirstResponseP95MS), float64(thresholds.MaxFirstResponseP95MS), false},
{"interruption_recovery_regressed", "打断恢复率低于发布门槛", metrics.InterruptionRecoveryRate, thresholds.MinInterruptionRecoveryRate, true},
{"average_response_length_regressed", "平均回答长度超过发布门槛", metrics.AverageResponseChars, thresholds.MaxAverageResponseChars, false},
{"salutation_accuracy_regressed", "称呼准确率低于发布门槛", metrics.SalutationAccuracy, thresholds.MinSalutationAccuracy, true},
{"memory_accuracy_regressed", "记忆准确率低于发布门槛", metrics.MemoryAccuracy, thresholds.MinMemoryAccuracy, true},
{"story_source_regressed", "故事来源完整率低于发布门槛", metrics.StorySourceCompleteness, thresholds.MinStorySourceCompleteness, true},
{"sensitive_handling_regressed", "敏感信息处理通过率低于发布门槛", metrics.SensitiveInformationHandling, thresholds.MinSensitiveHandlingRate, true},
}
failed := make([]FailedCheck, 0)
for _, check := range checks {
violated := check.actual > check.expected
if check.minimum {
violated = check.actual < check.expected
}
if violated {
failed = append(failed, FailedCheck{Code: check.code, Message: check.message, Actual: check.actual, Expected: check.expected})
}
}
if metrics.FabricatedFacts > thresholds.MaxFabricatedFacts {
failed = append(failed, FailedCheck{Code: "fabricated_facts_regressed", Message: "虚构事实总数超过发布门槛", Actual: metrics.FabricatedFacts, Expected: thresholds.MaxFabricatedFacts})
}
return failed
}
func percentile95(values []int) int {
sorted := append([]int(nil), values...)
sort.Ints(sorted)
index := int(math.Ceil(float64(len(sorted))*0.95)) - 1
if index < 0 {
return 0
}
return sorted[index]
}
func rate(passed, total int) float64 {
if total == 0 {
return 0
}
return float64(passed) / float64(total)
}
func requiredFlagRate(cases []Case, observations map[string]Observation, flag string) float64 {
passed, total := 0, 0
for _, testCase := range cases {
if !contains(testCase.Expected.RequiredFlags, flag) {
continue
}
total++
if observations[testCase.ID].Flags[flag] {
passed++
}
}
return rate(passed, total)
}
func flagRate(cases []Case, observations map[string]Observation, category, flag string) float64 {
passed, total := 0, 0
for _, testCase := range cases {
if testCase.Category != category {
continue
}
total++
if observations[testCase.ID].Flags[flag] {
passed++
}
}
return rate(passed, total)
}
func contains(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}