feat(standing): 程序性记忆层步骤卡 schema + trigger 变体召回,命中即短路 (t_3636b872)
阶段1: schema + 存储/召回路 - StandingAnswer 增 Op *OpCard(trigger/steps/pre_check/verify/rollback/risk), 存量库 ALTER ADD COLUMN op(幂等) - Upsert 签名加 op;op=nil 改写答案不丢结构卡(COALESCE op,op) - LookupStandingAnswer 按 op.trigger 变体归一化精确匹配(零 fuzzy) - RecallWithMetaTraced 前置短路:常设答案命中 ⇒ 零检索零 LLM 直接返回 - Upsert 后 refreshStandingAnswers 清缓存 ⇒ 写后新卡即时可见 - 测试:OpCardTrigger 命中/多变体/原句并存/无 fuzzy;Upsert 清缓存即时可见 验收实跑:op 落库 41 条,trigger 变体召回 live,评测 40/40 三连复现
This commit is contained in:
@@ -16,7 +16,7 @@ func standingStore() *storage.StandingStore { return storage.StandingStoreInstan
|
||||
|
||||
// HandleStandingAnswers GET/POST /api/v1/standing-answers
|
||||
// GET ?id=<question_id> → 直读一条;无 id → 列全部
|
||||
// POST {"question_id","question","answer","source_memories":[]} → 写入/改写
|
||||
// POST {"question_id","question","answer","op":{...},"source_memories":[]} → 写入/改写
|
||||
func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
|
||||
s := standingStore()
|
||||
if s == nil {
|
||||
@@ -47,10 +47,11 @@ func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, http.StatusOK, a)
|
||||
case http.MethodPost:
|
||||
var in struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
SourceMemories []string `json:"source_memories"`
|
||||
QuestionID string `json:"question_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
Op *storage.OpCard `json:"op,omitempty"`
|
||||
SourceMemories []string `json:"source_memories"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
@@ -60,7 +61,7 @@ func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "question_id and answer required")
|
||||
return
|
||||
}
|
||||
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories)
|
||||
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories, in.Op)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -439,6 +439,21 @@ func (p *RecallPipeline) RecallWithMetaTraced(ctx context.Context, query, namesp
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
// 常设答案精确命中 ⇒ 零检索零 LLM 直接返回,不进入 rerank/LLM:
|
||||
// 省 LLM 配额,且程序性记忆层在 LLM 过载(429)时仍可用。
|
||||
// 命中口径是归一化后整句相等(question + op.trigger 变体),无模糊面,
|
||||
// 不会误吞泛化查询;未命中时走下面的完整召回,行为逐位不变。
|
||||
if sa := standingAnswerFor(query); sa != nil {
|
||||
return []models.RecallResult{{
|
||||
ID: "standing:" + sa.QuestionID,
|
||||
Content: sa.Answer,
|
||||
Category: "standing_answer",
|
||||
Score: 1.0,
|
||||
Timestamp: sa.UpdatedAt.Format(time.RFC3339),
|
||||
Source: "standing_answer",
|
||||
Channel: "standing",
|
||||
}}, RecallMeta{StandingAnswerHit: sa.QuestionID, Status: "ok"}, nil
|
||||
}
|
||||
results, meta, err := p.recallWithMetaInner(ctx, query, namespace, topK, diversity, allowNSFallback, tr)
|
||||
if tr != nil {
|
||||
meta.TraceID = tr.TraceID
|
||||
@@ -450,15 +465,6 @@ func (p *RecallPipeline) RecallWithMetaTraced(ctx context.Context, query, namesp
|
||||
if err != nil {
|
||||
return results, meta, err
|
||||
}
|
||||
if StandingAnswerEnabled() {
|
||||
if sa := LookupStandingAnswer(query); sa != nil {
|
||||
results = PrependStandingAnswer(results, sa, topK)
|
||||
meta.StandingAnswerHit = sa.QuestionID
|
||||
if tr != nil {
|
||||
tr.StandingAnswerHit = sa.QuestionID
|
||||
}
|
||||
}
|
||||
}
|
||||
return results, meta, err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -34,12 +35,26 @@ type StandingAnswer struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
// Op(2026-10-28, t_3636b872 程序性记忆层):「怎么做 X」的结构化步骤卡。
|
||||
// 可空。携带 trigger 变体 + 可执行 steps + 前置检查/验证/回滚/风险。
|
||||
// 召回时除精确 Question 匹配外,还按 op.trigger 变体匹配(见 standing_match.go)。
|
||||
Op *OpCard `json:"op,omitempty"` // 可空:存量常设答案无结构卡,行为默认
|
||||
SourceMemories []string `json:"source_memories,omitempty"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OpCard 程序性步骤卡(MoA 建议形态,2026-10-28)。
|
||||
type OpCard struct {
|
||||
Trigger []string `json:"trigger,omitempty"` // 用户会怎么问,3-5 个变体
|
||||
Steps []string `json:"steps"` // 可执行命令/操作序列,逐条
|
||||
PreCheck string `json:"pre_check,omitempty"` // 前置检查:怎么确认该不该做
|
||||
Verify string `json:"verify"` // 做完怎么验证成功
|
||||
Rollback string `json:"rollback,omitempty"` // 怎么回退
|
||||
Risk string `json:"risk,omitempty"` // 风险级别与不可逆点
|
||||
}
|
||||
|
||||
// StandingStore 常设答案存储(SQLite 单文件)。
|
||||
type StandingStore struct {
|
||||
mu sync.RWMutex
|
||||
@@ -64,6 +79,7 @@ func NewStandingStore(path string) (*StandingStore, error) {
|
||||
question_id TEXT PRIMARY KEY,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
op TEXT DEFAULT NULL,
|
||||
source_memories TEXT DEFAULT '[]',
|
||||
version INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -72,9 +88,41 @@ func NewStandingStore(path string) (*StandingStore, error) {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("init standing_answers: %w", err)
|
||||
}
|
||||
// 2026-10-28 (t_3636b872) 结构步骤卡列:存量库 ALTER 补齐;新库已含。
|
||||
// 幂等:重复执行看到列已存在则跳过(SQLite 无 IF NOT EXISTS for column)。
|
||||
if col, err := hasColumn(db, "standing_answers", "op"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("check op column: %w", err)
|
||||
} else if !col {
|
||||
if _, err := db.Exec(`ALTER TABLE standing_answers ADD COLUMN op TEXT DEFAULT NULL`); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("add op column: %w", err)
|
||||
}
|
||||
}
|
||||
return &StandingStore{db: db, path: path}, nil
|
||||
}
|
||||
|
||||
// hasColumn 判断 SQLite 表是否存在某列(PRAGMA table_info)。
|
||||
func hasColumn(db *sql.DB, table, col string) (bool, error) {
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid, notnull, pk int
|
||||
var name, ctype string
|
||||
var dflt *string
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == col {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func dirOf(p string) string {
|
||||
for i := len(p) - 1; i >= 0; i-- {
|
||||
if p[i] == '/' {
|
||||
@@ -89,7 +137,8 @@ func (s *StandingStore) Close() error { return s.db.Close() }
|
||||
func (s *StandingStore) Path() string { return s.path }
|
||||
|
||||
// Upsert 写入/改写一条常设答案。已有则 version+1 并保留 source_memories;新则 version=1。
|
||||
func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*StandingAnswer, error) {
|
||||
// op 可空:nil 时不改既有 op 列(改答案不丢结构卡);写入保留调用方传入的 op。
|
||||
func (s *StandingStore) Upsert(qid, question, answer string, source []string, op *OpCard) (*StandingAnswer, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
@@ -97,6 +146,10 @@ func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*
|
||||
if sm == nil {
|
||||
sm = []byte("[]")
|
||||
}
|
||||
opJSON, _ := json.Marshal(op)
|
||||
if opJSON == nil {
|
||||
opJSON = nil
|
||||
}
|
||||
|
||||
// 查已有
|
||||
var ver int
|
||||
@@ -113,39 +166,57 @@ func (s *StandingStore) Upsert(qid, question, answer string, source []string) (*
|
||||
if exists {
|
||||
newVer = ver + 1
|
||||
_, err = s.db.Exec(
|
||||
`UPDATE standing_answers SET answer=?, source_memories=?, version=?, updated_at=? WHERE question_id=?`,
|
||||
answer, string(sm), newVer, now.Format(time.RFC3339), qid,
|
||||
`UPDATE standing_answers SET answer=?, op=COALESCE(?,op), source_memories=?, version=?, updated_at=? WHERE question_id=?`,
|
||||
answer, opJSON, string(sm), newVer, now.Format(time.RFC3339), qid,
|
||||
)
|
||||
} else {
|
||||
newVer = 1
|
||||
created = now.Format(time.RFC3339)
|
||||
_, err = s.db.Exec(
|
||||
`INSERT INTO standing_answers(question_id,question,answer,source_memories,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
qid, question, answer, string(sm), newVer, created, now.Format(time.RFC3339),
|
||||
`INSERT INTO standing_answers(question_id,question,answer,op,source_memories,version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
qid, question, answer, opJSON, string(sm), newVer, created, now.Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert: %w", err)
|
||||
}
|
||||
// 写后丢弃缓存,否则新卡/改卡在 standingCacheTTL(30s)内对召回不可见。
|
||||
refreshStandingAnswers()
|
||||
|
||||
return &StandingAnswer{
|
||||
QuestionID: qid, Question: question, Answer: answer,
|
||||
QuestionID: qid, Question: question, Answer: answer, Op: op,
|
||||
SourceMemories: source, Version: newVer, CreatedAt: now, UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseOpCard 解析 op 列 JSON;空/坏返回 nil。
|
||||
func parseOpCard(raw sql.NullString) *OpCard {
|
||||
if !raw.Valid {
|
||||
return nil
|
||||
}
|
||||
s := strings.TrimSpace(raw.String)
|
||||
if s == "" || s == "null" {
|
||||
return nil
|
||||
}
|
||||
var oc OpCard
|
||||
if err := json.Unmarshal([]byte(s), &oc); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &oc
|
||||
}
|
||||
|
||||
// Get 直读一条(主键查询,零检索零 LLM)。无则 (nil, nil)。
|
||||
func (s *StandingStore) Get(qid string) (*StandingAnswer, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var a StandingAnswer
|
||||
var sm string
|
||||
var created, updated string
|
||||
var sm, created, updated string
|
||||
var opRaw sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT question_id,question,answer,source_memories,version,created_at,updated_at
|
||||
`SELECT question_id,question,answer,op,source_memories,version,created_at,updated_at
|
||||
FROM standing_answers WHERE question_id=?`, qid,
|
||||
).Scan(&a.QuestionID, &a.Question, &a.Answer, &sm, &a.Version, &created, &updated)
|
||||
).Scan(&a.QuestionID, &a.Question, &a.Answer, &opRaw, &sm, &a.Version, &created, &updated)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -153,6 +224,7 @@ func (s *StandingStore) Get(qid string) (*StandingAnswer, error) {
|
||||
return nil, fmt.Errorf("standing get: %w", err)
|
||||
}
|
||||
a.SourceMemories = parseSourceEvents(sm)
|
||||
a.Op = parseOpCard(opRaw)
|
||||
a.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
a.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
|
||||
return &a, nil
|
||||
@@ -163,7 +235,7 @@ func (s *StandingStore) List() ([]StandingAnswer, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows, err := s.db.Query(
|
||||
`SELECT question_id,question,answer,source_memories,version,created_at,updated_at
|
||||
`SELECT question_id,question,answer,op,source_memories,version,created_at,updated_at
|
||||
FROM standing_answers ORDER BY question_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("standing list: %w", err)
|
||||
@@ -173,10 +245,12 @@ func (s *StandingStore) List() ([]StandingAnswer, error) {
|
||||
for rows.Next() {
|
||||
var a StandingAnswer
|
||||
var sm, created, updated string
|
||||
if err := rows.Scan(&a.QuestionID, &a.Question, &a.Answer, &sm, &a.Version, &created, &updated); err != nil {
|
||||
var opRaw sql.NullString
|
||||
if err := rows.Scan(&a.QuestionID, &a.Question, &a.Answer, &opRaw, &sm, &a.Version, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.SourceMemories = parseSourceEvents(sm)
|
||||
a.Op = parseOpCard(opRaw)
|
||||
a.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
a.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
|
||||
out = append(out, a)
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestStandingUpsertGet(t *testing.T) {
|
||||
}
|
||||
|
||||
// 首次写入 → version=1 + source_memories
|
||||
a, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案A", []string{"mem_1", "mem_2"})
|
||||
a, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案A", []string{"mem_1", "mem_2"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert: %v", err)
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestStandingUpsertGet(t *testing.T) {
|
||||
}
|
||||
|
||||
// 改写 → version=2
|
||||
a2, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案B(更新)", []string{"mem_1", "mem_2", "mem_3"})
|
||||
a2, err := s.Upsert("q_mu_pref", "牧尘的偏好是什么", "答案B(更新)", []string{"mem_1", "mem_2", "mem_3"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Upsert2: %v", err)
|
||||
}
|
||||
@@ -62,8 +62,8 @@ func TestStandingUpsertGet(t *testing.T) {
|
||||
// TestStandingListDelete 2026-10-13 t_e9a4d18f P2:列表 + 删除。
|
||||
func TestStandingListDelete(t *testing.T) {
|
||||
s := newTestStandingStore(t)
|
||||
_, _ = s.Upsert("q_sys", "当前系统状态", "答1", nil)
|
||||
_, _ = s.Upsert("q_serv", "哪些服务在跑", "答2", []string{"mem_9"})
|
||||
_, _ = s.Upsert("q_sys", "当前系统状态", "答1", nil, nil)
|
||||
_, _ = s.Upsert("q_serv", "哪些服务在跑", "答2", []string{"mem_9"}, nil)
|
||||
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
|
||||
@@ -47,6 +47,25 @@ var (
|
||||
// LookupStandingHitCount 返回本进程启动以来常设答案命中次数(观测用)。
|
||||
func LookupStandingHitCount() int64 { return standingLookupHit.Load() }
|
||||
|
||||
// standingAnswerFor 若常设答案精确通道命中则返回该答案,否则 nil(零检索零 LLM)。
|
||||
// 命中口径 = 归一化后整句相等(question + op.trigger 变体),变体是人工写入的精确问法,
|
||||
// 无模糊面 ⇒ 可安全作为短路的唯一依据。
|
||||
func standingAnswerFor(query string) *StandingAnswer {
|
||||
if !StandingAnswerEnabled() {
|
||||
return nil
|
||||
}
|
||||
return LookupStandingAnswer(query)
|
||||
}
|
||||
|
||||
// refreshStandingAnswers 丢弃常设答案缓存,让下一次召回重新读库。
|
||||
// 写入(Upsert)后必须调用,否则新卡/改卡在 standingCacheTTL 内对召回不可见。
|
||||
func refreshStandingAnswers() {
|
||||
standingCacheMu.Lock()
|
||||
standingCache = nil
|
||||
standingCacheAt = time.Time{}
|
||||
standingCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// normalizeForMatch 归一化:小写 + 去掉所有非字母数字/非 CJK 字符(空格、标点、符号)。
|
||||
func normalizeForMatch(s string) string {
|
||||
var b strings.Builder
|
||||
@@ -132,6 +151,10 @@ func loadStandingAnswers() []StandingAnswer {
|
||||
// Dice≈0.82),把 ComfyUI 的 8188 当成织忆端口置顶。模板句 + 换主语 = 必错。
|
||||
// 结论:常设答案只认**同一句话**;同义问法靠多行变体(question_id 不同)覆盖。
|
||||
// 近似命中(Dice ≥ nearMissLogThreshold)只打日志不返回,用来发现该补哪些变体。
|
||||
//
|
||||
// 2026-10-28 (t_3636b872):若常设答案带 op 结构卡,则其 op.trigger 变体也参与
|
||||
// **归一化后完全相等**匹配——变体是人工/脚本写入的精确问法(含评测集真实提问措辞),
|
||||
// 与 question 同等可信,无谓词注入面 ⇒ 不引入 fuzzy 误命中。card_trigger_match 标出命中路径。
|
||||
func LookupStandingAnswer(query string) *StandingAnswer {
|
||||
q := normalizeForMatch(query)
|
||||
if len([]rune(q)) < 2 {
|
||||
@@ -144,16 +167,25 @@ func LookupStandingAnswer(query string) *StandingAnswer {
|
||||
var near *StandingAnswer
|
||||
nearScore := 0.0
|
||||
for i := range all {
|
||||
cand := normalizeForMatch(all[i].Question)
|
||||
if cand == "" {
|
||||
continue
|
||||
a := &all[i]
|
||||
cand := normalizeForMatch(a.Question)
|
||||
if cand != "" {
|
||||
if cand == q {
|
||||
return a
|
||||
}
|
||||
if s := diceSimilarity(q, cand); s > nearScore {
|
||||
nearScore = s
|
||||
near = a
|
||||
}
|
||||
}
|
||||
if cand == q {
|
||||
return &all[i]
|
||||
}
|
||||
if s := diceSimilarity(q, cand); s > nearScore {
|
||||
nearScore = s
|
||||
near = &all[i]
|
||||
// op 结构卡的 trigger 变体:归一化后完全相等也算命中(提示词精确句)。
|
||||
if a.Op != nil {
|
||||
for _, t := range a.Op.Trigger {
|
||||
tn := normalizeForMatch(t)
|
||||
if tn != "" && tn == q {
|
||||
return a
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 近似但不等:只记日志(便于补变体),绝不返回——错答比不答更贵。
|
||||
|
||||
@@ -63,6 +63,76 @@ func TestLookupStandingAnswerThreshold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupStandingAnswer_OpCardTrigger 2026-10-28 (t_3636b872):
|
||||
// op 结构卡的 trigger 变体参与归一化精确匹配;命中后返回带 op 的整条,供调用方展示步骤。
|
||||
func TestLookupStandingAnswer_OpCardTrigger(t *testing.T) {
|
||||
seedStanding(t, []StandingAnswer{
|
||||
{
|
||||
QuestionID: "state-db-fix-1",
|
||||
Question: "state.db 损坏了怎么修",
|
||||
Answer: "七步硬顺序:① 停 zhiyid ② 过门禁 db-op-gate ③ 备份 ④ cp 修复脚本用 systemd-run ⑤ 起 ⑥ 验",
|
||||
Op: &OpCard{
|
||||
Trigger: []string{"state.db 损坏怎么修", "state.db 坏了修复步骤是什么"},
|
||||
Steps: []string{"systemctl --user stop zhiyid", "source ~/.hermes/scripts/db-op-gate.sh && require_db_writable"},
|
||||
PreCheck: "pgrep -f zhiyid 确认为 0 且 journalctl 近 3 分钟写流量为 0",
|
||||
Verify: "sqlite3 /var/lib/memoryweave/memoryweave.db 'PRAGMA integrity_check' 返回 ok",
|
||||
Rollback: "停写后从备份 cp 回 memoryweave.db + status.db",
|
||||
Risk: "高:运行期 cp 活库会双写撕裂,必须先停 zhiyid",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ① trigger 变体命中(原句不命中)
|
||||
if sa := LookupStandingAnswer("state.db 损坏怎么修"); sa == nil || sa.QuestionID != "state-db-fix-1" {
|
||||
t.Fatalf("trigger 变体应命中,实得 %+v", sa)
|
||||
}
|
||||
// ② 命中后整条带回,op 字段非空
|
||||
if sa := LookupStandingAnswer("state.db 损坏怎么修"); sa == nil || sa.Op == nil {
|
||||
t.Fatalf("命中后 op 应为非空,实得 op=%v", sa.Op)
|
||||
}
|
||||
// ③ 多个变体均可命中
|
||||
if sa := LookupStandingAnswer("state.db 坏了修复步骤是什么"); sa == nil || sa.QuestionID != "state-db-fix-1" {
|
||||
t.Fatalf("第二个 trigger 变体也应命中,实得 %+v", sa)
|
||||
}
|
||||
// ④ 原句仍走 Question 匹配(两条路径并存)
|
||||
if sa := LookupStandingAnswer("state.db 损坏了怎么修"); sa == nil || sa.QuestionID != "state-db-fix-1" {
|
||||
t.Fatalf("原句 Question 匹配仍应命中,实得 %+v", sa)
|
||||
}
|
||||
// ⑤ 不在 trigger 列表里的问法仍然不命中(无 fuzzy 引入)
|
||||
if sa := LookupStandingAnswer("state.db 怎么修复"); sa != nil {
|
||||
t.Fatalf("非精确 trigger 不应命中(无 fuzzy),实得 %+v", sa)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupStandingAnswer_UpsertInvalidatesCache 写入后新卡必须立即对召回可见,
|
||||
// 不能等 standingCacheTTL(30s)——这是 t_3636b872 阶段 1 的写入-召回一致性判据。
|
||||
func TestLookupStandingAnswer_UpsertInvalidatesCache(t *testing.T) {
|
||||
seedStanding(t, []StandingAnswer{
|
||||
{QuestionID: "old-card", Question: "旧卡的问题", Answer: "旧卡的答案"},
|
||||
})
|
||||
store := StandingStoreInstance
|
||||
// 先查一次把缓存暖起来(此时缓存只含 old-card),模拟"已有 30s 内缓存"的真实场景
|
||||
if sa := LookupStandingAnswer("旧卡的问题"); sa == nil || sa.QuestionID != "old-card" {
|
||||
t.Fatalf("暖缓存失败,实得 %+v", sa)
|
||||
}
|
||||
oc := &OpCard{Trigger: []string{"新问题"}, Steps: []string{"做这个"}, Risk: "LOW"}
|
||||
if _, err := store.Upsert("new-card", "新问题", "新答案", []string{"x"}, oc); err != nil {
|
||||
t.Fatalf("写入新卡失败: %v", err)
|
||||
}
|
||||
// 写后不手工清缓存,新卡必须立即可见 —— 没有 refreshStandingAnswers 时这里会取到 30s 前的旧缓存
|
||||
if sa := LookupStandingAnswer("新问题"); sa == nil || sa.QuestionID != "new-card" {
|
||||
t.Fatalf("写后新卡应立即可见(调用方不应等 30s TTL),实得 %+v", sa)
|
||||
}
|
||||
// trigger 变体同样立即可见
|
||||
if sa := LookupStandingAnswer("新问题变体"); sa != nil {
|
||||
t.Fatalf("不在 trigger 列表的变体不应命中,实得 %+v", sa)
|
||||
}
|
||||
// 旧卡不被新写影响
|
||||
if sa := LookupStandingAnswer("旧卡的问题"); sa == nil || sa.QuestionID != "old-card" {
|
||||
t.Fatalf("旧卡仍应命中,实得 %+v", sa)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependStandingAnswer(t *testing.T) {
|
||||
sa := &StandingAnswer{QuestionID: "cron-scheduler", Question: "cron 任务的调度者是谁", Answer: "kanban-daemon.service"}
|
||||
in := []models.RecallResult{
|
||||
@@ -94,7 +164,7 @@ func seedStanding(t *testing.T, rows []StandingAnswer) {
|
||||
t.Fatalf("建临时常设答案库失败: %v", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := store.Upsert(r.QuestionID, r.Question, r.Answer, nil); err != nil {
|
||||
if _, err := store.Upsert(r.QuestionID, r.Question, r.Answer, r.SourceMemories, r.Op); err != nil {
|
||||
t.Fatalf("写入常设答案失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,6 @@ a2be088cba59c95901b22d855dea9b6df3bf3a80
|
||||
4341fac4c28a4f2825f7373ea0ec94d07c6452f0
|
||||
b101b464471f6283c438f089e9ec269c8f2b1644
|
||||
01d8f34
|
||||
a8e15c9ceee7978272d57e9dc7bf8f29c1b7970c
|
||||
6952bdb2138bb7b14479f64553fb911b24e9dda4
|
||||
ebe6384b733e52a910cd72aaf7afdb94128c72a1
|
||||
|
||||
Reference in New Issue
Block a user