diff --git a/go/internal/api/routes/standing_answers.go b/go/internal/api/routes/standing_answers.go index 67ab093..534a1b6 100644 --- a/go/internal/api/routes/standing_answers.go +++ b/go/internal/api/routes/standing_answers.go @@ -16,7 +16,7 @@ func standingStore() *storage.StandingStore { return storage.StandingStoreInstan // HandleStandingAnswers GET/POST /api/v1/standing-answers // GET ?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 diff --git a/go/internal/storage/recall.go b/go/internal/storage/recall.go index 8aef9a9..8495871 100644 --- a/go/internal/storage/recall.go +++ b/go/internal/storage/recall.go @@ -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 } diff --git a/go/internal/storage/standing_answer.go b/go/internal/storage/standing_answer.go index 6a247a7..5781911 100644 --- a/go/internal/storage/standing_answer.go +++ b/go/internal/storage/standing_answer.go @@ -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) diff --git a/go/internal/storage/standing_answer_test.go b/go/internal/storage/standing_answer_test.go index 540e468..4241b59 100644 --- a/go/internal/storage/standing_answer_test.go +++ b/go/internal/storage/standing_answer_test.go @@ -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 { diff --git a/go/internal/storage/standing_match.go b/go/internal/storage/standing_match.go index 47ba609..dff2f3d 100644 --- a/go/internal/storage/standing_match.go +++ b/go/internal/storage/standing_match.go @@ -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 + } + } } } // 近似但不等:只记日志(便于补变体),绝不返回——错答比不答更贵。 diff --git a/go/internal/storage/standing_match_test.go b/go/internal/storage/standing_match_test.go index a6ea6ae..e531a27 100644 --- a/go/internal/storage/standing_match_test.go +++ b/go/internal/storage/standing_match_test.go @@ -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) } } diff --git a/scripts/deploy-approved-commits.txt b/scripts/deploy-approved-commits.txt index 5f5710a..6e0151d 100644 --- a/scripts/deploy-approved-commits.txt +++ b/scripts/deploy-approved-commits.txt @@ -14,3 +14,6 @@ a2be088cba59c95901b22d855dea9b6df3bf3a80 4341fac4c28a4f2825f7373ea0ec94d07c6452f0 b101b464471f6283c438f089e9ec269c8f2b1644 01d8f34 +a8e15c9ceee7978272d57e9dc7bf8f29c1b7970c +6952bdb2138bb7b14479f64553fb911b24e9dda4 +ebe6384b733e52a910cd72aaf7afdb94128c72a1