chore(api): 收工未提交杂项 core.go/eval.go/testdata

This commit is contained in:
小唯
2026-09-21 18:12:18 +08:00
parent c6be6a4cd5
commit 5eccbaef03
3 changed files with 298 additions and 11 deletions
+45 -5
View File
@@ -48,8 +48,29 @@ func respond(w http.ResponseWriter, code int, data interface{}) {
json.NewEncoder(w).Encode(data)
}
// Unified error body schema (t_2d4e3b92, 2026-09-21):
//
// HTTP non-2xx → {"error":{"code":<string>,"message":<string>,"retry_after_s":<int>}}
// HTTP 2xx → success schema only (recall: {"results":[...],"count":N,...})
//
// Contract for callers: 200 + "results" key present = success (results MAY be empty —
// a legitimate "no match"); non-2xx = failure (never parse as success). Do NOT
// distinguish by len(results)==0.
func respondError(w http.ResponseWriter, code int, msg string) {
respond(w, code, map[string]string{"error": msg})
respond(w, code, map[string]interface{}{"error": map[string]interface{}{
"code": "failure",
"message": msg,
"retry_after_s": 0,
}})
}
// respondErrorSchema returns a non-2xx with the unified error schema.
func respondErrorSchema(w http.ResponseWriter, code int, codeStr, msg string, retryAfterS int) {
respond(w, code, map[string]interface{}{"error": map[string]interface{}{
"code": codeStr,
"message": msg,
"retry_after_s": retryAfterS,
}})
}
// POST /api/v1/commit
@@ -273,11 +294,27 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
switch req.Mode {
case "keyword":
// Pure keyword search: LanceDB + BM25 re-rank, or graph.db fallback
// t_2d4e3b92: keyword 主路径依赖向量编码;编码失败(依赖不可用)⇒ 非 2xx,
// 不再把错误伪装成 200 + 错误体。
kwResults, kwErr := a.Pipeline.Recall(req.Query, req.Namespace, req.Limit, req.Diversity)
if kwErr != nil || len(kwResults) == 0 {
if kwErr != nil {
// 主路径硬错误:兜底命中也按"主路径故障"报告(502),调用方据此走失败路径
if fb := a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, req.Limit); len(fb) > 0 {
_ = fb
}
respondErrorSchema(w, 502, "recall_backend_error",
"keyword recall failed: "+kwErr.Error(), 5)
log.Printf("[recall:keyword] failed: %v", kwErr)
return
}
if len(kwResults) == 0 {
// 真无匹配(正常业务):成功 schema,results=[] + candidates_scanned 供区分
fallbackResults := a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, req.Limit)
results = convertFallbackResults(fallbackResults)
respond(w, 200, map[string]interface{}{"results": results, "count": len(results), "mode": "keyword"})
respond(w, 200, map[string]interface{}{
"results": results, "count": len(results), "mode": "keyword",
"candidates_scanned": 0, // keyword 路径未做 ANN 扫描
})
return
}
// Re-rank by BM25 only
@@ -298,13 +335,16 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
results, err = a.Pipeline.Recall(req.Query, req.Namespace, req.Limit, req.Diversity)
}
if err != nil {
// P0: 降级到 graph.db 关键词搜索
// P0: 降级到 graph.db 关键词搜索(仅 hybrid/semantic 主路径依赖向量/rerank)
fallbackResults := a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, req.Limit)
if len(fallbackResults) > 0 {
results = convertFallbackResults(fallbackResults)
w.Header().Set("X-Fallback", "graph")
} else {
respondError(w, 500, "recall failed: "+err.Error())
// t_2d4e3b92: 主路径与兜底都失败 ⇒ 非 2xx + 统一错误体(绝不在 200 里塞错误)
respondErrorSchema(w, 502, "recall_backend_error",
"recall failed: "+err.Error()+"; graph fallback also empty", 5)
log.Printf("[recall] failed: %v; fallback empty", err)
return
}
}
+136 -6
View File
@@ -4,11 +4,130 @@ package routes
import (
"encoding/json"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/xiaoxue/memoryweave/internal/storage"
)
// ─── 金标集定义 ──────────────────────────────────────────────
// GoldenSetItem 单条金标:query + 具体 expected_ids + 关键词相关性判据
type GoldenSetItem struct {
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
MustIncludeKeywords []string `json:"must_include_keywords"`
Category string `json:"category,omitempty"`
Difficulty string `json:"difficulty,omitempty"`
IDBaseline string `json:"id_baseline,omitempty"`
}
// GoldenSet 金标集
type GoldenSet struct {
Description string `json:"description"`
Items []GoldenSetItem `json:"items"`
}
// LoadGoldenSet 从 testdata/ 加载金标集,支持相对路径(生产用)和绝对路径(测试用)
func LoadGoldenSet(path string) (*GoldenSet, error) {
if path == "" {
// 编译后无法直接拿到源码路径,回退到 CWD 的 testdata/
path = "testdata/golden-set.json"
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var gs GoldenSet
if err := json.Unmarshal(data, &gs); err != nil {
return nil, err
}
// 空断言自守:禁止裸前缀/空串混入(避免恒真)
for i, item := range gs.Items {
if err := validateExpectedIDs(item.Query, item.ExpectedIDs); err != nil {
return nil, err
}
_ = i
}
return &gs, nil
}
// validateExpectedIDs 空断言自守:裸前缀 "mem_"、空串、None → 直接报错,不许静默 PASS
func validateExpectedIDs(query string, expectedIDs []string) error {
if len(expectedIDs) == 0 {
return nil // 允许空(bootstrap 时还没基线)
}
for _, id := range expectedIDs {
trimmed := strings.TrimSpace(id)
// 裸前缀 "mem_"(不含具体 ID 数字)
if trimmed == "mem_" || trimmed == "mem" {
return &VacuousAssertionError{
Query: query,
BadID: id,
Reason: "裸前缀 mem_ 是恒真断言——所有记忆 ID 都以 mem_ 开头",
}
}
// 空串
if trimmed == "" {
return &VacuousAssertionError{
Query: query,
BadID: id,
Reason: "空串期望值(无意义断言)",
}
}
// None(JSON null 解码成空字符串已处理,但检查 "None" 字符串)
if strings.EqualFold(trimmed, "none") {
return &VacuousAssertionError{
Query: query,
BadID: id,
Reason: "None 期望值(无意义断言)",
}
}
}
return nil
}
// VacuousAssertionError 空断言错误
type VacuousAssertionError struct {
Query string
BadID string
Reason string
}
func (e *VacuousAssertionError) Error() string {
return "FATAL 空断言被检出(恒真): query=" + e.Query + " 期望=" + e.BadID + " — " + e.Reason
}
// checkKeywordRelevance 关键词相关性判据:返回内容必须包含至少一个关键词
func checkKeywordRelevance(content string, keywords []string) bool {
if len(keywords) == 0 {
// 无关键词判据 → 视为不通过(禁止退化为恒真)
return false
}
lower := strings.ToLower(content)
for _, kw := range keywords {
if strings.Contains(lower, strings.ToLower(kw)) {
return true
}
}
return false
}
// validateGoldenSetForRun 对一次 eval run 的所有 expected_ids 做空断言自守
func validateGoldenSetForRun(queries []struct {
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
}) error {
for _, q := range queries {
if err := validateExpectedIDs(q.Query, q.ExpectedIDs); err != nil {
return err
}
}
return nil
}
type EvalAPI struct {
Pipeline *storage.RecallPipeline
LanceDB storage.LanceDB
@@ -39,13 +158,24 @@ type EvalRun struct {
RanAt time.Time `json:"ran_at"`
}
// EvalQueryDetail
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"`
Query string `json:"query"`
ExpectedIDs []string `json:"expected_ids"`
MustIncludeKeywords []string `json:"must_include_keywords,omitempty"`
Hits []string `json:"hits"`
Misses []string `json:"misses"`
RecallAt5 float64 `json:"recall_at_5"`
PrecisionAt5 float64 `json:"precision_at_5"`
KeywordOK bool `json:"keyword_ok,omitempty"`
}
// GoldenRunResult 金标 run 的扩展结果(含关键词判据)
type GoldenRunResult struct {
EvalRun
KeywordPass int `json:"keyword_pass"`
KeywordFail int `json:"keyword_fail"`
KeywordTotal int `json:"keyword_total"`
}
type ConsolidationHits struct {
+117
View File
@@ -0,0 +1,117 @@
{
"description": "织忆召回金标检查集(12条基线 + 2条目标知识)",
"items": [
{
"query": "织忆是什么",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"category": "system_fact",
"difficulty": "easy",
"id_baseline": "mem_1789153726378940805"
},
{
"query": "织忆的图谱扩展机制是什么",
"expected_ids": ["mem_1789823837902661249"],
"must_include_keywords": ["图谱"],
"category": "system_fact",
"difficulty": "medium",
"id_baseline": "mem_1789823837902661249"
},
{
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
"expected_ids": ["mem_1789915227303612056"],
"must_include_keywords": ["MMR"],
"category": "system_fact",
"difficulty": "hard",
"id_baseline": "mem_1789915227303612056"
},
{
"query": "牧尘的偏好是什么",
"expected_ids": ["mem_1789667544872361813"],
"must_include_keywords": ["牧尘"],
"category": "user_pref",
"difficulty": "easy",
"id_baseline": "mem_1789667544872361813"
},
{
"query": "牧尘喜欢什么样的工作方式",
"expected_ids": ["mem_1789837840173161705"],
"must_include_keywords": ["牧尘"],
"category": "user_pref",
"difficulty": "medium",
"id_baseline": "mem_1789837840173161705"
},
{
"query": "如何根据牧尘的偏好调整记忆检索策略",
"expected_ids": ["mem_1789190217889697697"],
"must_include_keywords": ["记忆"],
"category": "user_pref",
"difficulty": "hard",
"id_baseline": "mem_1789190217889697697"
},
{
"query": "当前项目的技术栈是什么",
"expected_ids": ["mem_1789680870868739202"],
"must_include_keywords": ["技术栈"],
"category": "proj_context",
"difficulty": "easy",
"id_baseline": "mem_1789680870868739202"
},
{
"query": "织忆项目有哪些核心组件",
"expected_ids": ["mem_1789154058288011112"],
"must_include_keywords": ["织忆"],
"category": "proj_context",
"difficulty": "medium",
"id_baseline": "mem_1789154058288011112"
},
{
"query": "织忆和其他记忆系统相比有什么架构优势",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"category": "proj_context",
"difficulty": "hard",
"id_baseline": "mem_1789153726378940805"
},
{
"query": "如何使用 recall 接口",
"expected_ids": ["mem_1787272787836687543"],
"must_include_keywords": ["recall"],
"category": "tool_usage",
"difficulty": "easy",
"id_baseline": "mem_1787272787836687543"
},
{
"query": "织忆有哪些管理工具",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"category": "tool_usage",
"difficulty": "medium",
"id_baseline": "mem_1789153726378940805"
},
{
"query": "如何通过 API 扩展织忆功能",
"expected_ids": ["mem_1789153726378940805"],
"must_include_keywords": ["织忆"],
"category": "tool_usage",
"difficulty": "hard",
"id_baseline": "mem_1789153726378940805"
},
{
"query": "本地 llama 服务 8080 端口",
"expected_ids": ["mem_1789229548476546193"],
"must_include_keywords": ["8080"],
"category": "target_knowledge",
"difficulty": "easy",
"id_baseline": "mem_1789229548476546193"
},
{
"query": "session-to-zhiyi.py 脚本名",
"expected_ids": ["mem_1788977361686058470"],
"must_include_keywords": ["session-to-zhiyi"],
"category": "target_knowledge",
"difficulty": "easy",
"id_baseline": "mem_1788977361686058470"
}
]
}