这批是 10-26~27 已在生产二进制(zhiyid-protectrails-20261027)里运行、 但一直留在工作树未提交的改动。收口原因:三件改造要开分支,工作树脏 = 分支基准与回滚点都不存在。 主要内容: - api/routes/contract.go + trace.go:召回响应契约字段(trace_id/status/scope/ continuation/channel)与 /api/v1/trace 回溯端点 - governance/entity_quality.go:实体质量闸 - storage/trace.go + recall.go/recall_write_buffer.go:召回写入缓冲与轨迹 - 测试:contract/recall_contract_fields/recall_lifecycle/stale_lifecycle/ entity_quality/trace 同时修一个 flaky:recall_lifecycle_test 原本硬编码「今天 11:00 已过」, 凌晨跑就会把「已失效」fixture 当成仍有效。改成相对时间(过期=now-1h), 生命周期判据与运行时刻无关。修完 go test ./internal/api/routes ./internal/storage 全绿。
229 lines
8.1 KiB
Go
229 lines
8.1 KiB
Go
package pattern
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"os"
|
||
"testing"
|
||
|
||
_ "modernc.org/sqlite"
|
||
|
||
"github.com/xiaoxue/memoryweave/internal/models"
|
||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||
)
|
||
|
||
// ── t_bd63b641 影-复:PATTERN/PEER 开门量具(不碰生产;只读 graph.db + 实时 embedder)──
|
||
// 量化「开 ZHIYI_PATTERN_RECALL / ZHIYI_PEER_RECALL」对 14 金标会注入什么。
|
||
// Provider 装配在 server.go 启动期由 env 决定 ⇒ 不重启无法在活服务上测开态;
|
||
// 故本测试直接构造真实 Provider + 生产 graph.db(只读)+ 实时 bge embedder,
|
||
// 跑出「开态会注入的条目」,与关态(=主召回原样)对比。
|
||
//
|
||
// 本测试只读、不落盘、不改库(AC:不碰生产二进制/不重启)。
|
||
|
||
type logRecord struct {
|
||
ID string
|
||
Category string
|
||
Content string
|
||
}
|
||
|
||
// runShadowWithDiag 返回:注入结果 + 诊断(pattern 装载数 / query 是否编码成功 / 各 query 最高余弦)。
|
||
// ⚠️ 关键发现:生产 graph_nodes(type='pattern') 的 id 全为 NULL(85/85),ListPatterns 读 id 为 string
|
||
// ⇒ 恒报 sql: Scan error (converting NULL to string),Match 直接 return nil。
|
||
// 故 runShadow 不把 ListPatterns 错误当失败 —— 它本身就是「开态也无法注入」的根因证据。
|
||
func runShadowWithDiag() (map[string][]logRecord, int, string, map[string]float64) {
|
||
endpoint := os.Getenv("VLLM_ENDPOINT")
|
||
if endpoint == "" {
|
||
endpoint = "http://localhost:8000/v1/embeddings"
|
||
}
|
||
emb := storage.NewEmbedder(endpoint)
|
||
store := NewGraphPatternStore("/var/lib/memoryweave/graph.db")
|
||
pp := NewPatternProvider(store, NewEmbedderProvider(emb))
|
||
|
||
// 装载 pattern 数(防「store 空 → 注入恒 0」的假阴性);记录 ListPatterns 错误原文
|
||
pats, _, lerr := store.ListPatterns(1000)
|
||
loaded := 0
|
||
listErr := ""
|
||
if lerr != nil {
|
||
listErr = lerr.Error() // 根因:id 全 NULL → Scan error
|
||
} else {
|
||
loaded = len(pats)
|
||
}
|
||
|
||
golden := []string{
|
||
"织忆是什么", "织忆的图谱扩展机制是什么",
|
||
"织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么",
|
||
"牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略",
|
||
"当前项目的技术栈是什么", "织忆项目有哪些核心组件",
|
||
"织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口",
|
||
"织忆有哪些管理工具", "如何通过 API 扩展织忆功能",
|
||
"本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名",
|
||
}
|
||
out := make(map[string][]logRecord)
|
||
maxCos := make(map[string]float64)
|
||
|
||
// 预编码 14 query(若编码失败会记 0,便于区分「没命中」vs「编码挂了」)
|
||
for _, q := range golden {
|
||
qv, eerr := emb.EncodeSingle(q)
|
||
if eerr != nil || len(qv) == 0 {
|
||
maxCos[q] = -1 // 编码失败标记
|
||
continue
|
||
}
|
||
// 逐个 pattern 算余弦,取最高(复现 Provider.Match 内部逻辑)
|
||
best := -1.0
|
||
for _, p := range pats {
|
||
rep := p.Data
|
||
if rep == "" {
|
||
rep = p.Name
|
||
}
|
||
text := rep
|
||
for _, tl := range p.Tools {
|
||
if tl != "" {
|
||
text += " " + tl
|
||
}
|
||
}
|
||
pv, perr := emb.EncodeSingle(text)
|
||
if perr != nil || len(pv) == 0 {
|
||
continue
|
||
}
|
||
s := cosineF32(qv, pv)
|
||
if s > best {
|
||
best = s
|
||
}
|
||
}
|
||
maxCos[q] = best
|
||
}
|
||
|
||
// 真注入(走 Provider.Match,同 server 路径)
|
||
for _, g := range golden {
|
||
pat := pp.Match(g, "hermes-main", 3) // server.go:902 topK=3
|
||
for _, r := range pat {
|
||
out[g] = append(out[g], logRecord{ID: r.ID, Category: r.Category, Content: r.Content})
|
||
}
|
||
}
|
||
return out, loaded, listErr, maxCos
|
||
}
|
||
|
||
// TestShadowPattern_Diff —— 开态 pattern 注入量化(只读生产库)
|
||
func TestShadowPattern_Diff(t *testing.T) {
|
||
out, loaded, listErr, maxCos := runShadowWithDiag()
|
||
t.Logf("PATTERNS_LOADED=%d (防假阴性:store 有货才会注入)", loaded)
|
||
if listErr != "" {
|
||
t.Logf("PATTERN_LISTERROR=%q — 根因:pattern 行 id 全 NULL ⇒ Provider.Match 恒 return nil", listErr)
|
||
}
|
||
|
||
queries := []string{
|
||
"织忆是什么", "织忆的图谱扩展机制是什么",
|
||
"织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么",
|
||
"牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略",
|
||
"当前项目的技术栈是什么", "织忆项目有哪些核心组件",
|
||
"织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口",
|
||
"织忆有哪些管理工具", "如何通过 API 扩展织忆功能",
|
||
"本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名",
|
||
}
|
||
totalInjected := 0
|
||
for _, q := range queries {
|
||
mc := maxCos[q]
|
||
if mc < 0 {
|
||
t.Logf("[EMB-FAIL] %q → 编码失败(不计命中)", q)
|
||
}
|
||
if n := len(out[q]); n > 0 {
|
||
totalInjected += n
|
||
t.Logf("[INJECT] %q → %d pattern (maxcos=%.3f)", q, n, mc)
|
||
for _, r := range out[q] {
|
||
t.Logf(" id=%s cat=%s content=%s", r.ID, r.Category, trunc(r.Content, 60))
|
||
}
|
||
} else {
|
||
t.Logf("[NO-INJECT] %q → 0 pattern (maxcos=%.3f, 阈值=0.6)", q, mc)
|
||
}
|
||
}
|
||
b, _ := json.MarshalIndent(out, "", " ")
|
||
t.Logf("SHADOW_PATTERN_TOTAL_INJECTED=%d", totalInjected)
|
||
os.WriteFile("/tmp/shadow-pattern-inject.json", b, 0644)
|
||
t.Logf("raw json → /tmp/shadow-pattern-inject.json (%d bytes)", len(b))
|
||
}
|
||
|
||
func trunc(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "…"
|
||
}
|
||
|
||
// ── peer 侧:生产 graph.db 无 peers 表 ⇒ 开态无数据可注入 ──
|
||
func TestShadowPeer_NoTable(t *testing.T) {
|
||
db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db")
|
||
if err != nil {
|
||
t.Fatalf("open graph.db: %v", err)
|
||
}
|
||
defer db.Close()
|
||
var cnt int
|
||
err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='peers'").Scan(&cnt)
|
||
if err != nil {
|
||
t.Fatalf("query peers table existence: %v", err)
|
||
}
|
||
t.Logf("SHADOW_PEER_TABLE_EXISTS=%d (0 ⇒ 开态 peer 无数据可注入)", cnt)
|
||
}
|
||
|
||
// peerOpenReadOnly 只读开 sqlite(WAL 兼容),避免 peer.Store 的写路径。
|
||
func peerOpenReadOnly(path string) (*sql.DB, error) {
|
||
return sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
|
||
}
|
||
|
||
// ── 深度根因:即使修好 NULL-id,85 行 pattern_stat 无 data/tools 键(只有 description),
|
||
// Match 只能 embed 命名键(如 "pattern:hermes-main:entity")⇒ 对金标余弦 < 0.6 阈值。
|
||
// 本测试量化「修好 NULL-id 后开态到底能注入几类金标」—— 证明开态收益仍≈0。
|
||
func TestShadowPattern_CosineIfIDFixed(t *testing.T) {
|
||
endpoint := os.Getenv("VLLM_ENDPOINT")
|
||
if endpoint == "" {
|
||
endpoint = "http://localhost:8000/v1/embeddings"
|
||
}
|
||
emb := storage.NewEmbedder(endpoint)
|
||
// 绕过 NULL-id 的 ListPatterns:只读 name + properties(非 NULL 列)
|
||
db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db")
|
||
if err != nil {
|
||
t.Fatalf("open: %v", err)
|
||
}
|
||
defer db.Close()
|
||
rows, _ := db.Query(`SELECT name, properties FROM graph_nodes WHERE type='pattern'`)
|
||
type patText struct{ name, text string }
|
||
var pats []patText
|
||
for rows.Next() {
|
||
var name, props string
|
||
if serr := rows.Scan(&name, &props); serr != nil {
|
||
continue
|
||
}
|
||
// 复现 Match 的 text 构造:rep=data(无→空) 回退 name;无 tools
|
||
text := name
|
||
pats = append(pats, patText{name, text})
|
||
}
|
||
rows.Close()
|
||
t.Logf("PATTERN_ROWS_LOADABLE_BYPASS_ID=%d", len(pats))
|
||
if len(pats) == 0 {
|
||
t.Skip("无 pattern 行可载(NULL-id 未修前本就 0 注入,已在 TestShadowPattern_Diff 覆盖)")
|
||
}
|
||
golden := []string{"织忆是什么", "牧尘的偏好是什么", "如何使用 recall 接口",
|
||
"本地 llama 服务 8080 端口", "织忆项目有哪些核心组件"}
|
||
for _, q := range golden {
|
||
qv, qerr := emb.EncodeSingle(q)
|
||
if qerr != nil || len(qv) == 0 {
|
||
t.Logf("[EMBFAIL] %q → %v", q, qerr)
|
||
continue
|
||
}
|
||
best, bestName := -1.0, ""
|
||
for _, p := range pats {
|
||
pv, perr := emb.EncodeSingle(p.text)
|
||
if perr != nil || len(pv) == 0 {
|
||
continue
|
||
}
|
||
s := cosineF32(qv, pv)
|
||
if s > best {
|
||
best, bestName = s, p.name
|
||
}
|
||
}
|
||
hit := best >= 0.6
|
||
t.Logf("COSINE_PROBE %q maxcos=%.4f hit=%v (thr=0.6) best=%s", q, best, hit, bestName)
|
||
}
|
||
}
|
||
|
||
// 防 unused 误报
|
||
var _ = models.RecallResult{} |