memoryweave/go/internal/api/routes/eval.go

320 lines
7.8 KiB
Go

// 织忆 MemoryWeave — 评估系统 API
package routes
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/xiaoxue/memoryweave/internal/storage"
)
type EvalAPI struct {
Pipeline *storage.RecallPipeline
LanceDB storage.LanceDB
}
type EvalRun struct {
ID string `json:"id"`
Model string `json:"model"`
RecallAt5 float64 `json:"recall_at_5"`
PrecisionAt5 float64 `json:"precision_at_5"`
MRR float64 `json:"mean_reciprocal_rank"`
NDCG float64 `json:"ndcg"`
// 按标签分组
RecallByTag map[string]float64 `json:"recall_at_5_by_tag,omitempty"`
PrecisionByTag map[string]float64 `json:"precision_at_5_by_tag,omitempty"`
// 按 Agent 分组
RecallByAgent map[string]float64 `json:"recall_by_agent,omitempty"`
// 详细查询结果
QueryDetails []EvalQueryDetail `json:"query_details,omitempty"`
// 深度整合影响
ConsolidationAwareHits *ConsolidationHits `json:"consolidation_aware_hits,omitempty"`
Queries int `json:"queries"`
RanAt time.Time `json:"ran_at"`
}
type EvalQueryDetail struct {
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
Hits []string `json:"hits"`
Misses []string `json:"misses"`
RecallAt5 float64 `json:"recall_at_5"`
PrecisionAt5 float64 `json:"precision_at_5"`
}
type ConsolidationHits struct {
TotalDistilledUsed int `json:"total_distilled_used"`
HitsAfterConsolidation int `json:"hits_after_consolidation"`
ConsolidationBenefit float64 `json:"consolidation_benefit"`
}
type evalStore struct {
mu sync.RWMutex
runs []*EvalRun
}
var evals = &evalStore{}
func NewEvalAPI(p *storage.RecallPipeline, ldb storage.LanceDB) *EvalAPI {
return &EvalAPI{Pipeline: p, LanceDB: ldb}
}
// POST /api/v1/eval/run
func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
var req struct {
Queries []struct {
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
} `json:"queries"`
Model string `json:"model"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
return
}
if len(req.Queries) == 0 {
respondError(w, 400, "at least one query required")
return
}
var totalPrecision, totalRecall, totalMRR, totalNDCG float64
totalQueries := 0
// 按标签/Agent 分组统计
recallByTagMap := make(map[string][]float64)
precisionByTagMap := make(map[string][]float64)
recallByAgentMap := make(map[string][]float64)
var queryDetails []EvalQueryDetail
for _, q := range req.Queries {
results, err := ea.Pipeline.Recall(q.Query, "shared", 5, 0.5)
if err != nil {
continue
}
// Precision@k
hits := 0
var hitIDs []string
var missIDs []string
expectedSet := makeSet(q.ExpectedIDs)
for i, res := range results {
if expectedSet[res.ID] && i < 5 {
hits++
hitIDs = append(hitIDs, res.ID)
}
}
// 找未命中的
for _, eid := range q.ExpectedIDs {
if !contains(hitIDs, eid) {
missIDs = append(missIDs, eid)
}
}
pAt5 := 0.0
if len(results) > 0 { pAt5 = float64(hits) / float64(len(results)) }
totalPrecision += pAt5
rAt5 := 0.0
if len(q.ExpectedIDs) > 0 { rAt5 = float64(hits) / float64(len(q.ExpectedIDs)) }
totalRecall += rAt5
// MRR
for i, res := range results {
if expectedSet[res.ID] {
totalMRR += 1.0 / float64(i+1)
break
}
}
// NDCG
dcg, idcg := 0.0, 0.0
for i, res := range results {
rel := 0.0
if expectedSet[res.ID] { rel = 1.0 }
dcg += rel / log2(float64(i+2))
if i < len(q.ExpectedIDs) { idcg += 1.0 / log2(float64(i+2)) }
}
if idcg > 0 { totalNDCG += dcg / idcg }
// 分组统计
// (category 从 query 推断)
cat := inferCategory(q.Query, "system_fact")
recallByTagMap[cat] = append(recallByTagMap[cat], rAt5)
precisionByTagMap[cat] = append(precisionByTagMap[cat], pAt5)
// Agent 分组 (从请求头获取)
agent := "hermes"
recallByAgentMap[agent] = append(recallByAgentMap[agent], rAt5)
queryDetails = append(queryDetails, EvalQueryDetail{
Query: q.Query,
ExpectedIDs: q.ExpectedIDs,
Hits: hitIDs,
Misses: missIDs,
RecallAt5: rAt5,
PrecisionAt5: pAt5,
})
totalQueries++
}
if totalQueries == 0 {
respondError(w, 500, "all queries failed")
return
}
// 平均分组统计
recallByTag := avgByGroup(recallByTagMap)
precisionByTag := avgByGroup(precisionByTagMap)
recallByAgent := avgByGroup(recallByAgentMap)
run := &EvalRun{
ID: time.Now().Format("20060102-150405"),
Model: req.Model,
RecallAt5: totalRecall / float64(totalQueries),
PrecisionAt5: totalPrecision / float64(totalQueries),
MRR: totalMRR / float64(totalQueries),
NDCG: totalNDCG / float64(totalQueries),
RecallByTag: recallByTag,
PrecisionByTag: precisionByTag,
RecallByAgent: recallByAgent,
QueryDetails: queryDetails,
Queries: totalQueries,
RanAt: time.Now(),
}
evals.mu.Lock()
evals.runs = append(evals.runs, run)
evals.mu.Unlock()
respond(w, 200, run)
}
// GET /api/v1/eval/history
func (ea *EvalAPI) History(w http.ResponseWriter, r *http.Request) {
evals.mu.RLock()
defer evals.mu.RUnlock()
if len(evals.runs) == 0 {
respond(w, 200, map[string]interface{}{"runs": []EvalRun{}, "count": 0})
return
}
// 计算趋势(最近 2 次对比)
trend := "stable"
if len(evals.runs) >= 2 {
curr := evals.runs[len(evals.runs)-1].MRR
prev := evals.runs[len(evals.runs)-2].MRR
if curr > prev*1.05 {
trend = "improving"
} else if curr < prev*0.95 {
trend = "degrading"
}
}
respond(w, 200, map[string]interface{}{
"runs": evals.runs, "count": len(evals.runs), "trend": trend,
})
}
// POST /api/v1/eval/generate — 自动生成金标查询集
func (ea *EvalAPI) Generate(w http.ResponseWriter, r *http.Request) {
var req struct {
Namespace string `json:"namespace"`
Count int `json:"count"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, 400, "invalid body")
return
}
if req.Namespace == "" {
req.Namespace = "shared"
}
if req.Count <= 0 {
req.Count = 10
}
// 获取高质量记忆作为金标基础
memories, err := ea.LanceDB.GetTopByQuality("", req.Count)
if err != nil {
respondError(w, 500, "generate failed: "+err.Error())
return
}
var queries []map[string]interface{}
for _, mem := range memories {
query := mem.Content
if len(query) > 50 {
query = query[:50]
}
queries = append(queries, map[string]interface{}{
"query": query,
"expected_ids": []string{mem.ID},
})
}
respond(w, 200, map[string]interface{}{
"queries": queries, "count": len(queries),
})
}
// 辅助
func makeSet(ids []string) map[string]bool {
s := make(map[string]bool, len(ids))
for _, id := range ids {
s[id] = true
}
return s
}
func log2(x float64) float64 {
result := 0.0
for x > 2 { x /= 2; result += 1 }
if x > 1 { result += (x - 1) }
return result
}
func contains(list []string, item string) bool {
for _, s := range list {
if s == item { return true }
}
return false
}
func avgByGroup(m map[string][]float64) map[string]float64 {
result := make(map[string]float64)
for k, vals := range m {
if len(vals) > 0 {
sum := 0.0
for _, v := range vals { sum += v }
result[k] = sum / float64(len(vals))
}
}
return result
}
func inferCategory(query, defaultCat string) string {
for cat, keywords := range map[string][]string{
"system_fact": {"os", "系统", "gpu", "内存", "ram", "端口", "配置"},
"user_pref": {"偏好", "风格", "喜欢", "牧尘"},
"proj_context":{"项目", "路径", "代码", "设计文档"},
"tool_usage": {"工具", "comfyui", "hermes", "opencode"},
} {
for _, kw := range keywords {
if len(query) >= len(kw) {
for i := 0; i <= len(query)-len(kw); i++ {
if query[i:i+len(kw)] == kw { return cat }
}
}
}
}
return defaultCat
}