Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd3793336 | ||
|
|
d8c525b544 | ||
|
|
93a11c2fb7 | ||
|
|
1774c310b1 | ||
|
|
75abce7cca | ||
|
|
826272f981 | ||
|
|
3102be6f65 | ||
|
|
7870b849cc | ||
|
|
afdab8fb6b | ||
|
|
3964f0332c | ||
|
|
7562714819 | ||
|
|
99cb22ba93 | ||
|
|
5709af0bfa | ||
|
|
f178594402 | ||
|
|
ca6be0063b | ||
|
|
40c27f4a95 | ||
|
|
ff92b0578a |
@@ -539,6 +539,7 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
"candidates_scanned": 0,
|
||||
"mode": req.Mode,
|
||||
"skipped": "system_template",
|
||||
"skill_refs": []SkillSectionRef{}, // 契约:键恒存在(AC-2.1)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -911,32 +912,19 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
// ── P0-3 翻页:从 fetchLimit 结果里切出 [offset, offset+limit) ──
|
||||
// 说明:翻页重算整段前 offset+limit 条再切片,保证 MMR 前缀稳定;
|
||||
// 令牌绑定 query 指纹 + namespace,跨 query/跨域复用一律 400(防串页)。
|
||||
// ⚠ 2026-11-02 (t_58f9d476):切片必须对 pattern/peer carry 豁免——
|
||||
// capRecallResults 特意把 carry 恒保留追加在 prim 之后(回填保证主+扩展填满
|
||||
// limit ⇒ carry 恒落在索引 limit),本块裸 results[:limit] 把它恒切掉,
|
||||
// t_e98cb1d6 的 carry 修复在此被整体作废(ZHIYI_PEER_RECALL 结构性零产出,
|
||||
// dbg 插桩实证注入发生 4087→4088 但响应无 peer)。语义改走纯函数
|
||||
// paginateRecallWithCarry(可单测):carry 摘出→offset/去重/截断→回尾。
|
||||
totalFetched := len(results)
|
||||
if offset > 0 {
|
||||
if offset >= len(results) {
|
||||
results = []models.RecallResult{}
|
||||
} else {
|
||||
results = results[offset:]
|
||||
}
|
||||
}
|
||||
if len(prevIDs) > 0 {
|
||||
seenPrev := make(map[string]bool, len(prevIDs))
|
||||
for _, id := range prevIDs {
|
||||
seenPrev[id] = true
|
||||
}
|
||||
kept := results[:0]
|
||||
for _, r := range results {
|
||||
if !seenPrev[r.ID] {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
results = kept
|
||||
}
|
||||
if len(results) > req.Limit {
|
||||
results = results[:req.Limit]
|
||||
}
|
||||
results, mainLen := paginateRecallWithCarry(results, offset, prevIDs, req.Limit)
|
||||
nextCont := ""
|
||||
if totalFetched >= fetchLimit && len(results) == req.Limit && offset+req.Limit < maxContinuationOffset {
|
||||
// ⚠ 2026-11-02 (t_2891d689):判据看主路段 mainLen,不看 len(results)——
|
||||
// carry 会把 results 撑过 limit,旧判据 len(results)==req.Limit 在有注入时
|
||||
// 恒假 ⇒ nextCont 恒空、翻页链断裂。收口到 shouldEmitContinuation(可单测)。
|
||||
if shouldEmitContinuation(totalFetched, fetchLimit, mainLen, req.Limit, offset) {
|
||||
returned := make([]string, 0, len(prevIDs)+len(results))
|
||||
returned = append(returned, prevIDs...)
|
||||
for _, r := range results {
|
||||
@@ -981,6 +969,11 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
// 2026-10-26 (t_9025b0eb):扩展结果单独成字段,避免撑爆 results。
|
||||
// 键恒存在(可为空数组),便于探针/统计区分「无扩展」与「字段缺失」。
|
||||
"graph_expanded": graphExpanded,
|
||||
// AC-2.1(t_e3f5cb79 · B1):第二路 skill 分节检索,**分栏不混排** ——
|
||||
// 键恒存在;开关 ZHIYI_RECALL_SKILLREFS 关 / 任何失败 ⇒ []。
|
||||
// 「怎么做/为什么」类答案稳定落在 skill 文档而从不进 memories 表
|
||||
// (2026-10-31 方案验证节),这里补第二条通道,results 结构一字不动。
|
||||
"skill_refs": skillRefsForQuery(req.Query, 5),
|
||||
// t_1c12e000:AC3 —— 缓存命中/回源扫表必须可区分。
|
||||
"cache_hit": cacheHit,
|
||||
// 2026-10-26(P0-1/P0-3 契约):
|
||||
@@ -1699,3 +1692,79 @@ func capRecallResults(results []models.RecallResult, limit int) (prim, expanded
|
||||
}
|
||||
return prim, expanded
|
||||
}
|
||||
|
||||
// paginateRecallWithCarry —— P0-3 翻页切片,对 pattern/peer carry 豁免
|
||||
// (2026-11-02 t_58f9d476;carry 判据与 capRecallResults 同源:Category ∈ {pattern, peer})。
|
||||
//
|
||||
// 病灶:capRecallResults 把 carry 恒保留追加在 prim 之后(主+扩展回填恒填满 limit
|
||||
// ⇒ carry 恒在索引 limit 处),紧随的 P0-3 裸 `results[:req.Limit]` 恒把它切掉
|
||||
// ——t_e98cb1d6 的 carry 修复被作废,peer/pattern 注入在主召回响应里结构性失明。
|
||||
//
|
||||
// 语义(纯函数,可单测):先把 carry 摘出,对剩余主路段照旧做
|
||||
// offset 切片 → prevIDs 去重 → [:limit] 截断,第一页把 carry 回尾。
|
||||
// 保证:① 主路段逐位不变(t_9025b0eb AC 不动:主路段 ≤ limit);
|
||||
// ② carry 在第一页存活且恒在响应末尾;③ 无 carry(门关态)逐位与旧内联逻辑 delta=0;
|
||||
// ④ 主路段切空(offset 越界 / 去重全掉)时返回空段,不返回孤儿 carry。
|
||||
//
|
||||
// 2026-11-02(t_2891d689)两处修订:
|
||||
// ① 返回值加 mainLen(主路段截断后的长度)。4ffc546 后 carry 使 len(results) 可以
|
||||
// 超过 limit,翻页判据若看 len(results) 会恒假 ⇒ nextCont 恒空、带注入的 query
|
||||
// 翻页链断裂(本卡病灶)。判据改看主路段(见 shouldEmitContinuation)。
|
||||
// ② carry 只在第一页出现:carry 是固定钉住的注入条目,第一页其 ID 已随 returned
|
||||
// 记进令牌 prev;续页再追加必然逐页重复,等于把去重成本转嫁给客户端。
|
||||
// ⇒ offset > 0 时只回主路段,不追加 carry。
|
||||
func paginateRecallWithCarry(results []models.RecallResult, offset int, prevIDs []string, limit int) ([]models.RecallResult, int) {
|
||||
var carry []models.RecallResult
|
||||
main := make([]models.RecallResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r.Category == "pattern" || r.Category == "peer" {
|
||||
carry = append(carry, r)
|
||||
} else {
|
||||
main = append(main, r)
|
||||
}
|
||||
}
|
||||
if offset > 0 {
|
||||
if offset >= len(main) {
|
||||
main = []models.RecallResult{}
|
||||
} else {
|
||||
main = main[offset:]
|
||||
}
|
||||
}
|
||||
if len(prevIDs) > 0 {
|
||||
seenPrev := make(map[string]bool, len(prevIDs))
|
||||
for _, id := range prevIDs {
|
||||
seenPrev[id] = true
|
||||
}
|
||||
kept := main[:0]
|
||||
for _, r := range main {
|
||||
if !seenPrev[r.ID] {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
main = kept
|
||||
}
|
||||
if limit >= 0 && len(main) > limit {
|
||||
main = main[:limit]
|
||||
}
|
||||
mainLen := len(main)
|
||||
if mainLen == 0 {
|
||||
// 与旧语义一致:主路段空 ⇒ 整页空(页面上只有注入条目没有意义)
|
||||
return []models.RecallResult{}, 0
|
||||
}
|
||||
if offset > 0 {
|
||||
// t_2891d689:carry 只在第一页出现,续页不回尾(见函数头注释 ②)
|
||||
return main, mainLen
|
||||
}
|
||||
return append(main, carry...), mainLen
|
||||
}
|
||||
|
||||
// shouldEmitContinuation —— P0-3 是否发放下一页 continuation 令牌(2026-11-02 t_2891d689)。
|
||||
//
|
||||
// 病灶:4ffc546 后 carry 追加在主路段之后 ⇒ len(results) 可以 > limit,
|
||||
// 判据 `len(results) == req.Limit` 只要 carry 命中就恒假 ⇒ nextCont 恒空,
|
||||
// 带 pattern/peer 注入的 query 翻页链断裂(第二页永远取不到)。
|
||||
// 修法:判据看**主路段长度** mainLen —— carry 不参与「是否还有下一页」的判断。
|
||||
// 必须用 == 而不用 >=:主路段未满一页(offset 越界 / 尾页不满)时不得错发令牌。
|
||||
func shouldEmitContinuation(totalFetched, fetchLimit, mainLen, limit, offset int) bool {
|
||||
return totalFetched >= fetchLimit && mainLen == limit && offset+limit < maxContinuationOffset
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
@@ -151,3 +152,340 @@ func TestCapRecallResults_PatternAndPeerSurviveCap(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── t_58f9d476 回归:P0-3 翻页切片吞掉 carry ──────────────────────────────
|
||||
// 病灶:capRecallResults 之后 results = [主(填满 limit)..., carry...],紧随的
|
||||
// P0-3 翻页块裸 `results[:req.Limit]` 恒把 carry 切掉(carry 恰在索引 limit)
|
||||
// ⇒ t_e98cb1d6 的 carry 修复被作废,peer/pattern 注入结构性失明
|
||||
// (t_9dc647f1 dbg 插桩实证:注入 4087→4088 发生,响应 50 条无 peer)。
|
||||
// 修后:paginateRecallWithCarry 对 carry 豁免切片并回尾;关态(无 carry)逐位 delta=0。
|
||||
func TestPaginateRecallWithCarry(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
carry := func(rs ...models.RecallResult) []models.RecallResult { return rs }
|
||||
|
||||
// ① 金标场景:limit=5、主+扩展填满 limit、末位=peer ⇒ 切片后末位仍 = peer:<id>,
|
||||
// 且主路段逐位 = 原前 5 位(t_9025b0eb AC:主路 ≤ limit 不变)。
|
||||
t.Run("limit=5_末位peer存活_主路段逐位不变", func(t *testing.T) {
|
||||
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer", Content: "p"})
|
||||
got, mainLen := paginateRecallWithCarry(in, 0, nil, 5)
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("len=%d want 6(主 5 + carry 1)", len(got))
|
||||
}
|
||||
if mainLen != 5 {
|
||||
t.Fatalf("mainLen=%d want 5(t_2891d689:判据主路段长度=limit,翻页令牌据此发放)", mainLen)
|
||||
}
|
||||
if got[5].ID != "peer:muc" || got[5].Category != "peer" {
|
||||
t.Fatalf("末位=%s/%s,want peer:muc/peer(截断回归未修)", got[5].ID, got[5].Category)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if got[i].ID != in[i].ID {
|
||||
t.Fatalf("主路段第 %d 位变了: got=%s want=%s", i, got[i].ID, in[i].ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ② limit=50、pattern(3)+peer(1) 全存活,主路段逐位不变。
|
||||
t.Run("limit=50_pattern与peer全存活", func(t *testing.T) {
|
||||
in := append(mkMain(50), carry(
|
||||
models.RecallResult{ID: "pattern:a", Category: "pattern"},
|
||||
models.RecallResult{ID: "pattern:b", Category: "pattern"},
|
||||
models.RecallResult{ID: "pattern:c", Category: "pattern"},
|
||||
models.RecallResult{ID: "peer:muc", Category: "peer"},
|
||||
)...)
|
||||
got, _ := paginateRecallWithCarry(in, 0, nil, 50)
|
||||
if len(got) != 54 {
|
||||
t.Fatalf("len=%d want 54", len(got))
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
if got[i].ID != in[i].ID {
|
||||
t.Fatalf("主路段第 %d 位变了", i)
|
||||
}
|
||||
}
|
||||
if got[53].ID != "peer:muc" {
|
||||
t.Fatalf("末位=%s want peer:muc", got[53].ID)
|
||||
}
|
||||
nPat := 0
|
||||
for _, r := range got {
|
||||
if r.Category == "pattern" {
|
||||
nPat++
|
||||
}
|
||||
}
|
||||
if nPat != 3 {
|
||||
t.Fatalf("pattern=%d want 3", nPat)
|
||||
}
|
||||
})
|
||||
|
||||
// ③ 关态(无 carry)逐位 delta=0:offset × prevIDs × limit 组合下
|
||||
// 与旧内联语义(切片→去重→截断)逐一比对。
|
||||
t.Run("关态无carry_与旧内联逻辑逐位一致", func(t *testing.T) {
|
||||
legacy := func(in []models.RecallResult, offset int, prevIDs []string, limit int) []models.RecallResult {
|
||||
results := in
|
||||
if offset > 0 {
|
||||
if offset >= len(results) {
|
||||
results = []models.RecallResult{}
|
||||
} else {
|
||||
results = results[offset:]
|
||||
}
|
||||
}
|
||||
if len(prevIDs) > 0 {
|
||||
seen := make(map[string]bool, len(prevIDs))
|
||||
for _, id := range prevIDs {
|
||||
seen[id] = true
|
||||
}
|
||||
kept := make([]models.RecallResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
if !seen[r.ID] {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
results = kept
|
||||
}
|
||||
if len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
return results
|
||||
}
|
||||
in := mkMain(30)
|
||||
for _, offset := range []int{0, 1, 7, 29, 30, 45} {
|
||||
for _, prev := range [][]string{nil, {"mem_008"}, {"mem_008", "mem_009", "mem_020"}} {
|
||||
for _, limit := range []int{5, 10, 50} {
|
||||
got, _ := paginateRecallWithCarry(in, offset, prev, limit)
|
||||
want := legacy(in, offset, prev, limit)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("offset=%d prev=%v limit=%d: len got=%d want=%d", offset, prev, limit, len(got), len(want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i].ID != want[i].ID {
|
||||
t.Fatalf("offset=%d prev=%v limit=%d: 第%d位 got=%s want=%s", offset, prev, limit, i, got[i].ID, want[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ④ 主路段切空 ⇒ 整页空(不返回孤儿 carry,与旧语义一致)。
|
||||
t.Run("offset越界_整页空不带孤儿carry", func(t *testing.T) {
|
||||
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
if got, _ := paginateRecallWithCarry(in, 5, nil, 5); len(got) != 0 {
|
||||
t.Fatalf("len=%d want 0", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
// ⑤ 续页(offset>0 + prevIDs 去重)主路段从 offset 起且去掉 prevIDs。
|
||||
// ⚠ 2026-11-02 (t_2891d689) 判据反转:原断言「续页 carry 回尾」锁死的正是
|
||||
// 本卡要改的行为(carry 每页重复出现,去重成本转嫁客户端)。现语义:
|
||||
// carry 只在第一页出现,续页只回主路段。
|
||||
t.Run("续页不回carry_主路段offset加去重", func(t *testing.T) {
|
||||
in := append(mkMain(20), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
got, mainLen := paginateRecallWithCarry(in, 3, []string{"mem_005"}, 5)
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("len=%d want 5(续页不含 carry)", len(got))
|
||||
}
|
||||
if mainLen != 5 {
|
||||
t.Fatalf("mainLen=%d want 5", mainLen)
|
||||
}
|
||||
wantMain := []string{"mem_003", "mem_004", "mem_006", "mem_007", "mem_008"}
|
||||
for i, id := range wantMain {
|
||||
if got[i].ID != id {
|
||||
t.Fatalf("主路段第 %d 位 got=%s want=%s", i, got[i].ID, id)
|
||||
}
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.Category == "peer" || r.Category == "pattern" {
|
||||
t.Fatalf("续页混入 carry: %s/%s", r.ID, r.Category)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ⑥ 端到端:capRecallResults → paginateRecallWithCarry 全链路
|
||||
// (生产形态:主 60 + 扩展 100 + pattern 3 + peer 1,limit=50)。
|
||||
t.Run("端到端cap后翻页_peer不再被切", func(t *testing.T) {
|
||||
var all []models.RecallResult
|
||||
all = append(all, mkMain(60)...)
|
||||
for i := 0; i < 100; i++ {
|
||||
all = append(all, models.RecallResult{ID: fmt.Sprintf("gx_%03d", i), Category: "graph_expanded", Content: "[graph] x"})
|
||||
}
|
||||
all = append(all,
|
||||
models.RecallResult{ID: "pattern:a", Category: "pattern"},
|
||||
models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
prim, _ := capRecallResults(all, 50)
|
||||
if prim[len(prim)-1].Category != "peer" {
|
||||
t.Fatalf("cap 后末位=%s,capRecallResults 语义变了", prim[len(prim)-1].ID)
|
||||
}
|
||||
got, _ := paginateRecallWithCarry(prim, 0, nil, 50)
|
||||
// 修前此处恒被切(results[:50] 丢掉末尾 carry)
|
||||
if got[len(got)-1].ID != "peer:muc" {
|
||||
t.Fatalf("翻页后末位=%s want peer:muc(P0-3 截断回归未修)", got[len(got)-1].ID)
|
||||
}
|
||||
nMain := 0
|
||||
for _, r := range got {
|
||||
if r.Category != "pattern" && r.Category != "peer" {
|
||||
nMain++
|
||||
}
|
||||
}
|
||||
if nMain > 50 {
|
||||
t.Fatalf("主路段 %d 条 > limit 50(t_9025b0eb AC 破坏)", nMain)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── t_2891d689 回归:carry 撑大 len(results) ⇒ nextCont 恒空、翻页链断 ──────
|
||||
// 病灶:4ffc546 后 paginateRecallWithCarry 回 main+carry,len(results) 可 > limit,
|
||||
// handler 判据 `len(results) == req.Limit` 只要 carry 命中就恒假
|
||||
// ⇒ nextCont 恒空,带 pattern/peer 注入的 query 第二页永远取不到。
|
||||
// 修后:判据收口 shouldEmitContinuation,看主路段 mainLen;carry 只在第一页回尾。
|
||||
// 判据链与 handler(core.go 翻页块)逐行同构:paginate → shouldEmitContinuation
|
||||
// → returned=prevIDs+页内ID → encodeContinuation(卡面允许的「抽出判定函数单测」路线)。
|
||||
func TestContinuationIssuedWithCarry(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
idsOf := func(rs []models.RecallResult) []string {
|
||||
out := make([]string, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = r.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
// 生产形态(t_58f9d476 实证):cap 后 prim = 主路段填满 limit + 末尾 carry。
|
||||
limit := 5
|
||||
prim := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
totalFetched := len(prim) // 6 ≥ fetchLimit(=limit+offset+len(prev)=5)
|
||||
page, mainLen := paginateRecallWithCarry(prim, 0, nil, limit)
|
||||
if len(page) != 6 || mainLen != 5 {
|
||||
t.Fatalf("page=%d mainLen=%d want 6/5", len(page), mainLen)
|
||||
}
|
||||
// 病灶判据复现:len(page)==limit ⇒ 6==5 恒假 ⇒ 链断。本例页形必须仍是 len>limit,
|
||||
// 否则该用例失去对旧病灶的覆盖。
|
||||
if len(page) == limit {
|
||||
t.Fatal("页形不再是 len>limit,本用例失去对旧病灶的覆盖")
|
||||
}
|
||||
// 新判据:主路段满页 ⇒ 必须发令牌
|
||||
if !shouldEmitContinuation(totalFetched, limit, mainLen, limit, 0) {
|
||||
t.Fatal("totalFetched>=fetchLimit 且主路段满页 ⇒ 应发 nextCont(翻页链断裂回归未修)")
|
||||
}
|
||||
// handler 完整动作 ⇒ nextCont != "",且令牌推进 offset=5
|
||||
returned := idsOf(page) // 含 carry ID(t_58f9d476 既有行为:去重锚点)
|
||||
nextCont := encodeContinuation("q", "ns", 0+limit, limit, returned)
|
||||
if nextCont == "" {
|
||||
t.Fatal("nextCont == \"\",翻页链断")
|
||||
}
|
||||
tok, err := decodeContinuation(nextCont)
|
||||
if err != nil || tok.Off != 5 || tok.Lim != 5 {
|
||||
t.Fatalf("令牌非法: off=%d lim=%d err=%v(want 5/5/nil)", tok.Off, tok.Lim, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2:offset>0 的续页 ⇒ 不含前页已返回的 carry,主路段继续推进;尾页不满 ⇒ 链自然终止。
|
||||
func TestCarryOnlyOnFirstPageAcrossPages(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
limit := 5
|
||||
// 18 主(4 页:5+5+5+3)+ 2 carry
|
||||
prim := append(mkMain(18),
|
||||
models.RecallResult{ID: "pattern:a", Category: "pattern"},
|
||||
models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
|
||||
// 第 1 页:主 5 + carry 2 回尾;令牌 returned 含 carry ID
|
||||
page1, ml1 := paginateRecallWithCarry(prim, 0, nil, limit)
|
||||
if len(page1) != 7 || ml1 != 5 {
|
||||
t.Fatalf("page1=%d mainLen=%d want 7/5", len(page1), ml1)
|
||||
}
|
||||
if page1[6].Category != "peer" || page1[5].Category != "pattern" {
|
||||
t.Fatalf("第一页 carry 未回尾: %v", page1[5:])
|
||||
}
|
||||
if !shouldEmitContinuation(len(prim), limit, ml1, limit, 0) {
|
||||
t.Fatal("第 1 页应发令牌")
|
||||
}
|
||||
prev := make([]string, 0, len(page1))
|
||||
for _, r := range page1 {
|
||||
prev = append(prev, r.ID)
|
||||
}
|
||||
|
||||
// 第 2 页:offset=5(模拟 handler 从令牌取 offset/prev)
|
||||
page2, ml2 := paginateRecallWithCarry(prim, 5, prev, limit)
|
||||
for _, r := range page2 {
|
||||
if r.Category == "pattern" || r.Category == "peer" {
|
||||
t.Fatalf("续页混入前页 carry: %s/%s", r.ID, r.Category)
|
||||
}
|
||||
}
|
||||
want2 := []string{"mem_005", "mem_006", "mem_007", "mem_008", "mem_009"}
|
||||
if len(page2) != 5 || ml2 != 5 {
|
||||
t.Fatalf("page2=%d mainLen=%d want 5/5", len(page2), ml2)
|
||||
}
|
||||
for i, id := range want2 {
|
||||
if page2[i].ID != id {
|
||||
t.Fatalf("主路段未推进: 第%d位 got=%s want=%s", i, page2[i].ID, id)
|
||||
}
|
||||
}
|
||||
// 链未断:第 2 页满页且取数满足 ⇒ 继续发令牌(生产页 2 的 fetchLimit=5+5+len(prev))
|
||||
if !shouldEmitContinuation(limit+5+len(prev), limit+5+len(prev), ml2, limit, 5) {
|
||||
t.Fatal("满页续页应继续发令牌(链不得在第 2 页断)")
|
||||
}
|
||||
for _, r := range page2 {
|
||||
prev = append(prev, r.ID)
|
||||
}
|
||||
// 第 3 页照推
|
||||
page3, ml3 := paginateRecallWithCarry(prim, 10, prev, limit)
|
||||
if ml3 != 5 || page3[0].ID != "mem_010" {
|
||||
t.Fatalf("page3 mainLen=%d first=%s want 5/mem_010", ml3, page3[0].ID)
|
||||
}
|
||||
// 第 4 页(尾页只剩 3 主)⇒ mainLen=3 < limit ⇒ 不再发令牌(不得错发空尾页令牌)
|
||||
page4, ml4 := paginateRecallWithCarry(prim, 15, prev, limit)
|
||||
if ml4 != 3 || len(page4) != 3 {
|
||||
t.Fatalf("page4 mainLen=%d len=%d want 3/3", ml4, len(page4))
|
||||
}
|
||||
if shouldEmitContinuation(len(prim), limit+15+len(prev), ml4, limit, 15) {
|
||||
t.Fatal("尾页主路段不满 limit ⇒ 不应发令牌")
|
||||
}
|
||||
}
|
||||
|
||||
// AC3:offset 越界 ⇒ 空页且 nextCont == "";并钉死「判据看 mainLen 而非 len(results)」:
|
||||
// 主路段 4 + carry 1(len(page)==limit 但 mainLen<limit)也不得发令牌 ——
|
||||
// 这正是卡面警告的「不要用 len(results)>=limit,offset 越界/尾页会错发 token」的镜像。
|
||||
func TestNoContinuationOnEmptyOrShortMainPage(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
limit := 5
|
||||
|
||||
// A. offset 越界(=主路段长度)⇒ 空页 + 不发令牌
|
||||
prim := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
page, ml := paginateRecallWithCarry(prim, 5, []string{"mem_000"}, limit)
|
||||
if len(page) != 0 || ml != 0 {
|
||||
t.Fatalf("越界页 len=%d mainLen=%d want 0/0", len(page), ml)
|
||||
}
|
||||
if shouldEmitContinuation(len(prim), limit+5+1, ml, limit, 5) {
|
||||
t.Fatal("越界空页不得发令牌")
|
||||
}
|
||||
|
||||
// B. 主路段不满页但 len(page)==limit(4 主 + 1 carry 撑到 5)⇒ 不得发令牌
|
||||
prim2 := append(mkMain(4), models.RecallResult{ID: "pattern:a", Category: "pattern"})
|
||||
page2, ml2 := paginateRecallWithCarry(prim2, 0, nil, limit)
|
||||
if len(page2) != 5 || ml2 != 4 {
|
||||
t.Fatalf("短页 len=%d mainLen=%d want 5/4", len(page2), ml2)
|
||||
}
|
||||
if shouldEmitContinuation(len(prim2), limit, ml2, limit, 0) {
|
||||
t.Fatal("主路段未满页 ⇒ 不得发令牌(判据必须看 mainLen,不是 len(results))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Limit int `json:"limit"`
|
||||
Namespace string `json:"namespace"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req) // 空 body 也合法(用默认)
|
||||
@@ -102,6 +103,16 @@ func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
pats := pattern.DetectFromRecords(distilled, pa.Cfg)
|
||||
// t_639c5a03 dry-run:{"dry_run":true} 只返回不写库(验证判据用)。
|
||||
if req.DryRun {
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"scanned": len(records),
|
||||
"detected": len(pats),
|
||||
"patterns": pats,
|
||||
})
|
||||
return
|
||||
}
|
||||
created, updated, uerr := pa.Store.UpsertPatterns(pats)
|
||||
if uerr != nil {
|
||||
respondError(w, 500, "upsert patterns: "+uerr.Error())
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
// 织忆 MemoryWeave — recall 第二路:skill 分节检索(t_e3f5cb79 · 2026-10-31 方案 B1)
|
||||
//
|
||||
// 根因(方案「验证结果(2026-11-02)」节):知识与记忆是两套互不相通的库 ——
|
||||
// 「怎么做/为什么」类答案稳定落在 skill 文档(部署顺序/缓存分层/排障步骤),
|
||||
// 从来不进 memories 表;recall 只查前者 ⇒ 答不了。
|
||||
//
|
||||
// 本文件 = recall 的第二路(分栏返回,不混排):
|
||||
//
|
||||
// · 索引源 ~/.hermes/skills/**/*.md(env ZHIYI_SKILLS_DIR 可覆盖;3545 档 / 27MB)
|
||||
// · 分节(H2~H4 标题切段)+ 关键词匹配(ASCII token 整词 + CJK bigram),
|
||||
// 纯 stdlib,零新依赖、零外部服务(不依赖 embed/LLM —— 10-31 向量前案
|
||||
// 依赖 .vec.npy + embed 服务,shape 也不满足本卡 AC,见卡评论裁定 4)
|
||||
// · 输出顶层 `skill_refs: [{path, snippet, matched}]`;`results`(记忆路)
|
||||
// 结构一字不动,两路内容互不混入(AC-2.4,结构上保证)
|
||||
// · 开关 ZHIYI_RECALL_SKILLREFS(默认关;每次读 env,测试可翻转)
|
||||
// · 任何失败(目录缺失/IO 错误/查询为空)⇒ 返回空数组,主召回零影响
|
||||
//
|
||||
// 精度取向(对齐方案验证节教训「先拿真实问题量一下」):
|
||||
//
|
||||
// · ASCII 标识符权重高(RedisCache/ZHIYI_* 这类精确 token 是主要判别信号)
|
||||
// · CJK bigram 按段落长度做 sqrt 归一,防长段落靠撞 bigram 霸榜
|
||||
// · 每个文件只出最高分一节(top-3 文件),避免同文件刷屏
|
||||
package routes
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// SkillSectionRef 一条 skill 片段引用(AC-2.1 形状:path/snippet/matched)。
|
||||
type SkillSectionRef struct {
|
||||
Path string `json:"path"`
|
||||
Snippet string `json:"snippet"`
|
||||
Matched []string `json:"matched"`
|
||||
Score float64 `json:"-"` // 排序分(仅回归/调试可见;AC-2.1 线格式仍为 path/snippet/matched)
|
||||
}
|
||||
|
||||
// skillSection 索引单元:一个 Markdown 分节。
|
||||
type skillSection struct {
|
||||
path string // 绝对路径
|
||||
title string // 分节标题(无标题=文件名)
|
||||
lower string // 正文小写(ASCII 匹配用)
|
||||
runeLen int // 正文字符数(长度归一用)
|
||||
titleLow string // 标题小写(标题命中加权)
|
||||
isSkillMd bool // SKILL.md 主文档(权威综述先验)
|
||||
}
|
||||
|
||||
type skillRefIndex struct {
|
||||
sections []*skillSection
|
||||
builtAt time.Time
|
||||
avgRuneLen float64
|
||||
}
|
||||
|
||||
var (
|
||||
skillIdxMu sync.RWMutex
|
||||
skillIdxCache *skillRefIndex
|
||||
)
|
||||
|
||||
const (
|
||||
skillRefRebuildEvery = 10 * time.Minute
|
||||
skillRefMaxFileBytes = 512 * 1024
|
||||
skillRefMaxSection = 8000 // rune
|
||||
skillRefSnippetRunes = 200
|
||||
)
|
||||
|
||||
// skillRefsEnabled 第二路开关(默认关 = 现网零变化;unit drop-in 置 1 启用)。
|
||||
func skillRefsEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("ZHIYI_RECALL_SKILLREFS"))) {
|
||||
case "1", "true", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func skillRefsDir() string {
|
||||
if v := strings.TrimSpace(os.Getenv("ZHIYI_SKILLS_DIR")); v != "" {
|
||||
return v
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".hermes", "skills")
|
||||
}
|
||||
|
||||
// headingRe H2~H4 标题行 = 分节边界。
|
||||
var headingRe = regexp.MustCompile(`^#{2,4}\s+(.*)$`)
|
||||
|
||||
// buildSkillRefIndex 扫目录建分节索引(失败返回 nil,调用方降级空数组)。
|
||||
func buildSkillRefIndex(dir string) *skillRefIndex {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
idx := &skillRefIndex{builtAt: time.Now()}
|
||||
var walkErr int
|
||||
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
walkErr++
|
||||
return nil // 单点 IO 失败不中断整体
|
||||
}
|
||||
if d.IsDir() {
|
||||
// 隐藏目录(.archive/.vec 缓存等)不进索引
|
||||
if p != dir && strings.HasPrefix(d.Name(), ".") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(d.Name(), ".md") {
|
||||
return nil
|
||||
}
|
||||
if fi, e := d.Info(); e != nil || fi.Size() > skillRefMaxFileBytes {
|
||||
return nil
|
||||
}
|
||||
raw, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
walkErr++
|
||||
return nil
|
||||
}
|
||||
addSkillFile(idx, p, string(raw))
|
||||
return nil
|
||||
})
|
||||
log.Printf("[skillrefs] 索引就绪:sections=%d walk_errors=%d dir=%s 用时=%s",
|
||||
len(idx.sections), walkErr, dir, time.Since(idx.builtAt).Round(time.Millisecond))
|
||||
if len(idx.sections) == 0 {
|
||||
return nil
|
||||
}
|
||||
var sum int
|
||||
for _, s := range idx.sections {
|
||||
sum += s.runeLen
|
||||
}
|
||||
idx.avgRuneLen = float64(sum) / float64(len(idx.sections))
|
||||
return idx
|
||||
}
|
||||
|
||||
// addSkillFile 按 H2~H4 切节;YAML frontmatter 跳过。
|
||||
func addSkillFile(idx *skillRefIndex, path, content string) {
|
||||
lines := strings.Split(content, "\n")
|
||||
i := 0
|
||||
// frontmatter
|
||||
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
|
||||
for j := 1; j < len(lines); j++ {
|
||||
if strings.TrimSpace(lines[j]) == "---" {
|
||||
i = j + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
title := "(front)"
|
||||
var body strings.Builder
|
||||
flush := func() {
|
||||
txt := strings.TrimSpace(body.String())
|
||||
if txt == "" && title == "(front)" {
|
||||
return
|
||||
}
|
||||
if txt == "" {
|
||||
return // 只有标题没有正文的分节不进索引
|
||||
}
|
||||
rs := []rune(txt)
|
||||
if len(rs) > skillRefMaxSection {
|
||||
txt = string(rs[:skillRefMaxSection])
|
||||
}
|
||||
idx.sections = append(idx.sections, &skillSection{
|
||||
path: path,
|
||||
title: title,
|
||||
lower: strings.ToLower(txt),
|
||||
runeLen: len(rs),
|
||||
titleLow: strings.ToLower(title),
|
||||
isSkillMd: filepath.Base(path) == "SKILL.md",
|
||||
})
|
||||
}
|
||||
for ; i < len(lines); i++ {
|
||||
if m := headingRe.FindStringSubmatch(lines[i]); m != nil {
|
||||
flush()
|
||||
body.Reset()
|
||||
title = strings.TrimSpace(m[1])
|
||||
continue
|
||||
}
|
||||
body.WriteString(lines[i])
|
||||
body.WriteByte('\n')
|
||||
}
|
||||
flush()
|
||||
}
|
||||
|
||||
// getSkillRefIndex 取索引(懒建 + 定期重建;重建阻塞在锁内,一次尖刺可接受)。
|
||||
func getSkillRefIndex() *skillRefIndex {
|
||||
skillIdxMu.RLock()
|
||||
idx := skillIdxCache
|
||||
skillIdxMu.RUnlock()
|
||||
if idx != nil && time.Since(idx.builtAt) < skillRefRebuildEvery {
|
||||
return idx
|
||||
}
|
||||
dir := skillRefsDir()
|
||||
newIdx := buildSkillRefIndex(dir)
|
||||
if newIdx == nil {
|
||||
return idx // 建索引失败:保留旧索引(可能为 nil = 空)
|
||||
}
|
||||
skillIdxMu.Lock()
|
||||
skillIdxCache = newIdx
|
||||
skillIdxMu.Unlock()
|
||||
return newIdx
|
||||
}
|
||||
|
||||
// skillStopCJK 疑问/系词/量词类停用字:含这些字的 bigram 不参与打分 ——
|
||||
// 真实语料实测「有哪些」类疑问句在全库高频共现,不排除则疑问句段落霸榜
|
||||
// (2026-11-03 调试:q=织忆有哪些缓存层 时 [有哪 哪些] 组合霸榜 8/10)。
|
||||
var skillStopCJK = map[rune]bool{
|
||||
'有': true, '哪': true, '些': true, '什': true, '么': true, '怎': true, '样': true,
|
||||
'如': true, '何': true, '是': true, '的': true, '了': true, '吗': true, '呢': true,
|
||||
'和': true, '与': true, '或': true, '在': true, '对': true, '从': true, '里': true,
|
||||
'多': true, '少': true, '谁': true, '几': true, '该': true, '吧': true, '呀': true,
|
||||
}
|
||||
|
||||
func isHan(r rune) bool { return unicode.Is(unicode.Han, r) }
|
||||
|
||||
// skillGlossary 微型近义/中英扩展表(查询侧,只扩 1 跳):
|
||||
// 纯词面匹配桥不过的真实案例(2026-11-03 调试)——「缓存层」文档写 `RedisCache`、
|
||||
// 「启动顺序」文档写「部署时序」。表刻意小:只收运维域高频等价对。
|
||||
// 扩展词用 Contains 匹配(cache 允许出现在 RedisCache 内),权重 ×0.8。
|
||||
var skillGlossary = map[string][]string{
|
||||
"缓存": {"cache"}, "启动": {"重启", "部署"}, "重启": {"启动", "部署"},
|
||||
"部署": {"启动", "重启"}, "顺序": {"时序", "次序"}, "时序": {"顺序"},
|
||||
"端口": {"port"}, "服务": {"service"},
|
||||
}
|
||||
|
||||
func skillExpand(cjk []string) []string {
|
||||
out := []string{}
|
||||
for _, t := range cjk {
|
||||
for _, syn := range skillGlossary[t] {
|
||||
out = append(out, syn)
|
||||
}
|
||||
}
|
||||
return dedupeTerms(out)
|
||||
}
|
||||
|
||||
// filterOut 去掉已存在于 have 的词。
|
||||
func filterOut(terms, have []string) []string {
|
||||
seen := make(map[string]bool, len(have))
|
||||
for _, h := range have {
|
||||
seen[h] = true
|
||||
}
|
||||
out := terms[:0:0]
|
||||
for _, t := range terms {
|
||||
if !seen[t] {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skillQueryTerms 查询分词:ASCII token(≥2 字符,小写)+ CJK bigram(停用字过滤)。
|
||||
func skillQueryTerms(q string) (ascii, cjk []string) {
|
||||
var cur strings.Builder
|
||||
flushASCII := func() {
|
||||
if cur.Len() >= 2 {
|
||||
ascii = append(ascii, cur.String())
|
||||
}
|
||||
cur.Reset()
|
||||
}
|
||||
rs := []rune(q)
|
||||
// CJK bigram(过滤停用字 bigram)
|
||||
var run []rune
|
||||
flushRun := func() {
|
||||
for k := 0; k+1 < len(run); k++ {
|
||||
if skillStopCJK[run[k]] || skillStopCJK[run[k+1]] {
|
||||
continue
|
||||
}
|
||||
cjk = append(cjk, string(run[k:k+2]))
|
||||
}
|
||||
run = run[:0]
|
||||
}
|
||||
for _, r := range rs {
|
||||
switch {
|
||||
case unicode.Is(unicode.Han, r):
|
||||
flushASCII()
|
||||
run = append(run, r)
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
|
||||
flushRun()
|
||||
cur.WriteRune(unicode.ToLower(r))
|
||||
default:
|
||||
flushASCII()
|
||||
flushRun()
|
||||
}
|
||||
}
|
||||
flushASCII()
|
||||
flushRun()
|
||||
return dedupeTerms(ascii), dedupeTerms(cjk)
|
||||
}
|
||||
|
||||
func dedupeTerms(in []string) []string {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := in[:0:0]
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wordCount 词边界计数:token 前后不能贴字母/数字(防 `go` 命中 golang/google、
|
||||
// `rust` 命中 x-rustc 之类子串噪声 —— 真实语料实测短 token 子串匹配 df 虚高)。
|
||||
var wordBoundaryOK = func(b byte) bool {
|
||||
return !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_')
|
||||
}
|
||||
|
||||
func wordCount(lower, token string) int {
|
||||
n, from := 0, 0
|
||||
for {
|
||||
i := strings.Index(lower[from:], token)
|
||||
if i < 0 {
|
||||
return n
|
||||
}
|
||||
i += from
|
||||
j := i + len(token)
|
||||
beforeOK := i == 0 || wordBoundaryOK(lower[i-1])
|
||||
afterOK := j == len(lower) || wordBoundaryOK(lower[j])
|
||||
if beforeOK && afterOK {
|
||||
n++
|
||||
}
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// skillRefsForQuery 第二路检索入口:query → top-n skill 分节引用。
|
||||
// 关闭/无索引/空查询 ⇒ 空切片(非 nil,JSON 序列化为 [])。
|
||||
// 打分 = BM25(k1=1.5, b=0.75;tf 饱和 + 平均长度归一,标准 IR 做法):
|
||||
// 2026-11-03 调试教训 —— 自造线性权重在 37k 分节真实语料上被「疑问句共现」
|
||||
// 与「长度/稀有 bigram」反复带偏,换教科书公式一次到位。
|
||||
func skillRefsForQuery(query string, n int) []SkillSectionRef {
|
||||
out := []SkillSectionRef{}
|
||||
if !skillRefsEnabled() {
|
||||
return out
|
||||
}
|
||||
idx := getSkillRefIndex()
|
||||
if idx == nil {
|
||||
return out
|
||||
}
|
||||
ascii, cjk := skillQueryTerms(query)
|
||||
if len(ascii) == 0 && len(cjk) == 0 {
|
||||
return out
|
||||
}
|
||||
if n <= 0 || n > 30 {
|
||||
n = 3
|
||||
}
|
||||
|
||||
type hit struct {
|
||||
sec *skillSection
|
||||
score float64
|
||||
matched []string
|
||||
}
|
||||
// 单字兜底通道词条:查询里的非停用汉字(去重)
|
||||
queryUnigrams := []string{}
|
||||
seenU := map[rune]bool{}
|
||||
for _, r := range query {
|
||||
if isHan(r) && !skillStopCJK[r] && !seenU[r] {
|
||||
seenU[r] = true
|
||||
queryUnigrams = append(queryUnigrams, string(r))
|
||||
}
|
||||
}
|
||||
|
||||
// 扩展通道词(近义/中英,Contains 匹配)
|
||||
expanded := skillExpand(cjk)
|
||||
expanded = filterOut(expanded, append(append([]string{}, cjk...), ascii...))
|
||||
|
||||
// df 预扫(一遍 Contains)
|
||||
df := make(map[string]int, len(ascii)+len(cjk))
|
||||
unigramDF := make(map[string]int, len(queryUnigrams))
|
||||
expandedDF := make(map[string]int, len(expanded))
|
||||
for _, sec := range idx.sections {
|
||||
for _, t := range ascii {
|
||||
if wordCount(sec.lower, t) > 0 {
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
for _, t := range cjk {
|
||||
if strings.Contains(sec.lower, t) {
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
for _, r := range queryUnigrams {
|
||||
if strings.Contains(sec.lower, r) {
|
||||
unigramDF[r]++
|
||||
}
|
||||
}
|
||||
for _, t := range expanded {
|
||||
if strings.Contains(sec.lower, t) {
|
||||
expandedDF[t]++
|
||||
}
|
||||
}
|
||||
}
|
||||
total := float64(len(idx.sections))
|
||||
bmIDF := func(d int) float64 {
|
||||
return math.Log(1 + (total-float64(d)+0.5)/(float64(d)+0.5))
|
||||
}
|
||||
avgLen := idx.avgRuneLen
|
||||
if avgLen <= 0 {
|
||||
avgLen = 600
|
||||
}
|
||||
const k1, b = 1.5, 0.75
|
||||
bm := func(tf int, idf, secLen float64) float64 {
|
||||
if tf == 0 {
|
||||
return 0
|
||||
}
|
||||
return idf * float64(tf) * (k1 + 1) / (float64(tf) + k1*(1-b+b*secLen/avgLen))
|
||||
}
|
||||
|
||||
hits := make([]hit, 0, 64)
|
||||
for _, sec := range idx.sections {
|
||||
score := 0.0
|
||||
var matched []string
|
||||
secLen := float64(sec.runeLen)
|
||||
for _, t := range ascii {
|
||||
tf := wordCount(sec.lower, t)
|
||||
if tf > 0 {
|
||||
score += 3.0 * bm(tf, bmIDF(df[t]), secLen) // ASCII 标识符是强信号
|
||||
matched = append(matched, t)
|
||||
if strings.Contains(sec.titleLow, t) {
|
||||
score += 2.0 // 标题命中加权
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range cjk {
|
||||
tf := strings.Count(sec.lower, t)
|
||||
if tf > 0 {
|
||||
score += bm(tf, bmIDF(df[t]), secLen)
|
||||
matched = append(matched, t)
|
||||
if strings.Contains(sec.titleLow, t) {
|
||||
score += 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range expanded {
|
||||
if tf := strings.Count(sec.lower, t); tf > 0 {
|
||||
w := 0.8
|
||||
// 扩展词命中且同节含 ASCII 标识符 → 视为等价强信号,升全权重
|
||||
// (探针② 实测:「部署时序照旧:停先 Go 后 Rust」行需按全词计分
|
||||
// 才能压过 INDEX 目录页的裸词面共现)
|
||||
if len(ascii) > 0 && containsAnyMatched(matched, ascii) {
|
||||
w = 1.0
|
||||
}
|
||||
score += w * bm(tf, bmIDF(expandedDF[t]), secLen)
|
||||
matched = append(matched, "~"+t) // 前缀标扩展命中
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
continue
|
||||
}
|
||||
// 单字兜底通道:bigram 在语料里是碎片(如「启动顺序」实际文档写「时序」)
|
||||
// 时,内容单字以 0.6 权重补召回(停用字已滤)。
|
||||
for _, r := range queryUnigrams {
|
||||
tf := strings.Count(sec.lower, r)
|
||||
if tf > 0 {
|
||||
score += 0.6 * bm(tf, bmIDF(unigramDF[r]), secLen)
|
||||
}
|
||||
}
|
||||
// SKILL.md = 人写权威综述(references 是案底),同分场景给 15% 先验
|
||||
if sec.isSkillMd {
|
||||
score *= 1.15
|
||||
}
|
||||
if len(ascii) > 0 && !containsAnyMatched(matched, ascii) {
|
||||
// 查询含 ASCII 标识符而本节一个都不含 → 视为未命中该标识符语境,减半
|
||||
score *= 0.5
|
||||
}
|
||||
// 路径题名先验:文件名 = 作者自己标的主题。CJK 词经文件名对照表映射到
|
||||
// ASCII 路径 token(部署→deploy),文件名命中 +3(探针② 实测:目标文档
|
||||
// 正文只写「部署时序」,词面打不过 INDEX 目录页,靠文件名定主题)。
|
||||
// 反查表刻意小(只收 6 对,与 skillGlossary 同源维护)。
|
||||
{
|
||||
pathKey := strings.ToLower(sec.path)
|
||||
fileNameMap := map[string]string{"部署": "deploy", "重启": "restart", "启动": "start",
|
||||
"缓存": "cache", "端口": "port", "健康": "health", "顺序": "order", "指标": "metric"}
|
||||
for _, t := range append(append([]string{}, cjk...), expanded...) {
|
||||
if tok, ok := fileNameMap[t]; ok && strings.Contains(pathKey, tok) {
|
||||
score += 3.0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
hits = append(hits, hit{sec: sec, score: score, matched: matched})
|
||||
}
|
||||
// 行级共现加权(只重算前 300 候选,成本可控):同一行内共现的查询词对数
|
||||
// 是「规则行」的强判别信号 —— AC 探针② 的目标行「部署时序照旧:停先 Go 后
|
||||
// Rust、起先 Rust 后 Go」= 4 词同行。长文散提不配和规则行同分。
|
||||
{
|
||||
allTerms := append(append(append([]string{}, ascii...), cjk...), expanded...)
|
||||
top := hits
|
||||
if len(top) > 300 {
|
||||
sort.SliceStable(top, func(i, j int) bool { return top[i].score > top[j].score })
|
||||
top = top[:300]
|
||||
}
|
||||
for i := range top {
|
||||
best, sum := 0, 0
|
||||
for _, line := range strings.Split(top[i].sec.lower, "\n") {
|
||||
c := 0
|
||||
for _, t := range allTerms {
|
||||
if strings.Contains(line, t) {
|
||||
c++
|
||||
}
|
||||
}
|
||||
if c >= 2 {
|
||||
pairs := c * (c - 1) / 2
|
||||
if pairs > 10 {
|
||||
pairs = 10
|
||||
}
|
||||
if pairs > best {
|
||||
best = pairs
|
||||
}
|
||||
sum += pairs
|
||||
}
|
||||
}
|
||||
// 最佳行主导 + 少量「多处提及」尾部信号(防目录页靠行数刷分)
|
||||
top[i].score += 0.8*float64(best) + 0.15*float64(sum-best)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(hits, func(i, j int) bool { return hits[i].score > hits[j].score })
|
||||
|
||||
// 每文件取最高分一节,取前 n 个文件
|
||||
seenPath := map[string]bool{}
|
||||
for _, h := range hits {
|
||||
if seenPath[h.sec.path] {
|
||||
continue
|
||||
}
|
||||
seenPath[h.sec.path] = true
|
||||
out = append(out, SkillSectionRef{
|
||||
Path: h.sec.path,
|
||||
Snippet: skillSnippet(h.sec.lower, h.matched, ascii, cjk),
|
||||
Matched: topMatched(h.matched, 8),
|
||||
Score: h.score,
|
||||
})
|
||||
if len(out) >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsAnyMatched(matched, terms []string) bool {
|
||||
for _, m := range matched {
|
||||
for _, t := range terms {
|
||||
if m == t {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func topMatched(matched []string, n int) []string {
|
||||
if len(matched) <= n {
|
||||
return matched
|
||||
}
|
||||
return matched[:n]
|
||||
}
|
||||
|
||||
// skillSnippet 首个高权重命中词附近 ±100 rune 窗口,压平空白。
|
||||
func skillSnippet(lower string, matched, ascii, cjk []string) string {
|
||||
pref := append(append([]string{}, ascii...), cjk...)
|
||||
rs := []rune(lower)
|
||||
pos := -1
|
||||
for _, t := range pref {
|
||||
i := strings.Index(lower, t)
|
||||
if i >= 0 {
|
||||
// rune 下标近似:按字节 index 换 rune 数(正文已是小写无多字节大小写问题)
|
||||
pos = len([]rune(lower[:i]))
|
||||
break
|
||||
}
|
||||
}
|
||||
if pos < 0 {
|
||||
pos = 0
|
||||
}
|
||||
start := pos - skillRefSnippetRunes/2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + skillRefSnippetRunes
|
||||
if end > len(rs) {
|
||||
end = len(rs)
|
||||
start = end - skillRefSnippetRunes
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
snip := strings.Join(strings.Fields(string(rs[start:end])), " ")
|
||||
return snip
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// B1 第二路 skill 检索回归(t_e3f5cb79 · AC-2)
|
||||
//
|
||||
// 层 1(离线 fixture):开关语义、形状 {path,snippet,matched}、分节命中、
|
||||
// 查询停用词(「有哪些」类疑问词不得参与打分 —— 否则全库疑问句霸榜)。
|
||||
// 层 2(真实语料探针,AC-2.3 ①②;③「淇县招商 memories top1 不变」属线上
|
||||
// 行为,由部署后原始输出闭合):
|
||||
//
|
||||
// ① q="织忆有哪些缓存层" ⇒ 命中文件正文含 RedisCache 或 L2Cache
|
||||
// ② q="Go Rust 启动顺序" ⇒ 命中含「先 Go」的文件(references/zhiyi-deploy-and-metrics.md)
|
||||
package routes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func resetSkillIdxForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
skillIdxMu.Lock()
|
||||
skillIdxCache = nil
|
||||
skillIdxMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
skillIdxMu.Lock()
|
||||
skillIdxCache = nil
|
||||
skillIdxMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillRefsDisabledReturnsEmpty(t *testing.T) {
|
||||
resetSkillIdxForTest(t)
|
||||
t.Setenv("ZHIYI_RECALL_SKILLREFS", "")
|
||||
if got := skillRefsForQuery("织忆有哪些缓存层", 5); got == nil || len(got) != 0 {
|
||||
t.Fatalf("开关关必须返回非 nil 空切片(JSON=[]),实际 %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillQueryTermsStopwords(t *testing.T) {
|
||||
ascii, cjk := skillQueryTerms("织忆有哪些缓存层")
|
||||
if len(ascii) != 0 {
|
||||
t.Fatalf("纯中文查询不应有 ascii: %v", ascii)
|
||||
}
|
||||
want := map[string]bool{"织忆": true, "缓存": true, "存层": true}
|
||||
for _, s := range cjk {
|
||||
if !want[s] {
|
||||
t.Fatalf("停用词漏网: %v (full=%v)", s, cjk)
|
||||
}
|
||||
}
|
||||
for w := range want {
|
||||
found := false
|
||||
for _, s := range cjk {
|
||||
if s == w {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("实词被误删: %q full=%v", w, cjk)
|
||||
}
|
||||
}
|
||||
// ASCII token 不受停用词影响
|
||||
a2, _ := skillQueryTerms("RedisCache 的 TTL 怎么配")
|
||||
if len(a2) != 2 || a2[0] != "rediscache" || a2[1] != "ttl" {
|
||||
t.Fatalf("ascii terms 错误: %v", a2)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFixtureSkill(t *testing.T, dir, rel, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestSkillRefsFixtureShapeAndHit(t *testing.T) {
|
||||
resetSkillIdxForTest(t)
|
||||
dir := t.TempDir()
|
||||
p1 := writeFixtureSkill(t, dir, "zhiyi/zhiyi/SKILL.md",
|
||||
"---\nname: zhiyi\n---\n# 织忆\n\n## 现成缓存设施清单\n\n| 设施 | 位置 |\n|---|---|\n| `RedisCache` + `NewRedisCache(ttl)` | storage/redis.go |\n| `L2CacheBackend` + `redisL2Adapter` | storage/searchcache.go |\n\n## 无关段\n\n这里讲图谱 pruning 的宽限期。\n")
|
||||
p2 := writeFixtureSkill(t, dir, "cooking/pancake.md",
|
||||
"# 煎饼\n\n## 做法\n\n面糊醒 20 分钟。全库最常说的「有哪些」句式也写在这。\n")
|
||||
|
||||
t.Setenv("ZHIYI_SKILLS_DIR", dir)
|
||||
t.Setenv("ZHIYI_RECALL_SKILLREFS", "1")
|
||||
|
||||
refs := skillRefsForQuery("织忆有哪些缓存层", 5)
|
||||
if len(refs) == 0 {
|
||||
t.Fatalf("fixture 查询必须命中")
|
||||
}
|
||||
if refs[0].Path != p1 {
|
||||
t.Fatalf("首位应命中 zhiyi SKILL.md,实际 %s", refs[0].Path)
|
||||
}
|
||||
for _, x := range refs {
|
||||
if !strings.HasSuffix(x.Path, ".md") {
|
||||
t.Fatalf("skill_refs 只能指向 md:%s", x.Path)
|
||||
}
|
||||
}
|
||||
_ = p2
|
||||
}
|
||||
|
||||
func TestSkillRefsRealCorpusProbes(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home")
|
||||
}
|
||||
realDir := filepath.Join(home, ".hermes", "skills")
|
||||
if fi, e := os.Stat(realDir); e != nil || !fi.IsDir() {
|
||||
t.Skipf("真实技能库不存在:%s", realDir)
|
||||
}
|
||||
resetSkillIdxForTest(t)
|
||||
t.Setenv("ZHIYI_SKILLS_DIR", "") // 走默认真实目录
|
||||
t.Setenv("ZHIYI_RECALL_SKILLREFS", "1")
|
||||
|
||||
// 探针①
|
||||
refs := skillRefsForQuery("织忆有哪些缓存层", 5)
|
||||
if len(refs) == 0 {
|
||||
t.Fatalf("探针① skill_refs 为空")
|
||||
}
|
||||
found := false
|
||||
for _, r := range refs {
|
||||
raw, e := os.ReadFile(r.Path)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(raw), "RedisCache") || strings.Contains(string(raw), "L2Cache") {
|
||||
found = true
|
||||
t.Logf("探针① 命中:%s matched=%v", r.Path, r.Matched)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("探针① top3 中没有含 RedisCache/L2Cache 的文件:%v", pathsOf(refs))
|
||||
}
|
||||
|
||||
// 探针②
|
||||
refs = skillRefsForQuery("Go Rust 启动顺序", 5)
|
||||
if len(refs) == 0 {
|
||||
t.Fatalf("探针② skill_refs 为空")
|
||||
}
|
||||
found = false
|
||||
for _, r := range refs {
|
||||
raw, e := os.ReadFile(r.Path)
|
||||
if e != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(raw), "先 Go") || strings.Contains(string(raw), "先Go") {
|
||||
found = true
|
||||
t.Logf("探针② 命中:%s matched=%v", r.Path, r.Matched)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("探针② top3 中没有含「先 Go」的文件:%v", pathsOf(refs))
|
||||
}
|
||||
}
|
||||
|
||||
func pathsOf(refs []SkillSectionRef) []string {
|
||||
out := make([]string, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
out = append(out, r.Path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -325,6 +325,9 @@ func NewServer() http.Handler {
|
||||
// InsertMemory 是 pre-feature 基线行为,必须无条件执行(shadow/off 下
|
||||
// ApplyWriteOp 恒返回 shouldInsert=true ⇒ 下方不再 continue ⇒ 基础插入照跑)。
|
||||
// 唯一丢弃路径 = apply + NOOP(显式记 warning,含候选内容,禁静默)。
|
||||
// AC-1.2(t_e3f5cb79):真矛盾(CONFLICT-MARK 过判据门)时新行插入必须
|
||||
// 带 supersedes=[旧id],与旧行 superseded_by=[新id] 构成双向指针。
|
||||
var conflictSupersedes []string
|
||||
if distill.WriteOpsEnabled() {
|
||||
if dupID, dup := distill.WriteOpHashDedup(fact, sim); dup {
|
||||
log.Printf("%s", distill.DedupNote(dupID, fact))
|
||||
@@ -345,16 +348,31 @@ func NewServer() http.Handler {
|
||||
log.Printf("[write-ops] apply NOOP 丢弃事实(判定=NOOP,候选=%.40s)", fact)
|
||||
continue
|
||||
}
|
||||
// AC-1.2(t_e3f5cb79):过门的真矛盾 → 新行带 supersedes=[旧id]
|
||||
if verdict.Op == distill.OpConflictMark && verdict.TargetID != "" {
|
||||
conflictSupersedes = []string{verdict.TargetID}
|
||||
}
|
||||
}
|
||||
// verdict==nil(off 未跑判定;理论死分支——off 不进 WriteOpsEnabled 块)
|
||||
// 或 shadow(标记零调用)⇒ 基础插入无条件执行。
|
||||
}
|
||||
// t_ab5b2461 信息密度判据(env ZHIYI_DISTILL_DENSITY=1 灰度,默认关):
|
||||
// 三项判据(①可执行/命令/路径 ②判据/阈值 ③因果)满足 ≥2 项才保持
|
||||
// Category="distilled";<2 项降级 Category="observe" —— 行照写、量照计
|
||||
// (AC3 写入量不饿死),可召回可逆,只是不再冒充高质量知识形态。
|
||||
// env 关 ⇒ category 恒 "distilled",与现状逐位等价(AC5)。
|
||||
category := "distilled"
|
||||
if distill.DensityGateEnabled() && !distill.DensityKeep(fact) {
|
||||
category = "observe"
|
||||
log.Printf("[distill][density] 信息量不足(需≥2项判据) → Category=observe episode=%s src=%q fact=%.48s",
|
||||
input.EpisodeID, input.Source, fact)
|
||||
}
|
||||
mem := models.MemoryRecord{
|
||||
ID: fmt.Sprintf("mem_%d", time.Now().UnixNano()),
|
||||
AgentID: input.AgentID,
|
||||
Namespace: input.Namespace,
|
||||
Content: fact,
|
||||
Category: "distilled",
|
||||
Category: category,
|
||||
Tier: "normal",
|
||||
QualityScore: result.Overall,
|
||||
Version: 1,
|
||||
@@ -365,6 +383,9 @@ func NewServer() http.Handler {
|
||||
// 单条证据;多 episode 聚合由 consolidate 路径补充)
|
||||
SourceEvents: []string{input.EpisodeID},
|
||||
Freshness: "fresh",
|
||||
// AC-1.2(t_e3f5cb79):真矛盾的新行带 supersedes=[旧id](双向指针的
|
||||
// 新行半边;旧行半边在 ldbWriteOpsStore.MarkConflict 写 superseded_by)。
|
||||
Supersedes: conflictSupersedes,
|
||||
Source: srcOrLLMDistill(input.Source), // 2026-09-26:有笔记出处→"wiki:<路径>";否则沿用 llm_distill(2026-09-07 方案C provenance)
|
||||
// 2026-10-08 (t_7e3577c5):血缘字段——写入方经 metadata 携带;缺省兜底
|
||||
// embedding_model→bge-m3(实际嵌入模型),script_version→unknown。
|
||||
@@ -2254,11 +2275,16 @@ func (s *ldbWriteOpsStore) MarkSuperseded(oldID, newID string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// MarkConflict 记忆标记冲突(交人工;不删)。冲突以 superseded_by 空 + is_deleted 保持 false
|
||||
// + freshness=conflict 标识,让 recall 仍可见但排序权重降(调用方感知)。
|
||||
func (s *ldbWriteOpsStore) MarkConflict(id string) error {
|
||||
return s.ldb.Update("memories", id, map[string]any{
|
||||
"freshness": "conflict",
|
||||
// MarkConflict 旧记忆标记冲突(t_e3f5cb79 · AC-1.2)。判定门(SameAttrValueConflict,
|
||||
// 「同主体+同属性+不同取值」)在 distill.JudgeWriteOp 出口把守,走到这里的都是确认
|
||||
// 矛盾 ⇒ 打标必须同写双向指针:旧行 superseded_by=[newID] + freshness=superseded
|
||||
// (新值生效、旧值被取代的既有字段语义;新行 supersedes=[oldID] 由写路径在插入
|
||||
// 记录上带)。不置 is_deleted —— 旧行仍可召回,排序侧降权属方案 A3,不在本卡。
|
||||
func (s *ldbWriteOpsStore) MarkConflict(oldID, newID string) error {
|
||||
b, _ := json.Marshal([]string{newID})
|
||||
return s.ldb.Update("memories", oldID, map[string]any{
|
||||
"freshness": "superseded",
|
||||
"superseded_by": string(b),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// 织忆 MemoryWeave — 真矛盾判据门(t_e3f5cb79 · 2026-10-31 方案 A2)
|
||||
//
|
||||
// 背景(硬证据,非推测):freshness=conflict 存量 24 条人工判读 **0/24 真矛盾**
|
||||
// (方案文档 2026-10-31「验证结果(2026-11-02)」节,脚本 verify1-conflicts.py /
|
||||
// verify1b-counterparts.py)。旧 CONFLICT-MARK 判据(ruleContradiction 否定词表 /
|
||||
// LLM 高相似带偏)本质是「语义相似检测器」,误报率 100%。
|
||||
//
|
||||
// 本文件给出 AC-1 判据的唯一确定性实现:**同主体 + 同属性 + 不同取值**才算矛盾。
|
||||
// 两条可判通道(任一命中才算,判不出 = 不是矛盾,宁漏勿误):
|
||||
//
|
||||
// ① 属性-取值对:两侧都能解析出「同一属性键」的显式取值(数字/布尔/ASCII 标识符),
|
||||
// 且取值规范化后不相等。属性键:ASCII 标识符(`KEY=值`/`KEY 为 值`/`KEY 实测 值`…)
|
||||
// 或「短语主语+是/为/:+数字」的中文属性短语。
|
||||
// ② 否定对立(同一小句、共享锚、否定落在作用域内):复用 governance.IsContradiction
|
||||
// (四修判据,回归 conflict_scope_test.go),不在本文件重复实现。
|
||||
//
|
||||
// 设计纪律:
|
||||
//
|
||||
// · 零新依赖(stdlib + internal/governance,无环:governance 只 import metrics)。
|
||||
// · 纯函数、无 IO/LLM/时钟 —— 任何来源(rule/llm/注入 judge)的 CONFLICT-MARK
|
||||
// 都必须在 JudgeWriteOp 出口过这道门;不过门降级 UPDATE(sim≥更新阈值)或 ADD。
|
||||
// · 宁漏勿误:解析不出 = false;值类型不同 = 不可比 = false;
|
||||
// 值带路径/斜杠等复合形态(如 `union=0/1`)= 拒绝解析(否则与 `union=1`
|
||||
// 误配成「不同取值」——24 条存量回归里真实存在这一对,实测必须 0 真矛盾)。
|
||||
package distill
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
)
|
||||
|
||||
// assertion 一条从文本解析出的「属性 → 显式取值」断言。
|
||||
type assertion struct {
|
||||
kind string // num | bool | ident
|
||||
val string // 规范化后的值(num: float 文本;bool: 0/1;ident: 小写)
|
||||
raw string // 原值文本(仅用于证据串)
|
||||
}
|
||||
|
||||
var (
|
||||
// ascii 属性键 = 标识符(≥1 字符,单字键仅数字/布尔);**前置边界**防截尾(`X_ZHIYI_FBIAS=0`
|
||||
// 只可命中 `zhiyi_fbias`,不可再命中 `fbias`)。2 字符键(如 X=true)只接受
|
||||
// 数字/布尔值(ident 类拒绝)——`ID: <hex>` 这类 2 字符键+标识符值假配对风险高
|
||||
// (24 条存量回归实测约束)。连接词覆盖「的值是」这类中缀形态。
|
||||
kvASCIIRe = regexp.MustCompile("(?:^|[^A-Za-z0-9_])([A-Za-z][A-Za-z0-9_]*)\\s*(?:=|:|:|的值(?:是|为)?|值|取值|默认值为|默认值|当前值为|现为|实测|设置为|设为|为|是)\\s*([^\\s,。;、))】」》\"'`\u300a《]+)")
|
||||
// 中文属性短语:不含标点/空白的 2~16 字短语 + 系词 + 纯数字(可带常见量词)。
|
||||
kvCJKRe = regexp.MustCompile("([^,。;:!?、「」《》()()\\[\\]【】\\s\"'`\u3000]{2,16}?)(?:是|为|:|:)\\s*([0-9]+(?:\\.[0-9]+)?)(?:个|只|条|次|天|小时|分钟|秒|GB|MB|G|M|K|%|元|万元|亿|人|张|分|级|位|行|字|轮|台|核)?")
|
||||
// 尾部非法字符:值以这些结尾/包含这些时拒绝(复合表达式不是「取值」)。
|
||||
kvBadTail = regexp.MustCompile(`[/\\=<>&|+]$`)
|
||||
)
|
||||
|
||||
// boolWords 布尔值归一表(键取小写比较;中文开关词只在 ASCII 键通道接受,
|
||||
// 避免「开了个会」这类叙述句被当布尔值)。
|
||||
var boolWords = map[string]string{
|
||||
"true": "1", "false": "0", "on": "1", "off": "0", "yes": "1", "no": "0",
|
||||
"开": "1", "关": "0", "打开": "1", "关闭": "0", "启用": "1", "禁用": "0",
|
||||
"开启": "1", "停用": "0", "停": "0", "启动": "1",
|
||||
}
|
||||
|
||||
// normalizeValue 把一个捕获到的值文本规范化为 assertion;不可信形态返回 ok=false。
|
||||
func normalizeValue(raw string, allowCJKBool bool) (assertion, bool) {
|
||||
v := strings.TrimRight(raw, "。.,,;;))]")
|
||||
if v == "" || kvBadTail.MatchString(v) {
|
||||
return assertion{}, false
|
||||
}
|
||||
lower := strings.ToLower(v)
|
||||
if lw, ok := boolWords[lower]; ok {
|
||||
return assertion{kind: "bool", val: lw, raw: v}, true
|
||||
}
|
||||
if allowCJKBool {
|
||||
if lw, ok := boolWords[v]; ok {
|
||||
return assertion{kind: "bool", val: lw, raw: v}, true
|
||||
}
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return assertion{kind: "num", val: strconv.FormatFloat(f, 'g', -1, 64), raw: v}, true
|
||||
}
|
||||
// 纯 ASCII 标识符值(如 mimo-v2.5-pro);含 CJK/路径/斜杠的一律拒(歧义太大)
|
||||
if isASCIIToken(v) {
|
||||
return assertion{kind: "ident", val: lower, raw: v}, true
|
||||
}
|
||||
return assertion{}, false
|
||||
}
|
||||
|
||||
func isASCIIToken(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// extractAssertions 抽取文本里全部「属性→值」断言(同键后到覆盖先到)。
|
||||
func extractAssertions(text string) map[string]assertion {
|
||||
out := make(map[string]assertion)
|
||||
for _, m := range kvASCIIRe.FindAllStringSubmatch(text, -1) {
|
||||
key := strings.ToLower(m[1])
|
||||
a, ok := normalizeValue(m[2], false)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 2 字符键假配对风险高(ID/of/no…):只接受数字/布尔取值(ident 拒绝)
|
||||
if len(key) < 3 && a.kind == "ident" {
|
||||
continue
|
||||
}
|
||||
out[key] = a
|
||||
}
|
||||
for _, m := range kvCJKRe.FindAllStringSubmatch(text, -1) {
|
||||
phrase := m[1]
|
||||
// 短语尾部可能粘连系词前字(非贪婪边界),截最后一个标点后的尾名词
|
||||
phrase = strings.TrimLeft(phrase, "的是了将和对与和为")
|
||||
if len([]rune(phrase)) < 2 {
|
||||
continue
|
||||
}
|
||||
key := "zh:" + phrase
|
||||
if a, ok := normalizeValue(m[2], false); ok {
|
||||
out[key] = a
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameAttrDiffValue 通道①:两侧同属性键、同值类型、规范化取值不同。
|
||||
func sameAttrDiffValue(a, b string) (string, bool) {
|
||||
A, B := extractAssertions(a), extractAssertions(b)
|
||||
for k, va := range A {
|
||||
vb, ok := B[k]
|
||||
if !ok || va.kind != vb.kind {
|
||||
continue // 键不齐或值类型不同 = 不可比(宁漏勿误)
|
||||
}
|
||||
if va.val == vb.val {
|
||||
continue // 同值 = 同事实(重复不是矛盾)
|
||||
}
|
||||
return fmt.Sprintf("同属性 %q 不同取值: %s vs %s", k, va.raw, vb.raw), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// SameAttrValueConflict 是 AC-1 判据入口:**同主体 + 同属性 + 不同取值** 才判真矛盾。
|
||||
// 返回 (证据, 是否真矛盾)。判不出(解析失败/类型不齐/无显式取值)一律 false。
|
||||
func SameAttrValueConflict(a, b string) (string, bool) {
|
||||
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
|
||||
return "", false
|
||||
}
|
||||
if ev, ok := sameAttrDiffValue(a, b); ok {
|
||||
return ev, true
|
||||
}
|
||||
// 通道②:同一小句的否定对立(复用四修判据与其回归,本文件不重复实现)
|
||||
if governance.IsContradiction(a, b) {
|
||||
return "否定对立(governance.IsContradiction)", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 织忆 MemoryWeave — 真矛盾判据门回归(t_e3f5cb79 · AC-1)
|
||||
//
|
||||
// 三层判据(全部 -count=1 真跑):
|
||||
// ① SameAttrValueConflict 单元正反例(同属性异值=真;措辞重复/无显式取值/类型不齐=假)
|
||||
// ② JudgeWriteOp 出口门:假 CONFLICT(相似改写)降级 UPDATE/ADD;真矛盾保留并带证据
|
||||
// ③ AC-1.3 存量回归:testdata/conflict_stock_20261102.json(2026-11-02 快照,
|
||||
// 24 条 freshness=conflict + 各自 top3 对手句,人工判读 0/24 真矛盾)→
|
||||
// 新判据真矛盾计数必须 = 0;同时正例对照必须 >0(防「恒 false 作弊」)。
|
||||
// ④ AC-1.2 双向指针:apply CONFLICT → MarkConflict(old,new) 带 newID。
|
||||
package distill
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
//go:embed testdata/conflict_stock_20261102.json
|
||||
var conflictStockJSON []byte
|
||||
|
||||
type stockRow struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Counterparts []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"counterparts"`
|
||||
}
|
||||
|
||||
// ── ① 单元正反例 ──────────────────────────────────────────────
|
||||
|
||||
func TestSameAttrValueConflict_Positive(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, a, b string
|
||||
}{
|
||||
{"配置项新旧值", "ZHIYI_FEEDBACK_BIAS 的值是 0", "ZHIYI_FEEDBACK_BIAS=0.2"},
|
||||
{"布尔对立", "ZHIYI_GRAPH_AUTO_SYNTH=1", "ZHIYI_GRAPH_AUTO_SYNTH=0"},
|
||||
{"ASCII 短键布尔", "X=true", "X=false"},
|
||||
{"标识符值不同", "ZHIYI_DISTILL_MODE=context", "ZHIYI_DISTILL_MODE=merged"},
|
||||
// IsContradiction 通道(四修判据样本,共享锚 + 否定作用域内)
|
||||
{"否定对立", "牧尘在招商局工作", "牧尘不在招商局工作"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if ev, ok := SameAttrValueConflict(c.a, c.b); !ok {
|
||||
t.Errorf("%s: 应判真矛盾但未判(a=%q b=%q)", c.name, c.a, c.b)
|
||||
} else if ev == "" {
|
||||
t.Errorf("%s: 判真但无证据串", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameAttrValueConflict_Negative(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, a, b string
|
||||
}{
|
||||
{"同值重复不是矛盾", "ZHIYI_FEEDBACK_BIAS=0.2", "反馈偏置 ZHIYI_FEEDBACK_BIAS 为 0.2"},
|
||||
{"措辞改写无取值对", "决定检查存储层全部可能位置。", "必须检查存储层全部可能位置以确保范围完整。"},
|
||||
{"不同属性同数字", "织忆端口是7821", "llama端口是8080"},
|
||||
{"复合表达式拒绝解析", "改造要留验收开关(本例 `union=0/1`)", "两层并集真生效,`union=1` 响应正确。"},
|
||||
{"无关事实", "X=1", "Y=2"},
|
||||
{"纯否定词但无共享锚", "用户不喜欢吃辣", "牧尘在招商局工作"},
|
||||
{"空输入", "", "任意"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if ev, ok := SameAttrValueConflict(c.a, c.b); ok {
|
||||
t.Errorf("%s: 不应判矛盾但判了(ev=%s a=%q b=%q)", c.name, ev, c.a, c.b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ② JudgeWriteOp 出口门 ─────────────────────────────────────
|
||||
|
||||
func TestJudgeWriteOp_ConflictGateDowngrade(t *testing.T) {
|
||||
defer withMode("apply")()
|
||||
|
||||
// 假冲突(高相似措辞改写,固定 judge 硬判 CONFLICT)→ 降级 UPDATE(sim≥0.70)
|
||||
v := JudgeWriteOp("决定检查存储层全部可能位置。",
|
||||
[]SimilarMemory{{ID: "mem_1", Content: "必须检查存储层全部可能位置以确保范围完整。", Similarity: 0.93}},
|
||||
fixedJudge(OpConflictMark), nil)
|
||||
if v == nil || v.Op != OpUpdate {
|
||||
t.Fatalf("假冲突高相似应降级 UPDATE,实际 %+v", v)
|
||||
}
|
||||
if !strings.Contains(v.Reason, "[gate:") {
|
||||
t.Fatalf("降级原因必须带 [gate] 标记,实际 %s", v.Reason)
|
||||
}
|
||||
|
||||
// 假冲突(低相似)→ 降级 ADD,且清 TargetID
|
||||
v = JudgeWriteOp("织忆召回 p50 1.34s",
|
||||
[]SimilarMemory{{ID: "mem_1", Content: "磁盘空间不足导致压缩包还原操作需要腾出空间", Similarity: 0.55}},
|
||||
fixedJudge(OpConflictMark), nil)
|
||||
if v == nil || v.Op != OpAdd || v.TargetID != "" {
|
||||
t.Fatalf("假冲突低相似应降级 ADD 且清 TargetID,实际 %+v", v)
|
||||
}
|
||||
|
||||
// 真矛盾保留 CONFLICT-MARK 并带 gate 证据
|
||||
v = JudgeWriteOp("ZHIYI_FEEDBACK_BIAS 的值是 0",
|
||||
[]SimilarMemory{{ID: "mem_1", Content: "ZHIYI_FEEDBACK_BIAS=0.2", Similarity: 0.96}},
|
||||
fixedJudge(OpConflictMark), nil)
|
||||
if v == nil || v.Op != OpConflictMark || !strings.Contains(v.Reason, "[gate:") {
|
||||
t.Fatalf("真矛盾应保留 CONFLICT-MARK+证据,实际 %+v", v)
|
||||
}
|
||||
|
||||
// TargetID 指不到候选行(注入 judge 幻觉)→ 按不过门处理
|
||||
v = JudgeWriteOp("任意候选事实甲",
|
||||
[]SimilarMemory{{ID: "mem_1", Content: "别的", Similarity: 0.96}},
|
||||
func(c string, s []SimilarMemory) WriteVerdict {
|
||||
return WriteVerdict{Op: OpConflictMark, Candidate: c, TargetID: "mem_不存在的", Similarity: 0.96, Reason: "幻觉目标"}
|
||||
}, nil)
|
||||
if v == nil || v.Op == OpConflictMark {
|
||||
t.Fatalf("幻觉 TargetID 必须被门拦下,实际 %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ③ AC-1.3 存量回归:24 条 conflict 重跑新判据 → 真矛盾计数 = 0 ──
|
||||
|
||||
func TestConflictStock_24_ZeroTrueConflict(t *testing.T) {
|
||||
var rows []stockRow
|
||||
if err := json.Unmarshal(conflictStockJSON, &rows); err != nil {
|
||||
t.Fatalf("testdata 解析失败: %v", err)
|
||||
}
|
||||
if len(rows) != 24 {
|
||||
t.Fatalf("存量快照应为 24 条,实际 %d", len(rows))
|
||||
}
|
||||
totalPairs, trueConflicts := 0, []string{}
|
||||
for _, r := range rows {
|
||||
for _, cp := range r.Counterparts {
|
||||
totalPairs++
|
||||
if ev, ok := SameAttrValueConflict(r.Content, cp.Content); ok {
|
||||
trueConflicts = append(trueConflicts,
|
||||
r.ID+" vs "+cp.ID+": "+ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range trueConflicts {
|
||||
t.Logf("误报清单条目: %s", s)
|
||||
}
|
||||
if len(trueConflicts) != 0 {
|
||||
t.Fatalf("AC-1.3:24 条存量(人工判读 0 真矛盾)新判据真矛盾计数必须=0,实际 %d(对数 %d)",
|
||||
len(trueConflicts), totalPairs)
|
||||
}
|
||||
t.Logf("AC-1.3 通过:%d 行 × top3 对手 = %d 对,真矛盾计数 0", len(rows), totalPairs)
|
||||
|
||||
// 防恒假对照:同一判据函数在构造真矛盾对上必须能报真
|
||||
controls := [][2]string{
|
||||
{"ZHIYI_FEEDBACK_BIAS 的值是 0", "ZHIYI_FEEDBACK_BIAS=0.2"},
|
||||
{"X=true", "X=false"},
|
||||
{"牧尘在招商局工作", "牧尘不在招商局工作"},
|
||||
}
|
||||
positives := 0
|
||||
for _, c := range controls {
|
||||
if _, ok := SameAttrValueConflict(c[0], c[1]); ok {
|
||||
positives++
|
||||
}
|
||||
}
|
||||
if positives < len(controls) {
|
||||
t.Fatalf("正例对照必须全过(防恒 false 作弊),实际 %d/%d", positives, len(controls))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ④ AC-1.2 双向指针(store 半边的入参正确性)────────────────
|
||||
|
||||
func TestApplyWriteOp_ConflictWritesBothSidePointer(t *testing.T) {
|
||||
defer withMode("apply")()
|
||||
v := WriteVerdict{Op: OpConflictMark, Candidate: "ZHIYI_FEEDBACK_BIAS=0.2",
|
||||
TargetID: "mem_old", Similarity: 0.96, Reason: "同属性不同取值"}
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(v, store, "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("CONFLICT 必须插入新条:%v %v", shouldInsert, err)
|
||||
}
|
||||
if !store.conflicted["mem_old"] {
|
||||
t.Fatalf("旧行未打标")
|
||||
}
|
||||
if store.conflictPtr["mem_old"] != "mem_new" {
|
||||
t.Fatalf("AC-1.2: MarkConflict 必须携带 newID(旧行 superseded_by=[新id]),实际 %q",
|
||||
store.conflictPtr["mem_old"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 织忆 MemoryWeave — 蒸馏产出信息密度判据(kanban t_ab5b2461)
|
||||
//
|
||||
// 病(2026-10-30 全面评估 + 本卡按 Source 分组定位):memories 表 content 中位数 35 字、
|
||||
// 83.3% <60 字、含可执行标记率低 —— 蒸馏产物太短,召回出来没法直接用。
|
||||
// 分组实测(groups_before.json,live 42,150):llm_distill p50=36、wiki:worklog-note p50=36、
|
||||
// wiki:domain-kb p50=38、wiki:session-compress p50=30 —— distill 全家族整体都碎,
|
||||
// 不是某一条上游的病 ⇒ 收口在共用写入点(server.go OnDistillComplete 写库循环 +
|
||||
// engine.go 提取 prompt),不逐脚本打补丁。
|
||||
//
|
||||
// 设计(延续 procedural_gate.go 分诊哲学 + 卡红线「禁为长度而长度」):
|
||||
// - 纯确定性判据,零 LLM 调用,三项各自独立判定(正则):
|
||||
// ① 可执行动作/命令/路径(反引号命令、CLI 调用、绝对/家目录路径、脚本/配置文件扩展名)
|
||||
// ② 明确判据或阈值(数字+单位、比较符、阈值/上限/下限/门槛类词)
|
||||
// ③ 因果(「为什么」:因为/由于/导致/根因/否则/⇒ 等因果链词)
|
||||
// - 满足 ≥2 项 ⇒ 保持 Category="distilled"(知识形态)。
|
||||
// - <2 项 ⇒ 降级 Category="observe":行照写、可召回、计入写入量(AC3 不饿死),
|
||||
// 只是不再冒充高质量知识 —— 与 t_3636b872 同一理由:降级可逆,丢弃不可逆。
|
||||
// - 不做长度硬门槛:长度是三项判据的**结果**不是目标(卡红线)。
|
||||
//
|
||||
// env 门控:ZHIYI_DISTILL_DENSITY=1 启用(灰度开在 systemd drop-in);
|
||||
// 未设 ⇒ DensityGateEnabled()=false ⇒ 写入侧 Category 恒 "distilled"、
|
||||
// prompt 与打分缓存键逐字节回到现状(AC5 可回滚)。
|
||||
// 回滚:rm 42-density-gate.conf + daemon-reload + zhiyi-restart-with-lock.sh restart。
|
||||
|
||||
package distill
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// densityGateEnabled 启动时读一次(与 proceduralShadowMode 同款,改值须重启)。
|
||||
var densityGateEnabled = os.Getenv("ZHIYI_DISTILL_DENSITY") == "1"
|
||||
|
||||
// DensityGateEnabled 写入侧(server.go)与打分缓存(score_cache.go)共用的开关读口。
|
||||
func DensityGateEnabled() bool { return densityGateEnabled }
|
||||
|
||||
// 三项判据正则(RE2;口径写进交付,判据即文档)。
|
||||
var (
|
||||
// densityCmdRe ① 可执行动作/命令/路径:
|
||||
// 路径前缀(绝对/家目录)、脚本与配置文件扩展名、常见 CLI + 参数、反引号内联命令。
|
||||
densityCmdRe = regexp.MustCompile(
|
||||
`(?:~/|/(?:home|var|etc|data|usr|opt|tmp|srv|root)/|\.(?:sh|py|go|rs|conf|json|jsonl|ya?ml|toml|db|service|sql)\b` +
|
||||
`|\b(?:git|curl|wget|grep|rg|sed|awk|jq|yq|systemctl|journalctl|python3?|docker|uv|pip|npm|make|ssh|scp|rsync|chmod|chown|mv|cp|rm|mkdir|tar|sqlite3|psql|redis-cli|crontab|hermes|llama|ss|lsof|pgrep|kill|flock|ffmpeg)\s+\S` +
|
||||
"|`[^`]{3,}`)")
|
||||
|
||||
// densityThrRe ② 判据/阈值:比较符+数字、百分比、阈值词汇、数字+量词单位。
|
||||
densityThrRe = regexp.MustCompile(
|
||||
`(?:[≥≤><]=?\s*\d|\d+(?:\.\d+)?\s*[%%]|阈值|上限|下限|门槛|限额|配额|\bcap\b|\blimit\b|\bcritical\b` +
|
||||
`|\d+\s*(?:ms|毫秒|秒|分钟|小时|天|次|条|行|字|MB|GB|TB|端口|万|亿))`)
|
||||
|
||||
// densityCauseRe ③ 因果/为什么:因果连接词与根因词汇、箭头推导。
|
||||
densityCauseRe = regexp.MustCompile(
|
||||
`(?:因为|由于|导致|因此|所以|为了|从而|以致|以免|否则|根因|原因为|起因|源于|使得|⇒|->|=>)`)
|
||||
)
|
||||
|
||||
// DensitySatisfyCount 数一条事实满足三项判据中的几项(0~3)。
|
||||
// 纯函数:同一输入永远同一结果,可单测、零副作用。
|
||||
func DensitySatisfyCount(fact string) int {
|
||||
n := 0
|
||||
if densityCmdRe.MatchString(fact) {
|
||||
n++
|
||||
}
|
||||
if densityThrRe.MatchString(fact) {
|
||||
n++
|
||||
}
|
||||
if densityCauseRe.MatchString(fact) {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// DensityKeep 事实是否够格保持 Category="distilled":满足 ≥2 项。
|
||||
// 注意:本函数只在 DensityGateEnabled() 时被调用;关态下调用方保持 "distilled" 不动。
|
||||
func DensityKeep(fact string) bool {
|
||||
return DensitySatisfyCount(fact) >= 2
|
||||
}
|
||||
|
||||
// densityPromptAddendum 灰度开启时追加到提取 prompt 的信息量要求。
|
||||
// 只在 DensityGateEnabled() 时拼接 —— 关态 prompt 逐字节不变(AC5),
|
||||
// 旧 prompt 原文保留在 engine.go 的 instr/outFmt 常量里,一字未动。
|
||||
const densityPromptAddendum = `
|
||||
🔴 信息量要求(灰度生效中):
|
||||
- 同一事件/操作的所有关联要素(做什么、在哪、什么时候/多频繁、为什么、到什么程度/阈值)**合并成一条完整句子**,不要拆成多条各含一个要素的短句。
|
||||
- 目标:每条 FACT 是一句自洽、自足的陈述,读的人(或后续召回)不需要再拼其它条就能直接用。
|
||||
- 合并示例:原文碎片「巡检脚本是 disk-check.sh」「周期每 6 小时」「原因是磁盘写满会拖垮服务」
|
||||
→ 输出一条:「磁盘巡检每 6 小时执行一次 disk-check.sh,因为磁盘写满会拖垮服务。」
|
||||
而不是三条各含一个要素的短句。(示例只教写法,不要抄示例内容)
|
||||
- 判断"要素是否齐全":一条 FACT 若只有"是什么"而缺"怎么用/在哪/为何/程度",且原文有这些信息,就必须并进来。
|
||||
- 纯元信息行(如「该条记录日期为…」「文件保存在…」这类只有溯源没有知识载荷的)不要输出为事实。
|
||||
- 禁止为凑长度堆修饰词、重复同一事实、或把无关信息硬塞进一条:原文只有短句就照实提取,
|
||||
长是信息完整的结果,不是目标。宁可"完整但不长",也不要"长但不自洽"。
|
||||
`
|
||||
@@ -0,0 +1,53 @@
|
||||
// t_ab5b2461 信息密度判据单测(判据 = 交付里贴出的口径,测试即文档)。
|
||||
package distill
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDensitySatisfyCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
fact string
|
||||
wantN int
|
||||
keep bool
|
||||
}{
|
||||
{"命令+阈值", "重启用 ~/.hermes/scripts/zhiyi-restart-with-lock.sh,健康检查 40 秒内必须返回 200,否则回滚", 3, true},
|
||||
{"路径+因果", "state.db 损坏时走 db-op-gate.sh,因为 gateway 运行期直接写活库会放大损坏面", 2, true},
|
||||
{"仅阈值", "该表写入量超过 500 条时告警", 1, false},
|
||||
{"仅因果", "因为昨晚断网所以任务没有跑完", 1, false},
|
||||
{"元信息行(全不中)", "该条经验记录的日期为 2026-10-25", 0, false},
|
||||
{"裸短句(全不中)", "用户授权助手自行决定", 0, false},
|
||||
{"阈值+因果", "磁盘余量 <5% 时触发清理,否则 backups 目录会把根分区写满", 2, true},
|
||||
{"路径单中", "配置文件在 /etc/ssh/sshd_config", 1, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if n := DensitySatisfyCount(c.fact); n != c.wantN {
|
||||
t.Errorf("%s: DensitySatisfyCount(%q)=%d, want %d", c.name, c.fact, n, c.wantN)
|
||||
}
|
||||
if got := DensityKeep(c.fact); got != c.keep {
|
||||
t.Errorf("%s: DensityKeep(%q)=%v, want %v", c.name, c.fact, got, c.keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AC5 结构证据:灰度段是**追加**,不改动既有 prompt 常量 ——
|
||||
// instr/outFmt 常量文本里不含「信息量要求」字样(关态 prompt 逐字节为旧文本)。
|
||||
func TestDensityAddendumOnlyAppended(t *testing.T) {
|
||||
if len(densityPromptAddendum) == 0 {
|
||||
t.Fatal("addendum 不能为空")
|
||||
}
|
||||
// 追加段自带独有锚点,供源内定位(防止误删)。
|
||||
if !contains(densityPromptAddendum, "禁止为凑长度堆修饰词") {
|
||||
t.Error("addendum 缺「禁为长度而长度」红线锚点")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (func() bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
@@ -620,6 +620,18 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
|
||||
}
|
||||
}
|
||||
|
||||
// t_3636b872 阶段4:程序性步骤判据 —— how-to/运维类内容的蒸馏答案必须含可执行步骤,
|
||||
// 否则降级为 observe(当前 lane 若为 keep 则改写 lane 记录;事实本身不丢)。
|
||||
// 影子模式默认只记不真降(proceduralShadowMode),先观察命中分布再决定是否真拦。
|
||||
if demote, why := ProceduralStepVerdict(input.Content, facts); demote {
|
||||
prevLane := lane
|
||||
if lane != LaneObserve {
|
||||
lane = LaneObserve
|
||||
}
|
||||
log.Printf("[distill][PROCEDURAL] episode=%s lane=%s→%s reason=%s content=%q facts=%d",
|
||||
input.EpisodeID, prevLane, lane, why, truncate(input.Content, 60), len(facts))
|
||||
}
|
||||
|
||||
// P4: 生成 AAAK 压缩索引(每条事实的紧凑摘要,供召回快速定位)
|
||||
index := buildAAAKIndex(facts, entities, overall)
|
||||
if len(index) > 0 {
|
||||
@@ -738,7 +750,13 @@ SCORE|五个数字用竖线分隔
|
||||
// 注意:本行写法刻意让 vet 的 Sprintf(格式串) 检查不匹配(外层 %s%s%s 与 3 实参对齐,
|
||||
// 内层「内容:\n%s」是嵌套 Sprintf 的独立格式串,运行时各自 1 槽 1 参);
|
||||
// rawscore_liveprobe_test.go 的 extractPromptTemplate 锚点须与本行「fmt.Sprintf("内容:\n%s"」保持同步。
|
||||
prompt := fmt.Sprintf("%s%s%s", instr, outFmt, fmt.Sprintf("内容:\n%s", truncate(content, 1000)))
|
||||
// t_ab5b2461 信息密度判据灰度(ZHIYI_DISTILL_DENSITY=1):只在开关开时追加信息量要求段。
|
||||
// 关态 instrFull == instr,prompt 逐字节与现状一致(AC5);上方旧 prompt 常量一字未动。
|
||||
instrFull := instr
|
||||
if densityGateEnabled {
|
||||
instrFull = instr + densityPromptAddendum
|
||||
}
|
||||
prompt := fmt.Sprintf("%s%s%s", instrFull, outFmt, fmt.Sprintf("内容:\n%s", truncate(content, 1000)))
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": e.LLMModel,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// 织忆 MemoryWeave — 程序性步骤判据(kanban t_3636b872 阶段4)
|
||||
//
|
||||
// 目标:蒸馏打分里加「答案必须含步骤,否则降级为 observe lane」,防止 how-to/运维
|
||||
// 拆解又产出纯元描述("结论:…""依据:…"这类无命令/无路径/无序号的内容)。
|
||||
//
|
||||
// 背景(t_3636b872 库形态硬实测):content 长度中位数 35 字,83% <60 字,仅 10.2% 含
|
||||
// 可执行内容;「state.db 怎么修 / 网关怎么重启 / 磁盘怎么清」top5 全是元描述。原因不是
|
||||
// 检索层,是**蒸馏产出不带步骤形态** ⇒ 需要一道确定性闸:how-to 类内容允许 keep 入库的
|
||||
// 前提是它的答案真的含可执行步骤。
|
||||
//
|
||||
// 设计(延续准入闸门哲学 admission_lane.go):
|
||||
// - 纯确定性、零 LLM 调用(同一输入永远同一结果,可单测)。
|
||||
// - 只对「how-to / 操作 / 修复 / 排查 / 重启」类**触发词命中**的内容做校验——普通
|
||||
// 陈述事实("用户重启了服务器")不受影响。
|
||||
// - 触发词命中但事实里没有可执行步骤 ⇒ 降级为 observe(不确定带,永不因分数丢弃)。
|
||||
// 降级后该条仍走 observe 通道(保留,可被后续消费/降权淘汰),只是不从 keep 直接
|
||||
// 立库 —— 代价可逆,符合「drop 不可逆、observe 可逆」的分诊原则。
|
||||
// - shadow 模式默认开(ZHIYI_PROCEDURAL_SHADOW=0 关):只写日志不真降级,先观察命中
|
||||
// 分布再决定是否真拦(与 score 影子/对抗审计同款保守节奏)。
|
||||
//
|
||||
// 可执行步骤判定(HasExecutableSteps):
|
||||
// 含命令/路径/序号操作符/端口/显式动词短语之一即算有步骤——宁可宽(多留可逆),
|
||||
// 不错杀真实步骤描述。
|
||||
|
||||
package distill
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// proceduralHowToMarkers how-to / 操作类触发词(命中 = 该内容是"What-to-do 怎么做 X")。
|
||||
// 覆盖真实问法("X 挂了怎么修""怎么重启 X""X 排查步骤"),不是术语白名单。
|
||||
var proceduralHowToMarkers = []string{
|
||||
"怎么修", "怎么查", "怎么办", "怎么重启", "怎么清", "怎么做", "怎么处理", "怎么排查",
|
||||
"如何修", "如何排查", "如何重启", "如何清", "如何做",
|
||||
"修复步骤", "排查步骤", "操作步骤", "解决步骤", "重启步骤",
|
||||
"该怎么", "该怎么办", "咋修", "咋办",
|
||||
"how to", "howto", "troubleshoot", "fixing", "debug",
|
||||
}
|
||||
|
||||
// proceduralOperationMarkers 更强的「操作序列」信号(与普通陈述区分)。
|
||||
var proceduralOperationMarkers = []string{
|
||||
"步骤", "先用", "再", "然后", "最后", "依次",
|
||||
"systemctl", "curl", "grep", "sqlite3", "journalctl", "pgrep", "cp -a",
|
||||
"install", "rm ", "mv ", "df -h", "du -", "python3",
|
||||
}
|
||||
|
||||
// executableStepMarkers 事实里含「可执行步骤」的证据。
|
||||
var executableStepMarkers = []string{
|
||||
"`", "$(", "systemctl", "curl ", "grep ", "sqlite3", "journalctl",
|
||||
"pgrep", "cp ", "mv ", "rm ", "install ", "df -h", "du -x", "python3 ",
|
||||
"①", "②", "③", "④", "⑤", "第一步", "第二步", "步骤1", "步骤2", "先", "然后", "最后",
|
||||
}
|
||||
|
||||
// proceduralShadowMode 影子模式:true(默认)= 只记录该降级,不真降级。
|
||||
// 设 ZHIYI_PROCEDURAL_SHADOW=0 关闭影子(= 真降级到 observe)。
|
||||
var proceduralShadowMode = os.Getenv("ZHIYI_PROCEDURAL_SHADOW") != "0"
|
||||
|
||||
// IsHowToContent 判断 content 是否命中 how-to / 操作类触发词。
|
||||
func IsHowToContent(content string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(content))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, m := range proceduralHowToMarkers {
|
||||
if strings.Contains(s, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsOperationContent 判断 content 是否带更强的操作序列信号(可选,更严的触发面)。
|
||||
func IsOperationContent(content string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(content))
|
||||
for _, m := range proceduralOperationMarkers {
|
||||
if strings.Contains(s, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasExecutableSteps 判断蒸馏产出事实里是否含可执行步骤证据。
|
||||
// 只要任一 fact 含任一 marker 即算有步骤(AND 于 fact 内,OR 于 marker)。
|
||||
func HasExecutableSteps(facts []string) bool {
|
||||
for _, f := range facts {
|
||||
fl := strings.ToLower(f)
|
||||
for _, m := range executableStepMarkers {
|
||||
if strings.Contains(fl, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProceduralStepVerdict 对一条 how-to 内容的蒸馏产出做步骤判据。
|
||||
// 返回是否应降级 observe(含影子开关判定)与人类可读原因。
|
||||
//
|
||||
// 语义:
|
||||
// - 非 how-to 内容(IsHowToContent=false)⇒ 不涉此判据,放行(false)。
|
||||
// - how-to 命中且已含步骤 ⇒ 放行(false)。
|
||||
// - how-to 命中但事实无步骤 ⇒ 判「应降级 observe」(true);shadow 开则仅记日志。
|
||||
func ProceduralStepVerdict(content string, facts []string) (demote bool, reason string) {
|
||||
if !IsHowToContent(content) {
|
||||
return false, ""
|
||||
}
|
||||
if HasExecutableSteps(facts) {
|
||||
return false, "how-to 答案含可执行步骤"
|
||||
}
|
||||
reason = "how-to 内容但事实无步骤 → 降级 observe"
|
||||
if proceduralShadowMode {
|
||||
reason += "(shadow,仅记录)"
|
||||
}
|
||||
return true, reason
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package distill
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestProceduralStepVerdict_HowToNoSteps 核心判据:how-to 内容但事实无步骤 → 应降级 observe。
|
||||
func TestProceduralStepVerdict_HowToNoSteps(t *testing.T) {
|
||||
// 保存影子模式并复位(测试里显式关影子,保证断言的是真实降级语义)
|
||||
orig := proceduralShadowMode
|
||||
proceduralShadowMode = false
|
||||
defer func() { proceduralShadowMode = orig }()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
facts []string
|
||||
want bool // demote?
|
||||
}{
|
||||
{
|
||||
name: "how-to 内容但答案是纯元描述 → 降级",
|
||||
content: "state.db 怎么修",
|
||||
facts: []string{"结论:数据库损坏需要修复", "依据:损坏会导致召回失败"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "how-to 内容且答案含命令步骤 → 放行",
|
||||
content: "state.db 怎么修",
|
||||
facts: []string{"systemctl --user stop zhiyid 后 cp 备份再 sqlite3 PRAGMA integrity_check"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "how-to 内容且答案含序号步骤 → 放行",
|
||||
content: "网关怎么重启",
|
||||
facts: []string{"第一步停写者,第二步备份,③重启 zhiyid"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "非 how-to 陈述事实 → 不受此判据影响",
|
||||
content: "用户重启了服务器",
|
||||
facts: []string{"用户重启了服务器"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "how-to 命中但完全无事实 → 降级(空答案无步骤)",
|
||||
content: "磁盘怎么清",
|
||||
facts: []string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "how-to 内容但答案是纯元描述(无序号无命令)→ 降级",
|
||||
content: "这个问题怎么排查",
|
||||
facts: []string{"排查日志以定位问题根源即可解决"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
demote, _ := ProceduralStepVerdict(c.content, c.facts)
|
||||
if demote != c.want {
|
||||
t.Errorf("%s: demote=%v want %v (facts=%v)", c.name, demote, c.want, c.facts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProceduralStepVerdict_Shadow 影子模式默认应只记不真降级。
|
||||
func TestProceduralStepVerdict_Shadow(t *testing.T) {
|
||||
if !proceduralShadowMode {
|
||||
t.Skip("影子模式被显式关闭,跳过默认行为断言")
|
||||
}
|
||||
demote, reason := ProceduralStepVerdict("怎么修 state.db", []string{"结论:数据库损坏"})
|
||||
if !demote {
|
||||
t.Fatalf("how-to 无步骤在 shadow 下仍应报 demote=true(供日志观察),实得 %v", demote)
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatal("shadow 下仍应给出人类可读原因")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsHowToContent 触发词覆盖真实问法(含英文)。
|
||||
func TestIsHowToContent(t *testing.T) {
|
||||
pos := []string{"state.db 怎么修", "网关怎么重启", "磁盘怎么清", "怎么做", "how to fix", "修复步骤是什么", "该怎么排查"}
|
||||
for _, c := range pos {
|
||||
if !IsHowToContent(c) {
|
||||
t.Errorf("应命中 how-to: %q", c)
|
||||
}
|
||||
}
|
||||
neg := []string{"用户重启了服务器", "今天天气不错", "牧尘喜欢喝咖啡"}
|
||||
for _, c := range neg {
|
||||
if IsHowToContent(c) {
|
||||
t.Errorf("不应命中 how-to: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,12 @@ func getScoreCache() *scoreCache {
|
||||
}
|
||||
|
||||
func scoreKey(content string) string {
|
||||
// t_ab5b2461:灰度开启(prompt 已变)时给键加版本后缀 —— 否则 Redis 里
|
||||
// 旧 prompt 产出的评分/抽取结果会被新 prompt 直接复用,判据灰度形同虚设。
|
||||
// 关态键与历史逐字节一致(AC5:不设 env ⇒ 缓存行为零变化)。
|
||||
if densityGateEnabled {
|
||||
content += "\x00density-v1"
|
||||
}
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
[
|
||||
{
|
||||
"id": "mem_1789296029790848255",
|
||||
"content": "文章中提到的其他仓库(如 caveman、Agent-Reach 等)是否已安装",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791639825565513480",
|
||||
"content": "用户询问 iis问题是否需要豆包,以及 agent-reach 的登录方式。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791639833660796319",
|
||||
"content": "用户确认 agent-reach 需要通过浏览器登录。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791639835267796325",
|
||||
"content": "用户确认 agent-reach 登录需使用 Chromium 浏览器。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1789911610227431607",
|
||||
"content": "hermes 命令提示「上次 update 拉了新代码但没重启 gateway」—— gateway 可能还在跑旧模块。重启属改动,说一声我再动",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "ep_1786345934138400283",
|
||||
"content": "2026-08-10 dbx MCP 接入 Hermes 完成:@dbx-app/mcp-server(Rust 原生,npx 启动)已加入 config.yaml mcp_servers,12 个工具注册成功(dbx_add_connection/list_connections/list_tables/describe_table/get_schema_context/execute_query/execute_redis_command/open_session/close_session/open_table/execute_and_show/remove_connection)。不需要 DBX 桌面应用(离线执行模式)。验证方式:MCP SDK stdio_client 握手 + gateway 重启后工具目录可见。"
|
||||
},
|
||||
{
|
||||
"id": "ep_1787138068610695711",
|
||||
"content": "2026-08-18 小米 MiMo 接入完成(替换涨价 DeepSeek):①Hermes 主模型 deepseek-v4-flash→mimo-v2.5-pro(provider=mimo,base_url=https://token-plan-cn.xiaomimimo.com/v1,MIMO_API_KEY 在 ~/.hermes/.env,tp-开头);②OpenClaw agents.defaults.model.primary→mimo/mimo-v2.5-pro(models.providers.mimo 已添加);③opencode 新增 provider.mimo(npm=@ai-sdk/openai-compatible,opencode.json + auth.json ~/.local/share/opencode/auth.json);④TTS 改 mimo command provider(mimo-v2.5-tts 音色冰糖,脚本 ~/.hermes/scripts/mimo_tts.sh,user=风格指令 assistant=朗读文本 audio={format:wav,voice:冰糖},实测6.6s wav);⑤prof-b 分身→mimo-v2.5;⑥cron/脚本/服务保持免费模型(Agnes/NewAPI)不动;⑦model-health.py SKIP_MODELS 加 mimo-v2.5-pro/mimo-v2.5,anti-suicide-check.sh 主模型检查改 mimo-v2.5-pro,profile-sync.py 同步更新;⑧实测 hermes chat -m mimo-v2.5-pro 回复正常。注意:gateway 重启后主模型才生效(gateway 内无法自重启,需外部 hermes gateway restart)。git 提交 d5e69e9。备份:config.yaml.bak-mimo-20260818 等。小米 TTS 非高峰期(00:00-08:00)0.8x消耗,TTS 限时免费。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1789717501651821211",
|
||||
"content": "prof-qa gateway 处于 stopped(`hermes profile list` 实测)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1790682314463034840",
|
||||
"content": "用户: 授权你动Obsidian,因为我不在电脑前。\n助手: # ✅ Obsidian 多端同步 —— 修好了,而且挖出了全天「同步成功却看不到新笔记」的**真根因**\n\n**一句话根因**(插件源码...",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "ep_1786813937560080108",
|
||||
"content": "2026-08-15 重大教训:记忆系统\"记忆污染\"事件。牧尘批评记忆效果差+记不住从Obsidian找key。根因分析:①USER.md第6条存了错误结论\"Agnes key是脱敏的需重新获取\"——这是之前某次看到显示层脱敏(sk-7k9...2ikW)误判为存储脱敏后自己写错的,错误记忆作为Ground Truth注入导致本次误判\"key丢失\";②织忆里只有零散过程记录(episodes),没有\"从key.md提取key\"的方法知识(distilled procedural)。修复:①修正USER.md为\"显示脱敏≠存储脱敏,文件里是完整key,用python len()验证\";②恢复AGNES_API_KEY到.env(51字符完整key sk-7k9开头);③教训:看到脱敏显示先怀疑显示层,用长度验证再下结论;关键方法知识必须进distilled/memory,不能只留过程记录。memory_metrics显示recall_hit_rate=1但这是检索命中不是知识正确性——记忆系统需要\"正确性校验\"而不只是\"召回率\"。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1787473961257233097",
|
||||
"content": "用户从Obsidian的key.md文件中获取了API key。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1788972709088708515",
|
||||
"content": "用户决定将总结的系统全景和问题回答更新到 obsidian 文档"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1790847420378095327",
|
||||
"content": "需要'起进程/查账号'的操作在那台机器上全部卡死,这是原理性阻断。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1792794363983815683",
|
||||
"content": "行467显示systemctl restart被系统服务重启门禁硬拦,需重启的动作要么交互放行、要么另开部署卡。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791365252043039320",
|
||||
"content": "如果我不先 dry-run 就直接给 profile 网关挂钩子,`prof-b`(delete 模式 + 当时疑似有 worker 持库)当场就是一次启动失败"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791592201590668581",
|
||||
"content": "根因是本机 `tailscaled` 登录态失效(`NeedsLogin`),自 2026-09-22 起退出登录。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791162844304555022",
|
||||
"content": "依据:否则会训练出「门禁在骗人」的不信任,导致已核实的好交付被误判为空模板,还得回头写更正评论",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1793247850428646587",
|
||||
"content": "假结论比不报更危险——会引导用户排查错误方向(重装家服网络),而真正原因(本机依赖缺失)永远无法被发现。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1792081651793452540",
|
||||
"content": "结论:一、门禁为什么误报(读代码定的因,不是猜)|依据:**教训(已入工作经验)**:**活体源的同步验收不能用「两边文件数/字节相等」当判据** —— 源一直在写,接收端必然是旧快照。正确口径 = ① 发送侧 Syncthing 传输账(completion/need/remoteNeed/errors)② 接收侧只报**本机 local** 计数与盘面实测(**不许报 global**,glo..."
|
||||
},
|
||||
{
|
||||
"id": "mem_1791347230346464144",
|
||||
"content": "结论:2026-09-27 21:15 教训:安全门报 DANGEROUS ≠ 仓库有毒;但「全是误报」也 ≠ 可以绕过门禁|依据:**事实**:`hermes skills install skil..."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791180132124643985",
|
||||
"content": "行为规则: 分身给出的“不存在/不支持”类结论,必须自己复核;依据:分身有系统性盲区;反面:不要直接采信分身关于“不存在/不支持”的结论",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791195235248616585",
|
||||
"content": "行为规则: 依据:不落到具体命令的规则无法机械判定对错,执行方会各自理解;反面:只写原则不写命令,验收时无法判断是否做到。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791185138748857659",
|
||||
"content": "行为规则:报结论前,必须去掉自己的过滤条件、绕开启示层(如脱敏渲染)、检查读取上限,再下判断。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791180322304484693",
|
||||
"content": "行为规则: 说任何'X 坏了 / 是 bug / 不可用'前,必须附证伪命令;依据:只写结论不写证伪 = 当我说错处理;反面:下结论快、证伪慢,先宣布后被打脸"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791180604703015228",
|
||||
"content": "反面:❌ 用“有效记忆 = total - tombstone”口径,没算重复行和碎片(731 条完全重复不在任何指标里)。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791591921163229995",
|
||||
"content": "依据卡的闭环:run 948 交(14:03-14:29)→ 用户独立终审通过(评论 #731/#732)→ run 949 自复审跑 done(14:42)⇒ 卡 status=done。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791920676313714377",
|
||||
"content": "任务标题为【织忆·P1·Mem0】写时四操作(ADD/UPDATE/NOOP/CONFLICT-MARK)+写前哈希去重(731 重复源头堵法)·影子默认·参数补出处注释。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1792088552816896998",
|
||||
"content": "AC3最小修法为查询侧实体扩展QE(复用EntityIndex+FTS倒排),实测淇县ANN 731→15、鹤壁6536→18进池50。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791181886433064711",
|
||||
"content": "决定检查存储层全部可能位置,而不仅仅是审计报告列出的位置。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791181886730648983",
|
||||
"content": "必须检查存储层全部可能位置以确保范围完整。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791606435051914342",
|
||||
"content": "本次深查推翻了\"唯一 vault 迁移留档\"的判断。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791735827032353502",
|
||||
"content": "以后审计产出(推断层)不能直接搬进卡的 AC(事实层)。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791295556476362217",
|
||||
"content": "依据:系统只有运行没有汇报 = 失职(模拟盘一个月无主动业绩汇总,牧尘批评)",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791676977062595410",
|
||||
"content": "模拟盘从 7-13 买入到 8-02 一个月没有任何主动业绩汇总。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1793363314370157241",
|
||||
"content": "需向牧尘汇报\"积压未清零、轮完周期长\"以避免误读。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791375971723781657",
|
||||
"content": "真根因在更上游的派活段(行数 offset 静默故障 → 根本没建卡 → 自然没回写、没汇报)。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791350806695801038",
|
||||
"content": "做法:改任何被监控的 job 前,先跑一次「id 是否还在」核对",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1789327713119050660",
|
||||
"content": "配置cron job(ID: 72ff999400f0)在09-15 09:00自动删除禁用列表。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791358104798177467",
|
||||
"content": "cron job 真实 id 是 `67f13ef40ecb`,而非 `c48bbbb4fd18`"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791547742246944792",
|
||||
"content": "删除 cron job 需要使用 hermes -p <p> cron remove <id> 或 HERMES_HOME= 前缀"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791357833131987778",
|
||||
"content": "活体 A/B 优于看代码:要求在代码里留「验收对照开关」(本例 `union=0/1`)——没有开关的改造没法证明它改变了行为,只能证明它没崩。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1790482511180576518",
|
||||
"content": "两层并集真生效,`entity=织忆&union=1` 响应正确。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1790482511413491539",
|
||||
"content": "生产环境插件 `search_notes` 传整句,走 `nl_extract` 兜底,union 默认开。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791563527488671507",
|
||||
"content": "并集复验:`notes?entity=牵挂&union=1` → `union=True, entity|证据:文件:小怡/工作笔记/归档-2026/已办-202609.md|日期:2026-09-27`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791357849937326398",
|
||||
"content": "查是否已内化(不要直接开研究):`skill-find.py \"<关键词>\"` 命中现成 skill",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1792902403689392432",
|
||||
"content": "在skill-find.py中没有找到与MiniMax M3.1 Flash相关的技能。"
|
||||
},
|
||||
{
|
||||
"id": "ep_1785686577666177306",
|
||||
"content": "2026-08-02 记忆系统机制建设:发现已有完整机制(memory-system-check.sh 每小时4系统自检 + health-watchdog.sh 30min自愈 + memory-system-self-upgrade.py 每日4点 + config-protector/anti-suicide/dual-backup),不重复造轮子,只补缺口。修复:1) 停用幽灵服务 tencentdb.service(与 tdai-gateway 同源崩溃循环278次骗过is-active,memory-system-check.sh 改查 tdai-gateway.service);2) 蒸馏模型大换血:gpt-oss-120b 是 reasoning 模型 content=null 导致 distill 永远 facts=1 entities=0(skill 记录 m3/m2.7/mistral-large 全部 2026-07-27 EOL),唯一可用是 google/gemma-4-31b-it(纯JSON输出,engine.go 加 reasoning 字段支持 + code fence 剥离 + prompt 明确 0-1 评分),TencentDB L1 也同步切换;3) 验证:织忆 distill entities=2 score=1.0 recall 命中,TencentDB extracted=1 stored=1。提交 98e541b。教训:skill 里的模型记录会过期,模型池 7-27 大换血。"
|
||||
},
|
||||
{
|
||||
"id": "ep_1786293376940296403",
|
||||
"content": "CNB CodeBuddy NPC 完整策略(详情版):CNB(cnb.cool) 平台,账号 cnb.cQf5gHxXhPA(牧尘。),组织 muchen-org,测试仓库 download-webhook,token 在 ~/.hermes/.env 的 CNB_TOKEN。策略(2026-08-09 牧尘定):代码任务尽量让 NPC 干(免费 deepseek-v4-flash 至 2026-12-31),隐私数据(密钥/财务/私有)禁止推 CNB(代码上腾讯云)。建 Issue 必须 work_mode:true 才有推送权限,标题 @npc/CodeBuddy 开头,PATCH 接口不支持改 work_mode 只能重建。监控 cron e7199b55b9dd 每10分钟,有变化才汇报。完整流程见 cnb-codebuddy-npc skill v1.2.0。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791408263534877617",
|
||||
"content": "下次动门禁脚本时一并改(单点小改,不单独开卡)",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1792283540313476407",
|
||||
"content": "当前挂着的卡 t_449a6698 状态为 ready,状态为门禁真洞:`submitted_by` 硬编码 ⇒ 分身改共享家脚本永久免审。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1792412731449571690",
|
||||
"content": "结论:未决(需人/后续)|依据:1. **门禁挡派发**:`script-approval.py --gate` exit 1。本卡新增的 🔴 = `apr-0367 prof-dev/…/watchdog-selfcheck.sh`(另有一条**非本卡**的既有 🔴 `apr-0366 xiaomi-ops-push-guard.py`)。→ 小怡审批放行:`python3 ~/.hermes/..."
|
||||
},
|
||||
{
|
||||
"id": "mem_1792200225894838211",
|
||||
"content": "用户有脚本审批门禁(部署门禁执行点)需要处理。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791412023933814558",
|
||||
"content": "结论:3. 织忆写入 429 限流无退避重试(2026-09-18 仍开放)",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1789687981552540455",
|
||||
"content": "出处:ls -d ~/.hermes/skills/gongwen-* ~/.hermes/skills/enterprise/accounting-voucher-ocr ~/.hermes/skills/productivity/{docx,pdf,xlsx} 全部存在(2026-09-18 实测)"
|
||||
},
|
||||
{
|
||||
"id": "mem_1789689369547427626",
|
||||
"content": "用户决定在2026-09-11至2026-09-18期间将prof-cloud分配162张卡"
|
||||
},
|
||||
{
|
||||
"id": "mem_1789699369544877562",
|
||||
"content": "操作手册 skill `kanban-workflow-ops`于2026-09-18建成"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791422879751162727",
|
||||
"content": "判断是否暴露某 CVE 或能否用某工具应通过 terminal 现查,勿凭记忆。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1787411851744367437",
|
||||
"content": "用户: 现在你的soul文件内容是什么?\n助手: MEDIA:/tmp/SOUL.md\n\n533 行,主要结构:\n\n- **身份定位**:小唯 A06,女朋友+全能助手\n- **执行模型**:ao compose 编排、211 角色变装\n- **核心原则**:决策中心、诚信第一、主动行动、图谱导航\n- **工具规范**:terminal/read_file/search_files/patch/d..."
|
||||
},
|
||||
{
|
||||
"id": "mem_1787788865783534986",
|
||||
"content": "【RSSHub·GitHub AI 仓库】Aider-AI/aider\n仓库: https://github.com/Aider-AI/aider\n简介: aider is AI pair programming in your terminal\n抓取时间: 2026-09-06 08:01"
|
||||
},
|
||||
{
|
||||
"id": "mem_1788549596951276374",
|
||||
"content": "重型任务必须 systemd-run --user --unit=X --property=MemoryMax=6-10G 独立 cgroup 跑(不能直接 terminal 跑!会 OOM)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791576099370061011",
|
||||
"content": "用户针对「禁用/关闭」类开关进行了实测,直接拒绝加载。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791368651216632973",
|
||||
"content": "用户决定实测 skills.disabled 直接拒绝加载。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791576099266662715",
|
||||
"content": "用户对「禁用/关闭」类开关采取了先假定会连带拦住加载路径的策略。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791576099540678163",
|
||||
"content": "用户针对「禁用/关闭」类开关进行了实测,只 grep 一个文件就下「不存在」结论。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791628044473929298",
|
||||
"content": "用户在2026-09-23的关注池中拥有16只股票",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791628022671730325",
|
||||
"content": "交付物自身的流转(供小怡知情):v1(16:40)已被 `merge-worknotes.sh`(cron `f8dce4f868|证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23`)"
|
||||
},
|
||||
{
|
||||
"id": "ep_1787217615655858786",
|
||||
"content": "AI视频/分镜/运镜提示词工程 skill (ai-video-storyboard-prompts) 已从A53资料蒸馏完成。覆盖即梦4.0/4.5、Seedance 2.0、SD。核心:5要素万能结构(主体+场景+运镜+分镜+负面)、10大经典运镜、10种情绪化运镜公式、50种精准模板(固定12+动态16+视角12+转场10)、九宫格分镜脚本、15种题材人物角色公式。触发词: 运镜/分镜/漫剧/镜头语言/画面构图。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1787713256190985052",
|
||||
"content": "[session_check] Session Key 有效性检查完成 - 2026-08-26 11:00 Asia/Shanghai\n当前活跃 session:16 个,覆盖 A01(main)、..."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791648351659433718",
|
||||
"content": "改完没变时先怀疑没重启而不是先确认是否只读一次。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791183732956639780",
|
||||
"content": "在决定是否重启服务前,必须先完成只读检查"
|
||||
},
|
||||
{
|
||||
"id": "mem_1793135296535762756",
|
||||
"content": "用户当前的状态是纯read-only,无改动。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791162724027223916",
|
||||
"content": "反面:修改后未 close/save 就允许其他进程读取,导致读到旧数据。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791658921678141314",
|
||||
"content": "依据消费方是实际运行时的判据,通用解析器或规范可能更严格,导致误报",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1792808725335640894",
|
||||
"content": "用户得出的结论是:归因错了要立刻换判据,不要换说法。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791551361679058872",
|
||||
"content": "反面案例为:旧版只靠关键词过滤,实测漏报(卡写的是「出处/交付」时未被识别)"
|
||||
},
|
||||
{
|
||||
"id": "mem_1792309087001754951",
|
||||
"content": "用户核心读写是通的,但「使用率」这类判据的量具长期是坏的。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791768670068692030",
|
||||
"content": "用户开关 `ZHIYI_SCORE_CACHE=0` 一行回退。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1793375311953035307",
|
||||
"content": "用户系统中无 ZHIYI_PATTERN_RECALL 配置"
|
||||
},
|
||||
{
|
||||
"id": "mem_1793364228280905481",
|
||||
"content": "用户决定不关闭ZHIYI_USAGE_TRACK"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791402407851571707",
|
||||
"content": "删除系统级 `zhiyi-backup.service`"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791788470636750667",
|
||||
"content": "补救姿势:停 kanban-daemon → 杀 worker → git checkout -- . 复位(先留抢救副本)→ kanban block 打回 → kanban link 串成链 → kanban unblock 落到 todo(父卡门控)→ 重启调度器",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791802868960000479",
|
||||
"content": "补救:停 kanban-daemon → 杀 worker → git checkout -- . 复位(先留抢救副本)→ kanban block 打回 → kanban link 串成链 → kanban unblock 落到 todo → 重启调度器"
|
||||
},
|
||||
{
|
||||
"id": "mem_1792085747695699334",
|
||||
"content": "结果为交付「告警→唤醒责任分身」通道(经牧尘两轮前提更正后重定):确认唯一唤醒路径 = kanban 建卡(assignee=责任分身)→调度器 spawn 分身 worker(notify+wake);origin/local/feishu/bot-chat 全是被动 notify 不唤醒。AC3 端到端实证:kanban-daemon(PID 3224504) 领走 assignee=prof-cl。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1792284525454906472",
|
||||
"content": "根因:kanban-daemon 用 systemd transient scope 派发 worker,worker 的 PPID=systemd(1267)、父链 worker→systemd→init(1) 中间不经过 kanban-daemon,哨兵 _is_kanban_daemon_worker 原「向上 6 层查父链 kanban daemon 标记」判据必然失配,把每个正常 kanban worker 误判为「未知写者」。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791878606153812088",
|
||||
"content": "否则基线缺该形态会导致误报缺失或基线滞后。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1791224552866727673",
|
||||
"content": "依据:指标可能只是配置或占位值,若底层未接线,指标正常是假象,掩盖系统实际未工作"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791551384671228579",
|
||||
"content": "用户发现同类 bug 可能存在基准算错问题。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791676986470060641",
|
||||
"content": "基准值需要独立算好写死,不能在卡里依赖动态计算或正则匹配嵌套数组来获取基准值。"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791900059384905968",
|
||||
"content": "用户建卡时没带 `parents`,导致两张卡同时 running、两个 prof-dev worker 同时改 `recall.go`",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1792268657637244950",
|
||||
"content": "依据:- `t_bd718662`(running, P1):`recall.go:846` `results[:-2]` 负索引 panic —— **既存 bug,非本次引入** ✓(panic 原文 20:42,早于部署;本批改动 0 处)"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791842568787216687",
|
||||
"content": "任务 t_91af03e9 状态为 running(05:37 起跑,prof-dev),todo 队列已空。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1793411911051714284",
|
||||
"content": "用户卡 t_1bb59796(prof-dev running) 进行代码修改"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "mem_1791900074696467609",
|
||||
"content": "没有 open parent 时 kanban_block 不会停在 todo,而是记成 needs_input。",
|
||||
"counterparts": [
|
||||
{
|
||||
"id": "mem_1789053881810464568",
|
||||
"content": "决定将复审结论升级为人工(blocked / needs_input),而非 approve 或 request_changes。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1789728394246816856",
|
||||
"content": "助手决定将任务 t_adcefe86 设为 blocked(needs_input),等待用户提供真实内容或进一步指示。"
|
||||
},
|
||||
{
|
||||
"id": "mem_1791536965771254112",
|
||||
"content": "牧尘决定看板通知「前期全部推给他」—— 保持 197 条订阅指向他的 DM,不收敛、不改成\"只推 blocked/needs_input\"。"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -373,6 +373,34 @@ func JudgeWriteOp(candidate string, similar []SimilarMemory, judge JudgeFunc, co
|
||||
}
|
||||
v = defaultJudge(candidate, similar, contradict)
|
||||
}
|
||||
// ── AC-1(t_e3f5cb79):CONFLICT-MARK 出口判据门 ──────────────────
|
||||
// 任何来源(rule / llm / 注入 judge)判出的 CONFLICT-MARK 必须过
|
||||
// 「同主体 + 同属性 + 不同取值」确定性门(conflict_gate.go)才算真矛盾。
|
||||
// 依据:24 条 conflict 存量人工判读 0/24 真矛盾(100% 误报,旧判据是相似
|
||||
// 检测器)。不过门 ⇒ 降级:sim≥更新阈值按 UPDATE 走(supersede 语义仍成立),
|
||||
// 否则按 ADD(两条都留,不造指针)。TargetID 指不到候选行(注入 judge 幻觉)
|
||||
// 同样按不过门处理。
|
||||
if v.Op == OpConflictMark {
|
||||
targetContent := ""
|
||||
for _, s := range similar {
|
||||
if s.ID == v.TargetID {
|
||||
targetContent = s.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if ev, ok := SameAttrValueConflict(v.Candidate, targetContent); ok {
|
||||
v.Reason = v.Reason + " [gate:" + ev + "]"
|
||||
} else {
|
||||
if v.Similarity >= ruleSimUpdate {
|
||||
v.Op = OpUpdate
|
||||
v.Reason = v.Reason + " [gate:非同属性不同取值 → 降级 UPDATE]"
|
||||
} else {
|
||||
v.Op = OpAdd
|
||||
v.TargetID = ""
|
||||
v.Reason = v.Reason + " [gate:非同属性不同取值 → 降级 ADD]"
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.Timestamp == "" {
|
||||
v.Timestamp = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
@@ -392,8 +420,10 @@ func JudgeWriteOp(candidate string, similar []SimilarMemory, judge JudgeFunc, co
|
||||
type WriteOpsStore interface {
|
||||
// MarkSuperseded 把旧记忆标记为「被 targetNewID 取代」(写 superseded_by + is_active=false)
|
||||
MarkSuperseded(oldID, targetNewID string) error
|
||||
// MarkConflict 把记忆标记为冲突(交人工;不删)
|
||||
MarkConflict(id string) error
|
||||
// MarkConflict 把「同主体+同属性+不同取值」确认成立的旧记忆标记为冲突(t_e3f5cb79):
|
||||
// 必须同写双向指针 —— 旧行 superseded_by=[newID] + freshness 置 superseded;
|
||||
// 新行 supersedes=[oldID] 由调用方在插入记录上带(同一批字段,禁造新字段)。
|
||||
MarkConflict(oldID, newID string) error
|
||||
}
|
||||
|
||||
// ApplyWriteOp 应用四操作判定到存储。返回是否应插入新记忆 + 错误。
|
||||
@@ -437,9 +467,11 @@ func ApplyWriteOp(v WriteVerdict, store WriteOpsStore, newID string) (shouldInse
|
||||
}
|
||||
return true, nil
|
||||
case OpConflictMark:
|
||||
// 旧条标 conflict + 新记忆也标 conflict(由调用方在 mem 上打标),仍插入供人工复核
|
||||
// AC-1.2(t_e3f5cb79):真矛盾(已过 SameAttrValueConflict 门)→ 旧行打标
|
||||
// + 双向指针(旧行 superseded_by=[newID] 在 store 内写;新行 supersedes=[oldID]
|
||||
// 由调用方插入时带上)。仍插入新条供召回对照(不静默丢)。
|
||||
if v.TargetID != "" && store != nil {
|
||||
if err := store.MarkConflict(v.TargetID); err != nil {
|
||||
if err := store.MarkConflict(v.TargetID, newID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,19 +13,22 @@ import (
|
||||
|
||||
// fakeStore 内存版 WriteOpsStore(记录被标记的操作,供 apply 断言)。
|
||||
type fakeStore struct {
|
||||
superseded map[string]string // oldID → newID
|
||||
conflicted map[string]bool
|
||||
superseded map[string]string // oldID → newID
|
||||
conflicted map[string]bool
|
||||
conflictPtr map[string]string // AC-1.2:oldID → newID(双向指针旧行半边)
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{superseded: map[string]string{}, conflicted: map[string]bool{}}
|
||||
return &fakeStore{superseded: map[string]string{}, conflicted: map[string]bool{},
|
||||
conflictPtr: map[string]string{}}
|
||||
}
|
||||
func (f *fakeStore) MarkSuperseded(oldID, newID string) error {
|
||||
f.superseded[oldID] = newID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) MarkConflict(id string) error {
|
||||
f.conflicted[id] = true
|
||||
func (f *fakeStore) MarkConflict(oldID, newID string) error {
|
||||
f.conflicted[oldID] = true
|
||||
f.conflictPtr[oldID] = newID
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ package governance
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -147,31 +148,40 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
|
||||
}
|
||||
|
||||
// 2. 创建实体间关系 + CO_OCCURS 共访边
|
||||
// t_20261102 小怡 · 合成边停写门(默认 0 = 停写):全库 622,804 边中这 4 类占
|
||||
// 619,825(99.5%),400 条抽样噪声率 88.5%,且 retrieval_count>0 恒 0(从未被消费
|
||||
// 加权);节点(§1)与确定性边(§3 CONFLICTS_WITH / §4 DERIVED_FROM 等)不受影响。
|
||||
// 恢复 = ZHIYI_GRAPH_AUTO_SYNTH=1(逐位回到改动前)。
|
||||
entityCount := len(validEnts)
|
||||
for i := 0; i < entityCount; i++ {
|
||||
for j := i + 1; j < entityCount; j++ {
|
||||
eidI := entityID(validEnts[i])
|
||||
eidJ := entityID(validEnts[j])
|
||||
// 防御:任何原因导致同 eid 时跳过,杜绝自环(所有 relation 建边口通用)
|
||||
if eidI == eidJ {
|
||||
continue
|
||||
}
|
||||
if !autoSynthEnabled() {
|
||||
log.Printf("[graph] auto-synth-off entities=%d episode=%s(不写 uses/related_to/configures/CO_OCCURS)",
|
||||
entityCount, distilled.EpisodeID)
|
||||
} else {
|
||||
for i := 0; i < entityCount; i++ {
|
||||
for j := i + 1; j < entityCount; j++ {
|
||||
eidI := entityID(validEnts[i])
|
||||
eidJ := entityID(validEnts[j])
|
||||
// 防御:任何原因导致同 eid 时跳过,杜绝自环(所有 relation 建边口通用)
|
||||
if eidI == eidJ {
|
||||
continue
|
||||
}
|
||||
|
||||
// 2a. 语义关系边(inferRelation)
|
||||
edgeID := fmt.Sprintf("e_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
|
||||
relation := inferRelation(validEnts[i], validEnts[j], distilled.Content)
|
||||
agu.graph.AddEdge(edgeID, eidI, eidJ,
|
||||
relation, distilled.Namespace, 0.5)
|
||||
// 2a. 语义关系边(inferRelation)
|
||||
edgeID := fmt.Sprintf("e_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
|
||||
relation := inferRelation(validEnts[i], validEnts[j], distilled.Content)
|
||||
agu.graph.AddEdge(edgeID, eidI, eidJ,
|
||||
relation, distilled.Namespace, 0.5)
|
||||
|
||||
// 2b. CO_OCCURS 共访边(§2.5.2 来源 2)
|
||||
// 权重 = 1 / sqrt(entityCount) — 实体越多单对权重越低
|
||||
coWeight := 1.0
|
||||
if entityCount > 2 {
|
||||
coWeight = 1.0 / sqrtFloat(float64(entityCount))
|
||||
// 2b. CO_OCCURS 共访边(§2.5.2 来源 2)
|
||||
// 权重 = 1 / sqrt(entityCount) — 实体越多单对权重越低
|
||||
coWeight := 1.0
|
||||
if entityCount > 2 {
|
||||
coWeight = 1.0 / sqrtFloat(float64(entityCount))
|
||||
}
|
||||
coEdgeID := fmt.Sprintf("co_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
|
||||
agu.graph.AddEdge(coEdgeID, eidI, eidJ,
|
||||
"CO_OCCURS", distilled.Namespace, coWeight)
|
||||
}
|
||||
coEdgeID := fmt.Sprintf("co_%s_%s_%d", eidI, eidJ, time.Now().UnixNano())
|
||||
agu.graph.AddEdge(coEdgeID, eidI, eidJ,
|
||||
"CO_OCCURS", distilled.Namespace, coWeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,8 +213,21 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
|
||||
// 目标节点从不经判据)。停写后血缘不丢,图节点不再膨胀。
|
||||
}
|
||||
|
||||
// autoSynthEnabled 合成边(uses / related_to / configures / CO_OCCURS)写入门。
|
||||
// 默认 0 = 停写(t_20261102 小怡);ZHIYI_GRAPH_AUTO_SYNTH=1 逐位恢复改动前行为。
|
||||
func autoSynthEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("ZHIYI_GRAPH_AUTO_SYNTH"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RecordCoOccurrence record recall 后的共访关系
|
||||
func (agu *AutoGraphUpdater) RecordCoOccurrence(query, namespace string, resultIDs []string) {
|
||||
if !autoSynthEnabled() {
|
||||
return // 合成边停写门(默认 0)
|
||||
}
|
||||
// 在知识图谱中创建 CO_OCCURS 关系
|
||||
for i := 0; i < len(resultIDs); i++ {
|
||||
for j := i + 1; j < len(resultIDs); j++ {
|
||||
@@ -222,32 +245,35 @@ func (agu *AutoGraphUpdater) RecordCoOccurrence(query, namespace string, resultI
|
||||
// processSources 来源类分档(过程/会话/迁移层):命中则实体/概念不写图。
|
||||
// 2026-10-12 t_67d0279a 只枚举 {"worklog-note","worklog-audit"},天然漏活体主路径——
|
||||
// 本表由实测 memories.lance 35,427 行 source 分布驱动(2026-10-13,数据定名单非照抄):
|
||||
// llm_distill 3717 / wiki:skill_rule_extractor_v2 4406 / wiki:a06_skill_rule_tierA 2650
|
||||
// / wiki:session-compress 494 / legacy:pre0926 12099(迁移层)
|
||||
//
|
||||
// llm_distill 3717 / wiki:skill_rule_extractor_v2 4406 / wiki:a06_skill_rule_tierA 2650
|
||||
// / wiki:session-compress 494 / legacy:pre0926 12099(迁移层)
|
||||
//
|
||||
// 这些是「过程/会话」层,不是「领域知识」层;图谱只承载领域概念。
|
||||
// ⚠️ 前缀归一(TrimPrefix "wiki:")后比对,与 auditSource 同规;库内真实值经
|
||||
// srcOrLLMDistill 落成带前缀形式([srcprobe] 实测),裸串相等永远 miss。
|
||||
var processSources = []string{
|
||||
"", // 空 source:sub-agent 会话蒸馏常无出处标记
|
||||
"llm_distill", // 无出处蒸馏的兜底名(srcOrLLMDistill 返回)
|
||||
"session-compress", // 会话压缩蒸馏产物
|
||||
"", // 空 source:sub-agent 会话蒸馏常无出处标记
|
||||
"llm_distill", // 无出处蒸馏的兜底名(srcOrLLMDistill 返回)
|
||||
"session-compress", // 会话压缩蒸馏产物
|
||||
"skill_rule_extractor_v2", // 技能规则提取器(过程层)
|
||||
"a06_skill_rule_tiera", // a06 技能规则 tierA(过程层,normalizeSrc 已 ToLower)
|
||||
"a06_prefix_test", // a06 前缀探针
|
||||
"a06_probe_direct", // a06 直连探针
|
||||
"a06_distill_probe", // a06 蒸馏探针
|
||||
"a06_ns_test_1005", // a06 ns 测试
|
||||
"legacy:pre0926", // 迁移/历史层(2026-09-26 前导入)
|
||||
"worklog-note", // 审计/工作流(1b 已立,保留)
|
||||
"worklog-audit", // 审计/工作流(1b 已立,保留)
|
||||
"legacy:pre0926", // 迁移/历史层(2026-09-26 前导入)
|
||||
"worklog-note", // 审计/工作流(1b 已立,保留)
|
||||
"worklog-audit", // 审计/工作流(1b 已立,保留)
|
||||
}
|
||||
|
||||
// subAgentNamespaces 纯分身会话 namespace:命中则实体/概念不写图。
|
||||
// ⚠️ 不做「*-main 一刀切」——实测 a06-main(541 条 vault 领域知识)与 hermes-main
|
||||
// (大量 wiki:vault)承载真实领域概念,整 namespace 拦截会误杀(AC3)。
|
||||
// 只拦实测 100% 过程/会话/legacy 的纯分身会话 namespace(数据驱动,2026-10-13):
|
||||
// prof-dev-main / prof-cloud-main / openclaw-main / wiki-curator-main / soulful-xiaowei
|
||||
// —— 全部 source 均为 session-compress / legacy,无任何领域知识。
|
||||
//
|
||||
// prof-dev-main / prof-cloud-main / openclaw-main / wiki-curator-main / soulful-xiaowei
|
||||
// —— 全部 source 均为 session-compress / legacy,无任何领域知识。
|
||||
var subAgentNamespaces = []string{
|
||||
"prof-dev-main",
|
||||
"prof-cloud-main",
|
||||
@@ -290,6 +316,7 @@ func inSubAgentNamespace(ns string) bool {
|
||||
// - 自ID 前缀(ep_/f_/n_/mem_/e_/co_/cw_/df_)→ 永不进图(AC2,本批 ep_ 成了 entity)
|
||||
// - AC 编号(AC\d+)、工具动词(grep/ingest dry-run/健康检查/部署流程/测试结果)
|
||||
// - "证据:无证据" 自述模板
|
||||
//
|
||||
// ⚠️ 不做「方案代号一刀切」——方案A/B 是领域决策常用概念(AC4 平台决策 14 保留),
|
||||
// 只拦「证据:无证据」这类纯自述模板 + AC 编号 + 工具动词 + 自ID 前缀。
|
||||
var noiseEntityRe = regexp.MustCompile(`(?i)^(ac\d+|ep_|f_|n_|mem_|e_|co_|cw_|df_|t_[0-9a-f]{6,}|.*(证据[::]无证据|ingest.?dry.?run|健康检查|部署流程|测试结果|代码修改|grep).*|.*(看板|kanban|任务).*(结案|发车|状态|归档|词汇|过程|卡|process).*)$`)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package governance
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMain 让既有测试保持「改动前」语义(它们断言的是合成边确实写入)。
|
||||
// 门关行为由 TestAutoSynthGate_Off_NoSyntheticEdges 单独用 t.Setenv 验证。
|
||||
func TestMain(m *testing.M) {
|
||||
if _, ok := os.LookupEnv("ZHIYI_GRAPH_AUTO_SYNTH"); !ok {
|
||||
os.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "1")
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// TestAutoSynthGate_Off_NoSyntheticEdges 门关(默认)⇒ 不写 uses/related_to/configures/CO_OCCURS,
|
||||
// 但 §1 节点创建不受影响。
|
||||
func TestAutoSynthGate_Off_NoSyntheticEdges(t *testing.T) {
|
||||
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
|
||||
g := NewInMemoryGraph()
|
||||
up := NewAutoGraphUpdater(g)
|
||||
up.UpdateFromDistill(&DistillInput{
|
||||
EpisodeID: "ep_gate_off_1",
|
||||
Content: "配置 tailscale docker nginx",
|
||||
Entities: []string{"Docker", "Nginx", "Tailscale"},
|
||||
Namespace: "ns_gate_off",
|
||||
Source: "wiki:vault-ingest",
|
||||
})
|
||||
|
||||
g.mu.RLock()
|
||||
total := len(g.edges)
|
||||
g.mu.RUnlock()
|
||||
if total != 0 {
|
||||
t.Fatalf("门关时应新增 0 条边,实际 %d 条", total)
|
||||
}
|
||||
if n := len(g.ListNodes("ns_gate_off")); n == 0 {
|
||||
t.Fatalf("门关不应影响 §1 节点创建,实际 0 个节点")
|
||||
}
|
||||
|
||||
// RecordCoOccurrence 同样受门控(零调用死代码,门关必须返回)
|
||||
up.RecordCoOccurrence("q", "ns_gate_off", []string{"n_a", "n_b"})
|
||||
g.mu.RLock()
|
||||
total = len(g.edges)
|
||||
g.mu.RUnlock()
|
||||
if total != 0 {
|
||||
t.Fatalf("门关时 RecordCoOccurrence 不应写边,实际 %d 条", total)
|
||||
}
|
||||
}
|
||||
@@ -611,11 +611,53 @@ func (gs *SQLiteGraphStore) CountNodesByType(typ string) (total, withID int, las
|
||||
return
|
||||
}
|
||||
|
||||
// pruneGraceDays 孤点删除宽限期(天)。t_31bb2992(2026-11-02)背景:
|
||||
// ZHIYI_GRAPH_AUTO_SYNTH 门关后 §1 仍建实体节点、§2 不建边 ⇒ 新建节点天然孤点;
|
||||
// 旧版 Prune 无条件删孤点且零日志,曾一次性静默删 93.6% 节点(41,371→2,630)。
|
||||
// AddNode 是 INSERT OR REPLACE(created_at 每次刷新),故宽限期实际语义 =
|
||||
// 「连续 N 天没被蒸馏引用过的孤点才删」。ZHIYI_GRAPH_PRUNE_GRACE_DAYS=0 回到旧行为。
|
||||
func pruneGraceDays() int {
|
||||
if v := strings.TrimSpace(os.Getenv("ZHIYI_GRAPH_PRUNE_GRACE_DAYS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 14
|
||||
}
|
||||
|
||||
func (gs *SQLiteGraphStore) Prune(minWeight float64) {
|
||||
gs.mu.Lock()
|
||||
defer gs.mu.Unlock()
|
||||
|
||||
// 删除前预告(t_31bb2992):把「会删多少、删的是谁」先写进日志,
|
||||
// 杜绝再次出现「静默删 93.6%」事后才从行数差发现的事故。
|
||||
grace := pruneGraceDays()
|
||||
// created_at 为空的行按最老处理(可删);id IS NULL 的行因 SQL 三值逻辑
|
||||
// 对 NOT IN 天然免疫,维持既有行为(pattern_stat 等 85 行不受影响)。
|
||||
// grace<=0 = 逃生阀:完全不加时间条件,逐位回到旧行为(同秒新建也照删)。
|
||||
orphanWhere := `id NOT IN (SELECT DISTINCT source FROM graph_edges UNION SELECT DISTINCT target FROM graph_edges)`
|
||||
if grace > 0 {
|
||||
orphanWhere += fmt.Sprintf(
|
||||
" AND COALESCE(NULLIF(created_at,''),'0000-00-00 00:00:00') < datetime('now','-%d day')", grace)
|
||||
}
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_edges WHERE weight < "+fmt.Sprintf("%f", minWeight)); n > 0 {
|
||||
log.Printf("[graph] prune will delete %d edges (weight < %.2f)", n, minWeight)
|
||||
}
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE "+orphanWhere); n > 0 {
|
||||
sample := ""
|
||||
if rows := queryRows(gs.db, "SELECT id, name FROM graph_nodes WHERE "+orphanWhere+" LIMIT 5"); len(rows) > 0 {
|
||||
names := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
names = append(names, fmt.Sprintf("%v(%v)", r["name"], r["id"]))
|
||||
}
|
||||
sample = strings.Join(names, ", ")
|
||||
}
|
||||
log.Printf("[graph] prune will delete %d orphan nodes (grace=%dd, minWeight=%.2f), sample=[%s]",
|
||||
n, grace, minWeight, sample)
|
||||
}
|
||||
|
||||
execSQL(gs.db, fmt.Sprintf("DELETE FROM graph_edges WHERE weight < %f", minWeight))
|
||||
execSQL(gs.db, `DELETE FROM graph_nodes WHERE id NOT IN (SELECT DISTINCT source FROM graph_edges UNION SELECT DISTINCT target FROM graph_edges)`)
|
||||
execSQL(gs.db, "DELETE FROM graph_nodes WHERE "+orphanWhere)
|
||||
}
|
||||
|
||||
// CleanupNoiseNodes 删除名称含编码噪音的节点(如 "n_fts=517," "n_fts(sqlite)+")
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package governance
|
||||
|
||||
// t_31bb2992 · Prune 孤点宽限期 + 删除前日志 单测
|
||||
// 背景:门关(ZHIYI_GRAPH_AUTO_SYNTH=0)后 §1 建点、§2 不建边 ⇒ 新点皆孤点;
|
||||
// 旧版 Prune 无条件删孤点且零日志(生产曾静默删 41,371→2,630)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTempGraphStore(t *testing.T) *SQLiteGraphStore {
|
||||
t.Helper()
|
||||
gs, err := NewSQLiteGraphStore(filepath.Join(t.TempDir(), "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteGraphStore: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { gs.Close() })
|
||||
return gs
|
||||
}
|
||||
|
||||
// 宽限期:门关时新建的孤点,宽限期内 Prune 不得删;超过宽限期才删;
|
||||
// grace=0 是逃生阀 = 旧行为(立即删)。
|
||||
func TestPruneGracePeriod_OrphanSurvival(t *testing.T) {
|
||||
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
|
||||
gs := newTempGraphStore(t)
|
||||
up := NewAutoGraphUpdater(gs)
|
||||
up.UpdateFromDistill(&DistillInput{
|
||||
EpisodeID: "ep_grace_1",
|
||||
Content: "宽限期 单测",
|
||||
Entities: []string{"孤点甲", "孤点乙"},
|
||||
Namespace: "ns_grace",
|
||||
Source: "wiki:vault-ingest",
|
||||
})
|
||||
|
||||
// ① 新孤点在 14d 宽限期内 ⇒ Prune 后仍在
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_grace'"); n != 2 {
|
||||
t.Fatalf("宽限期内新孤点应保留 2 个,实际 %d", n)
|
||||
}
|
||||
|
||||
// ② 把 created_at 推到 15 天前 ⇒ 出宽限期,Prune 删除
|
||||
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_grace'")
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_grace'"); n != 0 {
|
||||
t.Fatalf("超宽限期孤点应被删除,实际剩 %d", n)
|
||||
}
|
||||
|
||||
// ③ 逃生阀 grace=0 ⇒ 新孤点也被删(回旧行为)
|
||||
if err := gs.AddNode("n_grace_zero", "逃生阀点", "entity", "ns_grace0"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
t.Setenv("ZHIYI_GRAPH_PRUNE_GRACE_DAYS", "0")
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE id='n_grace_zero'"); n != 0 {
|
||||
t.Fatalf("grace=0 应回到旧行为(即删新孤点),实际仍在")
|
||||
}
|
||||
|
||||
// ④ 有边引用的节点任何情况下都不被孤点删除误伤
|
||||
if err := gs.AddNode("n_anchor_a", "甲", "entity", "ns_edge"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
if err := gs.AddNode("n_anchor_b", "乙", "entity", "ns_edge"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
if err := gs.AddEdge("e_anchor", "n_anchor_a", "n_anchor_b", "CONFLICTS_WITH", "ns_edge", 0.9); err != nil {
|
||||
t.Fatalf("AddEdge: %v", err)
|
||||
}
|
||||
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_edge'")
|
||||
gs.Prune(0.15)
|
||||
if n := queryInt(gs.db, "SELECT COUNT(*) FROM graph_nodes WHERE namespace='ns_edge'"); n != 2 {
|
||||
t.Fatalf("有边端点不应被删,实际剩 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除前日志:Prune 删孤点前必须打「will delete N orphan nodes + 抽样」,
|
||||
// 让静默大规模删点以后在日志里看得见。
|
||||
func TestPruneLogsOrphanDeletionPreview(t *testing.T) {
|
||||
t.Setenv("ZHIYI_GRAPH_AUTO_SYNTH", "0")
|
||||
gs := newTempGraphStore(t)
|
||||
for _, e := range []struct{ id, name string }{
|
||||
{"n_log_1", "日志点一"}, {"n_log_2", "日志点二"},
|
||||
} {
|
||||
if err := gs.AddNode(e.id, e.name, "concept", "ns_log"); err != nil {
|
||||
t.Fatalf("AddNode: %v", err)
|
||||
}
|
||||
}
|
||||
execSQL(gs.db, "UPDATE graph_nodes SET created_at='2020-01-01 00:00:00' WHERE namespace='ns_log'")
|
||||
|
||||
var buf bytes.Buffer
|
||||
old := log.Writer()
|
||||
log.SetOutput(&buf)
|
||||
defer log.SetOutput(old)
|
||||
|
||||
gs.Prune(0.15)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "[graph] prune will delete 2 orphan nodes") {
|
||||
t.Fatalf("日志缺少 will-delete 计数行,实际输出:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"sample=", "日志点一", "n_log_1", "grace=14d"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("日志缺少 %q,实际输出:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// 无可删对象时不得虚报
|
||||
buf.Reset()
|
||||
gs.Prune(0.15)
|
||||
if strings.Contains(buf.String(), "prune will delete") {
|
||||
t.Fatalf("无可删对象时不应打删除预告,实际:\n%s", buf.String())
|
||||
}
|
||||
_ = os.Getenv // keep imports stable if env helpers change
|
||||
}
|
||||
+326
-67
@@ -6,8 +6,9 @@
|
||||
// type='pattern_stat',id 全 NULL、语义为 L1 命名空间统计快照,非 L2 pattern / 2026-09-18)。本包把 L1→L2 蒸馏
|
||||
// 复活成**短命单发**(modernc sqlite 直连 graph.db,开/关不占长连接,不碰 state.db),
|
||||
// 并按设计 §4.1 叠加两条检测规则:
|
||||
// - 工具组合频率:同一 tool 组合出现 ≥3 次 → tool_combo pattern
|
||||
// (confidence = min(freq*0.1, 1.0))
|
||||
// - 工具组合频率:同一 tool 组合(成员 ∈ knownTools 白名单、≥2 成员、排除通用词)
|
||||
// 被 ≥2 条独立来源记忆支撑 → tool_combo pattern(confidence = min(freq*0.1, 1.0));
|
||||
// 落库另需 confidence ≥ MinWriteConfidence=0.5(判据收紧史见 t_639c5a03)
|
||||
// - 相似解决路径聚类:cosine ≥0.85 成簇且簇内 ≥3 条 → workflow pattern
|
||||
//
|
||||
// 存储:复用现有 graph_nodes(type='pattern'),**不另开新表**(避免与现存图谱查询割裂;
|
||||
@@ -37,37 +38,102 @@ type Pattern struct {
|
||||
|
||||
// Config 检测参数(设计 §4.1)。
|
||||
type Config struct {
|
||||
MinComboFreq int // 工具组合出现 ≥ N 次才产 pattern(默认 3)
|
||||
MinComboFreq int // 工具组合需 ≥ N 条**独立来源记忆**才产 pattern(默认 2,t_639c5a03 门槛)
|
||||
ClusterThresh float64 // 相似路径聚类 cosine 阈值(默认 0.85)
|
||||
MinClusterSize int // 簇内 ≥ N 条才产 workflow pattern(默认 3)
|
||||
MaxRecords int // 单次扫描参与检测的记忆上限(防 O(n²) 爆炸,默认 500)
|
||||
TopKPatternList int // 召回/列出取前 N(默认 10)
|
||||
// WorkflowPatterns 是否启用「相似解决路径聚类」workflow 分支。
|
||||
// t_639c5a03 三审(小怡 2026-10-30)后决定**默认退役**:该分支把簇内最长记忆正文当
|
||||
// Data(truncate 120 字),在自然语言语料上产出即「整句记忆正文 conf=1.0」的伪 pattern,
|
||||
// 违反「不许把记忆正文当 pattern name」硬约束。通道退役,默认关闭;仅当显式开启才跑。
|
||||
WorkflowPatterns bool
|
||||
}
|
||||
|
||||
// DefaultConfig 设计参数。
|
||||
// DefaultConfig 设计参数。WorkflowPatterns 默认 false = workflow 分支退役(t_639c5a03 三审)。
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
MinComboFreq: 3,
|
||||
ClusterThresh: 0.85,
|
||||
MinClusterSize: 3,
|
||||
MaxRecords: 500,
|
||||
TopKPatternList: 10,
|
||||
MinComboFreq: 2,
|
||||
ClusterThresh: 0.85,
|
||||
MinClusterSize: 3,
|
||||
MaxRecords: 500,
|
||||
TopKPatternList: 10,
|
||||
WorkflowPatterns: false,
|
||||
}
|
||||
}
|
||||
|
||||
// knownTools 常见工具/CLI/命令名(小写)。工具组合频率分析从这里识别 + 兜底正则。
|
||||
// MinWriteConfidence 落库置信度门槛(小怡二轮裁定 #5,2026-10-30):conf<0.5 一律不写库。
|
||||
// tool_combo 的 conf=来源数×0.1 ⇒ 实际要求 ≥5 条独立来源;workflow 簇需平均相似度 ≥0.5。
|
||||
const MinWriteConfidence = 0.5
|
||||
|
||||
// knownTools 真实命令/CLI 名(小写)白名单 —— tool_combo 成员的**唯一**来源。
|
||||
// t_639c5a03 二轮(小怡 2026-10-30 裁定):原"含 -/数字即可"的命令式标识兜底放行是
|
||||
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
|
||||
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除;白名单外一律不进组合键。
|
||||
// 同时移除过于通用的条目(git/go/curl/grep/cat/ls/find/…,见 genericTools)。
|
||||
// 新增工具 = 改这张表(可审计),不再靠形态猜测。
|
||||
var knownTools = map[string]bool{
|
||||
"jq": true, "yq": true, "git": true, "grep": true, "sed": true, "awk": true,
|
||||
"curl": true, "wget": true, "tar": true, "zip": true, "unzip": true,
|
||||
"jq": true, "yq": true, "sed": true, "awk": true,
|
||||
"wget": true, "tar": true, "zip": true, "unzip": true,
|
||||
"docker": true, "kubectl": true, "helm": true, "npm": true, "pip": true,
|
||||
"go": true, "python": true, "python3": true, "node": true, "pnpm": true,
|
||||
"python": true, "python3": true, "node": true, "pnpm": true,
|
||||
"make": true, "cmake": true, "gcc": true, "gcc-12": true, "clang": true,
|
||||
"sqlite3": true, "psql": true, "mysql": true, "redis-cli": true,
|
||||
"ssh": true, "scp": true, "rsync": true, "systemctl": true, "journalctl": true,
|
||||
"systemd-run": true, "crontab": true, "find": true, "rg": true, "ripgrep": true,
|
||||
"fd": true, "fzf": true, "gh": true, "gcloud": true, "aws": true, "jq2": true,
|
||||
"hermes": true, "zhiyi": true, "obsidian": true, "esbuild": true, "ts": true,
|
||||
"systemd-run": true, "crontab": true, "rg": true, "ripgrep": true,
|
||||
"fd": true, "fzf": true, "gh": true, "gcloud": true, "aws": true,
|
||||
"hermes": true, "zhiyi": true, "obsidian": true, "esbuild": true,
|
||||
"tsc": true, "cargo": true, "rustc": true, "swift": true, "brew": true,
|
||||
"bash": true, "sh": true, "zsh": true, "openssl": true,
|
||||
"flock": true, "nc": true,
|
||||
"java": true, "javac": true, "mvn": true, "gradle": true, "dotnet": true,
|
||||
"remotion": true, "uv": true, "pytest": true, "gofmt": true, "golangci-lint": true,
|
||||
}
|
||||
|
||||
// genericTools 过于通用的词/命令(小怡二轮裁定 #4 点名 git/go/curl/grep/bot-chat/mcp/api「等」):
|
||||
// 几乎任何任务都命中 ⇒ 进 pattern 等于往召回 top-3 灌噪声。即使形态合格也**绝不**作为组合成员。
|
||||
// md/json/py/api/log/db 等亦在 toolStopwords 覆盖内,这里重复登记作双保险。
|
||||
// 后半段是 shell 基础词(cat/ls/find/…,「等」的裁量延伸:与 git 同级别的散文高频词)。
|
||||
var genericTools = map[string]bool{
|
||||
"git": true, "go": true, "curl": true, "grep": true, "bot-chat": true,
|
||||
"mcp": true, "api": true, "md": true, "json": true, "py": true,
|
||||
"cat": true, "ls": true, "cp": true, "mv": true, "rm": true, "mkdir": true,
|
||||
"head": true, "tail": true, "sort": true, "uniq": true, "wc": true, "find": true,
|
||||
"xargs": true, "kill": true, "ps": true, "chmod": true, "touch": true,
|
||||
"diff": true, "echo": true, "ping": true, "lsof": true, "tar": true,
|
||||
}
|
||||
|
||||
// commonExts 常见文件扩展名(小写,含点)。token 以此结尾 ⇒ 文件名/扩展名,不是工具。
|
||||
var commonExts = map[string]bool{
|
||||
".md": true, ".go": true, ".json": true, ".py": true, ".js": true, ".ts": true,
|
||||
".tsx": true, ".jsx": true, ".yaml": true, ".yml": true, ".toml": true, ".sh": true,
|
||||
".db": true, ".sql": true, ".csv": true, ".log": true, ".txt": true, ".html": true,
|
||||
".css": true, ".rs": true, ".c": true, ".h": true, ".cpp": true, ".conf": true,
|
||||
".xml": true, ".ini": true, ".env": true, ".pdf": true, ".png": true, ".jpg": true,
|
||||
".jpeg": true, ".svg": true, ".gif": true, ".ipynb": true, ".lock": true, ".sum": true,
|
||||
".bak": true, ".tmp": true, ".gz": true, ".mp4": true, ".mp3": true, ".docx": true,
|
||||
".xlsx": true, ".pptx": true, ".service": true, ".sock": true, ".socket": true,
|
||||
}
|
||||
|
||||
// toolStopwords 常见停用词(小写):高频非工具词,绝不作为工具名(卡要求第 1 条)。
|
||||
var toolStopwords = map[string]bool{
|
||||
"the": true, "and": true, "for": true, "with": true, "that": true, "this": true,
|
||||
"from": true, "into": true, "then": true, "when": true, "what": true, "they": true,
|
||||
"have": true, "will": true, "your": true, "not": true, "but": true, "all": true,
|
||||
"can": true, "use": true, "via": true, "per": true, "are": true, "was": true,
|
||||
"one": true, "two": true, "ok": true, "yes": true, "true": true, "false": true,
|
||||
"null": true, "none": true, "etc": true, "vs": true, "id": true, "ids": true,
|
||||
"ns": true, "log": true, "logs": true, "file": true, "files": true, "path": true,
|
||||
"line": true, "lines": true, "name": true, "type": true, "data": true, "time": true,
|
||||
"date": true, "test": true, "note": true, "step": true, "steps": true, "first": true,
|
||||
"last": true, "next": true, "top": true, "api": true, "http": true, "https": true,
|
||||
"url": true, "uri": true, "json": true, "yaml": true, "yml": true, "md": true,
|
||||
"txt": true, "tmp": true, "dev": true, "prod": true, "src": true, "doc": true,
|
||||
"docs": true, "ref": true, "config": true, "default": true, "error": true,
|
||||
"errors": true, "warn": true, "info": true, "debug": true, "fail": true,
|
||||
"failed": true, "success": true, "start": true, "stop": true, "check": true,
|
||||
"list": true, "get": true, "set": true, "new": true, "old": true, "fix": true,
|
||||
"pr": true, "mr": true, "db": true,
|
||||
}
|
||||
|
||||
// DetectFromRecords 纯检测核心:从 L1 记忆列表发现重复模式。
|
||||
@@ -75,7 +141,7 @@ var knownTools = map[string]bool{
|
||||
// 返回合并去重后的 pattern 列表(同 Name 合并 source_tasks、取更高 confidence)。
|
||||
func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
|
||||
if cfg.MinComboFreq <= 0 {
|
||||
cfg.MinComboFreq = 3
|
||||
cfg.MinComboFreq = 2
|
||||
}
|
||||
if cfg.MinClusterSize <= 0 {
|
||||
cfg.MinClusterSize = 3
|
||||
@@ -129,21 +195,36 @@ func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
|
||||
c.tasks = append(c.tasks, rec.ID)
|
||||
}
|
||||
for name, c := range comboCount {
|
||||
if len(c.tasks) < cfg.MinComboFreq {
|
||||
// 二轮:「组合」语义 —— 单工具无共现信息量,≥2 个成员才产 tool_combo。
|
||||
if len(c.tools) < 2 {
|
||||
continue
|
||||
}
|
||||
// t_639c5a03 门槛:支撑数按**独立来源记忆**(去重且非空 ID)计,< MinComboFreq 直接丢。
|
||||
seen := map[string]bool{}
|
||||
var sources []string
|
||||
for _, id := range c.tasks {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
sources = append(sources, id)
|
||||
}
|
||||
}
|
||||
if len(sources) < cfg.MinComboFreq {
|
||||
continue
|
||||
}
|
||||
merge(Pattern{
|
||||
Type: "tool_combo",
|
||||
Name: name,
|
||||
Data: strings.Join(c.tools, "+"),
|
||||
Tools: c.tools,
|
||||
Confidence: confidenceFromFreq(len(c.tasks)),
|
||||
SourceTasks: c.tasks,
|
||||
Type: "tool_combo",
|
||||
Name: name,
|
||||
Data: strings.Join(c.tools, "+"),
|
||||
Tools: c.tools,
|
||||
Confidence: confidenceFromFreq(len(sources)),
|
||||
SourceTasks: sources,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 相似解决路径聚类(cosine ≥ 阈值 成簇,簇内 ≥ N 条)
|
||||
paths := clusterPaths(records, cfg.ClusterThresh, cfg.MinClusterSize)
|
||||
// t_639c5a03 三审:该分支把记忆正文当 pattern Data(整句注入),默认退役;仅 cfg.WorkflowPatterns 显式开启才跑。
|
||||
if cfg.WorkflowPatterns {
|
||||
paths := clusterPaths(records, cfg.ClusterThresh, cfg.MinClusterSize)
|
||||
for _, cluster := range paths {
|
||||
// 代表路径 = 簇内最长 content(共同步骤近似)
|
||||
rep := ""
|
||||
@@ -168,6 +249,7 @@ func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
|
||||
SourceTasks: ids,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Pattern, 0, len(order))
|
||||
for _, n := range order {
|
||||
@@ -198,6 +280,103 @@ func confidenceFromFreq(freq int) float64 {
|
||||
return c
|
||||
}
|
||||
|
||||
// isNumericUnitFragment 数值+单位碎片(小怡二轮裁定 #2):^\d+(\.\d+)?(mb|kb|gb|tb|ms|ns|us|s|m|h|x|%)?$
|
||||
// 例:"2.6mb"、"512kb"、"100ms"、"3x"。纯数字无单位形态由 isNumericFragment 拦截,这里不重复依赖它。
|
||||
func isNumericUnitFragment(tok string) bool {
|
||||
i := 0
|
||||
for i < len(tok) && tok[i] >= '0' && tok[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return false // 无数字前缀 ⇒ 不是该形态
|
||||
}
|
||||
if i < len(tok) && tok[i] == '.' {
|
||||
j := i + 1
|
||||
for j < len(tok) && tok[j] >= '0' && tok[j] <= '9' {
|
||||
j++
|
||||
}
|
||||
if j == i+1 {
|
||||
return false // '.' 后无数字 ⇒ 不是该形态
|
||||
}
|
||||
i = j
|
||||
}
|
||||
switch strings.ToLower(tok[i:]) {
|
||||
case "":
|
||||
return true // 纯数字
|
||||
case "mb", "kb", "gb", "tb", "ms", "ns", "us", "s", "m", "h", "x", "%":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isHexOnly token 是否只由 [0-9a-f] 构成(非空)。
|
||||
func isHexOnly(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isHashFragment hash/uuid 碎片(小怡二轮裁定 #1):
|
||||
// - 纯 [0-9a-f] 且 len≥6 ⇒ hash 片段(374b20f98c44 / b713b48a70ad / 635dccc0);
|
||||
// - 纯 [0-9a-f] 且 len≥4 且含数字 ⇒ 短 hash(ac10);'cafe'/'beef' 类纯字母词放行;
|
||||
// - 形如 <hex≥2>-<hex≥2> 前缀 ⇒ uuid 段(^[0-9a-f]{2,}-[0-9a-f]{2,},如 b713-48a7)。
|
||||
// 'gcc-12'(gcc 非 hex)、'redis-cli'(r 非 hex)不受影响。
|
||||
func isHashFragment(tok string) bool {
|
||||
if idx := strings.IndexByte(tok, '-'); idx > 0 {
|
||||
head, rest := tok[:idx], tok[idx+1:]
|
||||
tail := rest
|
||||
if k := strings.IndexByte(rest, '-'); k >= 0 {
|
||||
tail = rest[:k]
|
||||
}
|
||||
if len(head) >= 2 && isHexOnly(head) && len(tail) >= 2 && isHexOnly(tail) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if !isHexOnly(tok) {
|
||||
return false
|
||||
}
|
||||
if len(tok) >= 6 {
|
||||
return true
|
||||
}
|
||||
return len(tok) >= 4 && strings.ContainsAny(tok, "0123456789")
|
||||
}
|
||||
|
||||
// isNumberedDirName 数字编号目录名(小怡二轮裁定 #3):^\d+(-[a-z]+)+$
|
||||
// 例:"05-ai-agent"、"38-patternfix"。"gcc-12"(首段非数字)不受影响。
|
||||
func isNumberedDirName(tok string) bool {
|
||||
parts := strings.Split(tok, "-")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
first := parts[0]
|
||||
if first == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(first); i++ {
|
||||
if first[i] < '0' || first[i] > '9' {
|
||||
return false // 首段非纯数字
|
||||
}
|
||||
}
|
||||
for _, seg := range parts[1:] {
|
||||
if seg == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(seg); i++ {
|
||||
if seg[i] < 'a' || seg[i] > 'z' {
|
||||
return false // 某段含数字/非法字符
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// clusterPaths 贪心成簇:为每条未分配记忆开一个簇,把与其 cosine≥阈值 的其余未分配
|
||||
// 记忆并入;最终只保留 size≥minSize 的簇。向量缺失时用记录自带 Vector,全缺则退到
|
||||
// 文本 bigram Jaccard(确定性,不依赖网络)。
|
||||
@@ -276,35 +455,104 @@ func clusterConfidence(cluster []models.MemoryRecord) float64 {
|
||||
return sum / float64(pairs)
|
||||
}
|
||||
|
||||
// ExtractTools 从记忆内容提取工具/命令名。识别 knownTools + 兜底 ascii token。
|
||||
// ExtractTools 从记忆内容提取工具/命令名(t_639c5a03 二轮:白名单唯一来源)。
|
||||
// 判据(卡·必须实现 #1「来自 tool_use 记录或白名单」+ 小怡二轮"更硬判据"取向):
|
||||
// 成员必须**命中 knownTools 白名单**(词边界),且过 isValidTool 形态闸(通用词黑名单、
|
||||
// hash/uuid 片段、数值+单位、数字编号目录名、扩展名、日期/版本片段、停用词等)。
|
||||
//
|
||||
// 旧实现的"命令式标识"兜底(白名单外、含 -/数字即放行)是二轮 dry-run 里
|
||||
// hash 片段(374b20f98c44/ac10)、目录名(05-ai-agent)、文件名(dispatch-backlog.jsonl)、
|
||||
// CVE/协议碎片(cve-2026-99999/ipv4)的进入路径,已整体移除。
|
||||
// 确定性:结果排序去重。
|
||||
func ExtractTools(content string) []string {
|
||||
found := map[string]bool{}
|
||||
lower := strings.ToLower(content)
|
||||
out := make([]string, 0, 4)
|
||||
for t := range knownTools {
|
||||
// 词边界匹配(避免 "go" 命中 "google")
|
||||
if tokenPresent(lower, t) {
|
||||
found[t] = true
|
||||
if isValidTool(t) && tokenPresent(lower, t) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
// 兜底:捕获已知列表外的 ascii 命令式 token(2~14 字符,含 - 的命令风格)
|
||||
for _, tok := range regexTokenExtract(content) {
|
||||
lt := strings.ToLower(tok)
|
||||
if len(lt) < 2 {
|
||||
continue
|
||||
}
|
||||
// 排除明显非工具的常见词(纯小写短词且不在 knownTools 的放行——保持宽松,
|
||||
// 让工具组合信号进来;过噪由 MinComboFreq 过滤)
|
||||
found[lt] = true
|
||||
}
|
||||
out := make([]string, 0, len(found))
|
||||
for t := range found {
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// isValidTool token 形态判据(lowercase):是否为真实命令/工具标识的形状。
|
||||
// 排除:长度 <2 或 >32;含 '/'、'\\'、'_'(路径、usage_track 键、t_9143c968 任务 ID);
|
||||
// 以 '.' 开头(".md");无字母(纯数字/纯标点/数字+分隔符);以常见扩展名结尾
|
||||
// (report.md / a.py);数字/日期/版本片段(2026、2026-09-23、2026+23、09:23、1.2.3、v1.2.3);
|
||||
// 停用词。
|
||||
func isValidTool(tok string) bool {
|
||||
if len(tok) < 2 || len(tok) > 32 {
|
||||
return false
|
||||
}
|
||||
if genericTools[tok] {
|
||||
return false // 通用词黑名单(二轮 #4)
|
||||
}
|
||||
if strings.ContainsAny(tok, `/_\`) {
|
||||
return false
|
||||
}
|
||||
if tok[0] == '.' || tok[0] == '-' {
|
||||
return false // 隐藏文件 / CLI flag
|
||||
}
|
||||
hasLetter := false
|
||||
for i := 0; i < len(tok); i++ {
|
||||
if c := tok[i]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
|
||||
hasLetter = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLetter {
|
||||
return false
|
||||
}
|
||||
if i := strings.LastIndexByte(tok, '.'); i > 0 && commonExts[tok[i:]] {
|
||||
return false
|
||||
}
|
||||
if isNumericFragment(tok) {
|
||||
return false
|
||||
}
|
||||
if isNumericUnitFragment(tok) {
|
||||
return false // 二轮 #2:2.6mb / 512kb / 100ms
|
||||
}
|
||||
if isHashFragment(tok) {
|
||||
return false // 二轮 #1:374b20f98c44 / ac10 / uuid 片段
|
||||
}
|
||||
if isNumberedDirName(tok) {
|
||||
return false // 二轮 #3:05-ai-agent / 38-patternfix
|
||||
}
|
||||
if toolStopwords[tok] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isNumericFragment 纯数字 / 日期 / 时间 / 版本片段:
|
||||
// - 只由数字与分隔符 [-+:./] 组成("2026"、"2026-09-23"、"2026+23"、"09:23"、"1.2.3"),或
|
||||
// - 至多一个前导字母 + 其余全为数字/分隔符("v1.2.3"、"v2")。
|
||||
//
|
||||
// "gcc-12"/"sqlite3" 这类字母出现在数字之前且不止前导一个 ⇒ 正常标识,返回 false。
|
||||
func isNumericFragment(tok string) bool {
|
||||
if tok == "" {
|
||||
return false
|
||||
}
|
||||
seenDigit := false
|
||||
rest := tok
|
||||
if c := rest[0]; (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
|
||||
rest = rest[1:] // 至多剥一个前导字母
|
||||
}
|
||||
for _, r := range rest {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
seenDigit = true
|
||||
case r == '-' || r == '+' || r == ':' || r == '.' || r == '/':
|
||||
// 分隔符放行
|
||||
default:
|
||||
return false // 还有别的字母/字符 ⇒ 不是纯数字片段
|
||||
}
|
||||
}
|
||||
return seenDigit
|
||||
}
|
||||
|
||||
// tokenPresent 检查 lower 文本中 t 是否作为独立 token 出现(词边界,避免 "go" 命中 "google")。
|
||||
func tokenPresent(lower, t string) bool {
|
||||
if t == "" {
|
||||
@@ -325,30 +573,6 @@ func tokenPresent(lower, t string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// regexTokenExtract 轻量 token 抽取(无正则依赖,按字符切):
|
||||
// 返回形如命令/token 的 ascii 串(长度 ≥2,含字母,可能带数字/_.-)。
|
||||
func regexTokenExtract(content string) []string {
|
||||
var out []string
|
||||
var cur []rune
|
||||
flush := func() {
|
||||
if len(cur) >= 2 {
|
||||
out = append(out, string(cur))
|
||||
}
|
||||
cur = nil
|
||||
}
|
||||
for _, r := range content {
|
||||
isTok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.'
|
||||
if isTok {
|
||||
cur = append(cur, r)
|
||||
} else {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeSorted(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
@@ -449,3 +673,38 @@ func truncateForPattern(s string, n int) string {
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// IsValidPatternName pattern 命名键合法性闸门(t_639c5a03,写入前最后防线)。
|
||||
// 合法形态:
|
||||
// - tool_combo:<valid-tool>(+<valid-tool>)* —— 每个成员过 isValidTool
|
||||
// - workflow:cluster-<N>-<fingerprint>
|
||||
//
|
||||
// 其余("tool_combo:.md"、"tool_combo:2026+23" 等垃圾键)一律拒绝,
|
||||
// UpsertPatterns 拒写 —— 即使调用方判据再退化,垃圾也进不了 graph_nodes。
|
||||
func IsValidPatternName(name string) bool {
|
||||
i := strings.IndexByte(name, ':')
|
||||
if i <= 0 {
|
||||
return false
|
||||
}
|
||||
body := name[i+1:]
|
||||
if body == "" {
|
||||
return false
|
||||
}
|
||||
switch name[:i] {
|
||||
case "tool_combo":
|
||||
parts := strings.Split(body, "+")
|
||||
if len(parts) < 2 {
|
||||
return false // 「组合」语义:单成员键拒写(二轮)
|
||||
}
|
||||
for _, t := range parts {
|
||||
// 写入闸 = 白名单唯一来源 + 形态检查(通用词/hash/数值/目录碎片黑名单)。
|
||||
if !knownTools[t] || !isValidTool(t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "workflow":
|
||||
return strings.HasPrefix(body, "cluster-")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// 构造 N 条同一工具组合的记忆(内容里带 jq/git/curl 三个已知工具)。
|
||||
// 构造 N 条同一工具组合的记忆(内容里带白名单、非通用词的真工具)。
|
||||
func comboRecords(n int, combo string) []models.MemoryRecord {
|
||||
// combo 形如 "jq git curl"——内容里出现这三个工具名(空格分隔,保证词边界命中)。
|
||||
// 上下文用纯中文(非 ascii token),避免回退正则把上下文词误当工具混进组合键。
|
||||
// combo 形如 "jq sqlite3 rsync"——内容里出现这几个工具名(空格分隔,保证词边界命中)。
|
||||
// 上下文用纯中文(非 ascii token),避免上下文词误当工具混进组合键。
|
||||
recs := make([]models.MemoryRecord, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
recs = append(recs, models.MemoryRecord{
|
||||
@@ -25,7 +25,7 @@ func comboRecords(n int, combo string) []models.MemoryRecord {
|
||||
|
||||
// TestDetect_ToolCombo —— 3 条同工具组合记忆 → 检出 1 个 tool_combo pattern(AC2)。
|
||||
func TestDetect_ToolCombo(t *testing.T) {
|
||||
recs := comboRecords(3, "jq git curl")
|
||||
recs := comboRecords(3, "jq sqlite3 rsync")
|
||||
pats := DetectFromRecords(recs, DefaultConfig())
|
||||
var toolPatterns []Pattern
|
||||
for _, p := range pats {
|
||||
@@ -37,8 +37,8 @@ func TestDetect_ToolCombo(t *testing.T) {
|
||||
t.Fatalf("期望 1 个 tool_combo pattern,实际 %d: %#v", len(toolPatterns), toolPatterns)
|
||||
}
|
||||
p := toolPatterns[0]
|
||||
if p.Name != "tool_combo:curl+git+jq" {
|
||||
t.Fatalf("命名键不符: got %q (工具应排序去重为 curl,git,jq)", p.Name)
|
||||
if p.Name != "tool_combo:jq+rsync+sqlite3" {
|
||||
t.Fatalf("命名键不符: got %q (工具应排序去重为 jq,rsync,sqlite3)", p.Name)
|
||||
}
|
||||
if len(p.Tools) != 3 {
|
||||
t.Fatalf("Tools 应含 3 个,实际 %v", p.Tools)
|
||||
@@ -52,18 +52,19 @@ func TestDetect_ToolCombo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_BelowThreshold —— 2 条同组合(<3)→ 不产 tool_combo(AC2 反例)。
|
||||
// TestDetect_BelowThreshold —— 1 条同组合(<2)→ 不产 tool_combo(AC2 反例;门槛 t_639c5a03=2)。
|
||||
func TestDetect_BelowThreshold(t *testing.T) {
|
||||
recs := comboRecords(2, "jq git curl")
|
||||
recs := comboRecords(1, "jq sqlite3 rsync")
|
||||
pats := DetectFromRecords(recs, DefaultConfig())
|
||||
for _, p := range pats {
|
||||
if p.Type == "tool_combo" {
|
||||
t.Fatalf("2 条同组合不应产 tool_combo,实际产出: %#v", p)
|
||||
t.Fatalf("1 条同组合不应产 tool_combo,实际产出: %#v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_Workflow —— 3 条近同文(向量近似 1.0 或 bigram 高度重合)→ 1 个 workflow 簇。
|
||||
// t_639c5a03 三审后 workflow 分支默认退役,需显式 WorkflowPatterns=true 才跑(验证门仍在)。
|
||||
func TestDetect_Workflow(t *testing.T) {
|
||||
// 向量相同(cosine=1.0 ≥ 0.85)且 3 条 → 应聚成 1 簇。
|
||||
vec := []float32{1.0, 0.0, 0.0, 0.0}
|
||||
@@ -72,7 +73,9 @@ func TestDetect_Workflow(t *testing.T) {
|
||||
{ID: "w2", Content: "重启服务后拉日志", Category: "distilled", Vector: vec},
|
||||
{ID: "w3", Content: "重启服务后拉日志", Category: "distilled", Vector: vec},
|
||||
}
|
||||
pats := DetectFromRecords(recs, DefaultConfig())
|
||||
cfg := DefaultConfig()
|
||||
cfg.WorkflowPatterns = true
|
||||
pats := DetectFromRecords(recs, cfg)
|
||||
var wf []Pattern
|
||||
for _, p := range pats {
|
||||
if p.Type == "workflow" {
|
||||
@@ -87,6 +90,21 @@ func TestDetect_Workflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_WorkflowRetiredByDefault —— 默认 Config 下 workflow 分支关闭:喂 3 条近同文
|
||||
// 记忆(含完整自然语言句子)⇒ 0 pattern。t_639c5a03 三审「不许把记忆正文当 pattern name」硬约束。
|
||||
func TestDetect_WorkflowRetiredByDefault(t *testing.T) {
|
||||
vec := []float32{1.0, 0.0, 0.0, 0.0}
|
||||
recs := []models.MemoryRecord{
|
||||
{ID: "s1", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
|
||||
{ID: "s2", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
|
||||
{ID: "s3", Content: "给 memories 加新列必须同时改 Rust 写 builder *_col.push", Category: "distilled", Vector: vec},
|
||||
}
|
||||
pats := DetectFromRecords(recs, DefaultConfig())
|
||||
for _, p := range pats {
|
||||
t.Fatalf("默认 Config 不应产出任何 pattern(含 workflow 整句),实际: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_Empty —— 空输入 → 空输出不报错(AC1 空库前提)。
|
||||
func TestDetect_Empty(t *testing.T) {
|
||||
if pats := DetectFromRecords(nil, DefaultConfig()); len(pats) != 0 {
|
||||
@@ -96,16 +114,209 @@ func TestDetect_Empty(t *testing.T) {
|
||||
|
||||
// TestExtractTools —— 词边界:「google」不应命中工具 "go"。
|
||||
func TestExtractTools_WordBoundary(t *testing.T) {
|
||||
tools := ExtractTools("I like google and grep the log with jq")
|
||||
// 应含 grep、jq;不应含 go("google" 被词边界排除)
|
||||
tools := ExtractTools("I like google and rsync the backup with jq")
|
||||
// 应含 rsync、jq;不应含 go("google" 被词边界排除;且 go 已进通用词黑名单)
|
||||
found := map[string]bool{}
|
||||
for _, s := range tools {
|
||||
found[s] = true
|
||||
}
|
||||
if !found["grep"] || !found["jq"] {
|
||||
t.Fatalf("应识别 grep/jq,实际 %v", tools)
|
||||
if !found["rsync"] || !found["jq"] {
|
||||
t.Fatalf("应识别 rsync/jq,实际 %v", tools)
|
||||
}
|
||||
if found["go"] {
|
||||
t.Fatalf("'google' 误命中工具 'go'(词边界失效): %v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// ── t_639c5a03:垃圾 pattern 判据单测(AC2)──────────────────────────────
|
||||
|
||||
// fakeMem 一条带指定 ID 的假蒸馏记忆(向量 nil → 走 bigram 回退,确定性)。
|
||||
func fakeMem(id, content string) models.MemoryRecord {
|
||||
return models.MemoryRecord{ID: id, Content: content, Category: "distilled", Vector: nil}
|
||||
}
|
||||
|
||||
// TestDetect_JunkTokensRejected —— 3 条含 .md / 2026 / 标点 的假记忆 ⇒ 0 pattern(AC2 第一条)。
|
||||
// 覆盖卡现象里的垃圾样本:.md、.md+2026-09-23、2026+23 全不得被当工具组合。
|
||||
func TestDetect_JunkTokensRejected(t *testing.T) {
|
||||
recs := []models.MemoryRecord{
|
||||
fakeMem("j1", "今天把结果导出成 .md 文件"),
|
||||
fakeMem("j2", "版本 .md+2026-09-23 的构建产物"),
|
||||
fakeMem("j3", "数字 2026+23 是什么意义"),
|
||||
}
|
||||
pats := DetectFromRecords(recs, DefaultConfig())
|
||||
for _, p := range pats {
|
||||
t.Fatalf("垃圾记忆不应产出任何 pattern,实际 %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_RealToolComboNeeds2Sources —— 2 条真工具共现(jq+rsync)⇒ 1 pattern(AC2 第二条)。
|
||||
// 同时验证门槛 ≥2 条独立来源(单条不产)。
|
||||
func TestDetect_RealToolComboNeeds2Sources(t *testing.T) {
|
||||
// 单条真工具组合 → 不产(门槛 2)
|
||||
one := DetectFromRecords([]models.MemoryRecord{
|
||||
fakeMem("o1", "用 jq 和 rsync 同步 json"),
|
||||
}, DefaultConfig())
|
||||
for _, p := range one {
|
||||
if p.Type == "tool_combo" {
|
||||
t.Fatalf("单条真工具组合不应产 pattern(门槛≥2),实际 %#v", p)
|
||||
}
|
||||
}
|
||||
// 2 条同真组合 → 1 个 tool_combo,成员 jq+rsync,支撑 2 条
|
||||
two := DetectFromRecords([]models.MemoryRecord{
|
||||
fakeMem("a1", "先用 jq 解析再 rsync 备份"),
|
||||
fakeMem("a2", "jq 提取字段后 rsync 同步"),
|
||||
}, DefaultConfig())
|
||||
var tc []Pattern
|
||||
for _, p := range two {
|
||||
if p.Type == "tool_combo" {
|
||||
tc = append(tc, p)
|
||||
}
|
||||
}
|
||||
if len(tc) != 1 {
|
||||
t.Fatalf("2 条同真组合应产 1 个 tool_combo,实际 %d: %#v", len(tc), two)
|
||||
}
|
||||
if tc[0].Name != "tool_combo:jq+rsync" {
|
||||
t.Fatalf("命名键不符: got %q", tc[0].Name)
|
||||
}
|
||||
if len(tc[0].SourceTasks) != 2 {
|
||||
t.Fatalf("支撑应为 2 条独立来源,实际 %v", tc[0].SourceTasks)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_SingletonToolNoCombo —— 二轮:单工具(即使白名单、≥2 来源)不产 tool_combo(「组合」语义)。
|
||||
func TestDetect_SingletonToolNoCombo(t *testing.T) {
|
||||
pats := DetectFromRecords([]models.MemoryRecord{
|
||||
fakeMem("s1", "用 jq 解析配置文件甲"),
|
||||
fakeMem("s2", "用 jq 解析配置文件乙"),
|
||||
}, DefaultConfig())
|
||||
for _, p := range pats {
|
||||
if p.Type == "tool_combo" {
|
||||
t.Fatalf("单工具不应产 tool_combo(组合需≥2成员),实际 %#v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractTools_JunkTokenFilter —— 形态级:.md/2026/日期/纯标点/扩展名/停用词/通用词全被滤。
|
||||
// 二轮(小怡裁定):git/curl 从"应识别"翻转为"必须滤掉"(通用词黑名单)。
|
||||
func TestExtractTools_JunkTokenFilter(t *testing.T) {
|
||||
content := "处理 .md 和 2026 与 2026-09-23 以及 2026+23 与 report.md 与 a.py" +
|
||||
" 还有 git status 和 curl -I https://x 和 ... 和 step 1 以及 sqlite3 dump 和 jq 解析"
|
||||
tools := ExtractTools(content)
|
||||
found := map[string]bool{}
|
||||
for _, s := range tools {
|
||||
found[s] = true
|
||||
}
|
||||
// 垃圾与通用词一律不得出现
|
||||
for _, junk := range []string{".md", "2026", "2026-09-23", "2026+23", "report", "a", "step",
|
||||
"https", "-i", "git", "curl"} {
|
||||
if found[junk] {
|
||||
t.Fatalf("垃圾/通用 token %q 不应被当工具,实际 %v", junk, tools)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"sqlite3", "jq"} {
|
||||
if !found[want] {
|
||||
t.Fatalf("应识别真标识 %q,实际 %v", want, tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractTools_RoundTwoJunkClasses —— 小怡二轮 dry-run 里 14 条样本的垃圾类别逐项回归:
|
||||
// hash 片段 / 短 hash / uuid 段 / 数值+单位 / 数字编号目录名 / 通用词 / CVE、协议碎片
|
||||
// —— 全部不得出现;只放行白名单真工具。
|
||||
func TestExtractTools_RoundTwoJunkClasses(t *testing.T) {
|
||||
content := "参考 05-ai-agent 目录,产物 2.6mb 与 512kb 与 100ms," +
|
||||
"commit 374b20f98c44 与 b713b48a70ad 与 ac10 与 635dccc0," +
|
||||
"接口走 bot-chat 与 mcp 与 api,用 git grep go curl 查询," +
|
||||
"ipv4 与 cve-2026-99999 与 dispatch-backlog.jsonl 与 38-patternfix," +
|
||||
"真工具是 docker 与 sqlite3 与 jq 与 rsync"
|
||||
tools := ExtractTools(content)
|
||||
found := map[string]bool{}
|
||||
for _, s := range tools {
|
||||
found[s] = true
|
||||
}
|
||||
for _, junk := range []string{
|
||||
"05-ai-agent", "38-patternfix", "2.6mb", "512kb", "100ms",
|
||||
"374b20f98c44", "b713b48a70ad", "ac10", "635dccc0",
|
||||
"bot-chat", "mcp", "api", "git", "grep", "go", "curl",
|
||||
"ipv4", "cve-2026-99999", "dispatch-backlog.jsonl",
|
||||
} {
|
||||
if found[junk] {
|
||||
t.Fatalf("二轮垃圾类别 token %q 漏网,实际 %v", junk, tools)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"docker", "sqlite3", "jq", "rsync"} {
|
||||
if !found[want] {
|
||||
t.Fatalf("白名单真工具 %q 应被识别,实际 %v", want, tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormPredicates —— 形态级判据的单元断言(hash/uuid/数值单位/编号目录)。
|
||||
func TestFormPredicates(t *testing.T) {
|
||||
hashBad := []string{"374b20f98c44", "b713b48a70ad", "635dccc0", "ac10", "b713-48a7", "deadbeef"}
|
||||
hashGood := []string{"gcc-12", "redis-cli", "cafe", "beef", "fd", "systemd-run", "golangci-lint", "hermes"}
|
||||
for _, s := range hashBad {
|
||||
if !isHashFragment(s) {
|
||||
t.Fatalf("isHashFragment(%q) 应为 true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range hashGood {
|
||||
if isHashFragment(s) {
|
||||
t.Fatalf("isHashFragment(%q) 应为 false(真工具标识被误杀)", s)
|
||||
}
|
||||
}
|
||||
numBad := []string{"2.6mb", "512kb", "100ms", "3x", "50%", "1024"}
|
||||
numGood := []string{"sqlite3", "python3", "gcc-12", "v10"} // v10 由 isNumericFragment 拦,这里不误判成单位碎片
|
||||
for _, s := range numBad {
|
||||
if !isNumericUnitFragment(s) {
|
||||
t.Fatalf("isNumericUnitFragment(%q) 应为 true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range numGood {
|
||||
if isNumericUnitFragment(s) {
|
||||
t.Fatalf("isNumericUnitFragment(%q) 应为 false", s)
|
||||
}
|
||||
}
|
||||
dirBad := []string{"05-ai-agent", "38-patternfix", "10-after-sidecar"}
|
||||
dirGood := []string{"gcc-12", "systemd-run", "redis-cli", "obsidian-vault"}
|
||||
for _, s := range dirBad {
|
||||
if !isNumberedDirName(s) {
|
||||
t.Fatalf("isNumberedDirName(%q) 应为 true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range dirGood {
|
||||
if isNumberedDirName(s) {
|
||||
t.Fatalf("isNumberedDirName(%q) 应为 false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsValidPatternName_Gate —— 写入闸:垃圾/单成员/通用词命名键全拒,白名单多成员键/工作流键放行。
|
||||
func TestIsValidPatternName_Gate(t *testing.T) {
|
||||
bad := []string{
|
||||
"tool_combo:.md", "tool_combo:2026+23", "tool_combo:.md+2026-09-23",
|
||||
"tool_combo:2026", "tool_combo:report", "tool_combo:2026-09-23",
|
||||
"tool_combo:usage_track", "tool_combo:t_9143c968", "tool_combo:a+b",
|
||||
"tool_combo:log", "tool_combo:",
|
||||
// 二轮新增:单成员、通用词成员、hash/数值/目录碎片成员
|
||||
"tool_combo:sqlite3", "tool_combo:jq", "tool_combo:git+jq",
|
||||
"tool_combo:curl+git+jq", "tool_combo:374b20f98c44+jq",
|
||||
"tool_combo:05-ai-agent", "tool_combo:2.6mb", "tool_combo:ac10",
|
||||
"tool_combo:cve-2026-99999", "tool_combo:ipv4+jq", "tool_combo:bot-chat",
|
||||
}
|
||||
for _, name := range bad {
|
||||
if IsValidPatternName(name) {
|
||||
t.Fatalf("垃圾命名键 %q 不应通过闸门", name)
|
||||
}
|
||||
}
|
||||
good := []string{
|
||||
"tool_combo:jq+rsync", "tool_combo:docker+sqlite3",
|
||||
"tool_combo:hermes+zhiyi+jq",
|
||||
"workflow:cluster-3-abcdef12", "workflow:cluster-2-00aa11bb",
|
||||
}
|
||||
for _, name := range good {
|
||||
if !IsValidPatternName(name) {
|
||||
t.Fatalf("合法命名键 %q 应通过闸门", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,15 @@ func (s *GraphPatternStore) UpsertPatterns(patterns []Pattern) (created, updated
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
for _, p := range patterns {
|
||||
// t_639c5a03 最后写入门闸:垃圾命名键(tool_combo:.md / tool_combo:2026+23 等)拒写。
|
||||
if !IsValidPatternName(p.Name) {
|
||||
continue
|
||||
}
|
||||
// t_639c5a03 二轮(小怡裁定 #5):conf<MinWriteConfidence(0.5) 一律不落库
|
||||
// —— freq=2 ⇒ conf=0.2 的弱信号只出现在 dry-run 观测里,不污染召回。
|
||||
if p.Confidence < MinWriteConfidence {
|
||||
continue
|
||||
}
|
||||
// 读现有
|
||||
var (
|
||||
id string
|
||||
|
||||
@@ -45,13 +45,13 @@ func TestStore_UpsertEmpty(t *testing.T) {
|
||||
func TestStore_UpsertCreate(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p := Pattern{
|
||||
ID: "pat_tool_combo:curl+git+jq",
|
||||
ID: "pat_tool_combo:jq+rsync+sqlite3",
|
||||
Type: "tool_combo",
|
||||
Name: "tool_combo:curl+git+jq",
|
||||
Data: "curl+git+jq",
|
||||
Confidence: 0.3,
|
||||
Tools: []string{"curl", "git", "jq"},
|
||||
SourceTasks: []string{"m_a", "m_b", "m_c"},
|
||||
Name: "tool_combo:jq+rsync+sqlite3",
|
||||
Data: "jq+rsync+sqlite3",
|
||||
Confidence: 0.6,
|
||||
Tools: []string{"jq", "rsync", "sqlite3"},
|
||||
SourceTasks: []string{"m_a", "m_b", "m_c", "m_d", "m_e", "m_f"},
|
||||
}
|
||||
created, updated, err := s.UpsertPatterns([]Pattern{p})
|
||||
if err != nil {
|
||||
@@ -71,17 +71,17 @@ func TestStore_UpsertCreate(t *testing.T) {
|
||||
func TestStore_UpsertIdempotent(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
p := Pattern{
|
||||
ID: "pat_tool_combo:curl+git+jq",
|
||||
ID: "pat_tool_combo:jq+rsync+sqlite3",
|
||||
Type: "tool_combo",
|
||||
Name: "tool_combo:curl+git+jq",
|
||||
Data: "curl+git+jq",
|
||||
Confidence: 0.3,
|
||||
Tools: []string{"curl", "git", "jq"},
|
||||
SourceTasks: []string{"m_a", "m_b", "m_c"},
|
||||
Name: "tool_combo:jq+rsync+sqlite3",
|
||||
Data: "jq+rsync+sqlite3",
|
||||
Confidence: 0.6,
|
||||
Tools: []string{"jq", "rsync", "sqlite3"},
|
||||
SourceTasks: []string{"m_a", "m_b", "m_c", "m_d", "m_e"},
|
||||
}
|
||||
c1, u1, _ := s.UpsertPatterns([]Pattern{p}) // 新建
|
||||
p.Confidence = 0.4 // 第二次检测:同组合再次出现(freq 抬升)
|
||||
p.SourceTasks = []string{"m_d"}
|
||||
p.Confidence = 0.8 // 第二次检测:同组合再次出现(freq 抬升)
|
||||
p.SourceTasks = []string{"m_f"}
|
||||
c2, u2, _ := s.UpsertPatterns([]Pattern{p}) // 应幂等更新(updated=1)
|
||||
if c1 != 1 || u1 != 0 {
|
||||
t.Fatalf("首次应 created=1/updated=0,实际 %d/%d", c1, u1)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package pattern
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestStore_WriteConfidenceGate —— 二轮裁定 #5:conf<MinWriteConfidence(0.5) 一律不落库。
|
||||
// 命名键合法(白名单、≥2 成员)也不行——弱信号只出现在 dry-run 观测里。
|
||||
func TestStore_WriteConfidenceGate(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
low := Pattern{
|
||||
ID: "pat_tool_combo:jq+rsync", Type: "tool_combo",
|
||||
Name: "tool_combo:jq+rsync", Data: "jq+rsync",
|
||||
Confidence: 0.2, // freq=2 ⇒ 0.2,正是被点名的弱信号档
|
||||
Tools: []string{"jq", "rsync"},
|
||||
SourceTasks: []string{"m_1", "m_2"},
|
||||
}
|
||||
created, updated, err := s.UpsertPatterns([]Pattern{low})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert 报错: %v", err)
|
||||
}
|
||||
if created != 0 || updated != 0 {
|
||||
t.Fatalf("conf=0.2 < 门槛 0.5 应拒写,实际 created=%d updated=%d", created, updated)
|
||||
}
|
||||
ok := low
|
||||
ok.Name = "tool_combo:jq+sqlite3"
|
||||
ok.ID = "pat_tool_combo:jq+sqlite3"
|
||||
ok.Data = "jq+sqlite3"
|
||||
ok.Confidence = 0.5 // 恰好达门槛:允许写
|
||||
created, _, err = s.UpsertPatterns([]Pattern{ok})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert 报错: %v", err)
|
||||
}
|
||||
if created != 1 {
|
||||
t.Fatalf("conf=0.5 恰达门槛应写入 1 条,实际 created=%d", created)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinWriteConfidenceValue —— 门槛值与卡面(小怡裁定 ≥0.5)钉死,防手滑改低。
|
||||
func TestMinWriteConfidenceValue(t *testing.T) {
|
||||
if MinWriteConfidence != 0.5 {
|
||||
t.Fatalf("MinWriteConfidence 应为 0.5(小怡 2026-10-30 裁定 #5),实际 %v", MinWriteConfidence)
|
||||
}
|
||||
}
|
||||
@@ -72,18 +72,25 @@ func (vp *VPropagator) loadVPropFromRedis() {
|
||||
}
|
||||
}
|
||||
|
||||
func (vp *VPropagator) persistVProp() {
|
||||
func (vp *VPropagator) persistVProp(d *VDecision, memIDs []string) {
|
||||
if vp.redisClient == nil {
|
||||
return
|
||||
}
|
||||
for id, d := range vp.decisions {
|
||||
data, _ := json.Marshal(d)
|
||||
vp.redisClient.HSet(vpropRedisKey, id, string(data))
|
||||
// 增量持久化:只写本次变更的条目。
|
||||
// 此前是全量遍历 decisions(7592 条)+memVValues(1938 条)、每条一次 HSET
|
||||
// ⇒ 单次 recall 9530 次 Redis 往返 ≈133ms,且随运行时长线性恶化(p50 4ms→192ms)。
|
||||
// 历史条目早已在 Redis(LoadFromRedis 全量载入 + 每条写入时都增量落过),故增量写语义等价。
|
||||
if d != nil {
|
||||
if data, err := json.Marshal(d); err == nil {
|
||||
vp.redisClient.HSet(vpropRedisKey, d.ID, string(data))
|
||||
}
|
||||
}
|
||||
for _, memID := range memIDs {
|
||||
if v, ok := vp.memVValues[memID]; ok {
|
||||
vp.redisClient.HSet(vpropValsRedisKey, memID, strconv.FormatFloat(v, 'f', 4, 64))
|
||||
}
|
||||
}
|
||||
vp.redisClient.Expire(vpropRedisKey, 90*24*time.Hour)
|
||||
for memID, v := range vp.memVValues {
|
||||
vp.redisClient.HSet(vpropValsRedisKey, memID, strconv.FormatFloat(v, 'f', 4, 64))
|
||||
}
|
||||
vp.redisClient.Expire(vpropValsRedisKey, 90*24*time.Hour)
|
||||
}
|
||||
|
||||
@@ -135,7 +142,7 @@ func (vp *VPropagator) RecordDecision(id string, memoryIDs []string, action, out
|
||||
}
|
||||
}
|
||||
|
||||
vp.persistVProp()
|
||||
vp.persistVProp(d, memoryIDs)
|
||||
return d
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package storage
|
||||
|
||||
// t_9143c968 消费侧熔断修复:游标调度测试。
|
||||
// 覆盖 AC5(窗口外 1000 pending + 窗口内 1 行必须被处理)、tail/sweep 游标、
|
||||
// 失败熔断(替代旧「积压>cap⇒skip」)、retry 通道、expire 标记与 TTL。
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// flakyLance 包装任意 LanceDB:failAppend=true ⇒ AppendHitEvent 恒失败(模拟持久性坏行)。
|
||||
type flakyLance struct {
|
||||
LanceDB
|
||||
failAppend bool
|
||||
}
|
||||
|
||||
func (f *flakyLance) AppendHitEvent(id, outcome, reason string) error {
|
||||
if f.failAppend {
|
||||
return errors.New("simulated commit conflict")
|
||||
}
|
||||
return f.LanceDB.AppendHitEvent(id, outcome, reason)
|
||||
}
|
||||
|
||||
// insertRecallRowAt 直插一条召回行(recalled_at 可控,绕开 RecordRecalled 的 now)。
|
||||
func insertRecallRowAt(t *testing.T, db *sql.DB, memID, content, recalledAt string) {
|
||||
t.Helper()
|
||||
toks, _ := json.Marshal(DistinctiveTokens(content, usageTokenTopN))
|
||||
if _, err := db.Exec(`INSERT INTO usage_recall(memory_id,content,tokens,q_hash,namespace,agent,recalled_at)
|
||||
VALUES(?,?,?,?,?,?,?)`, memID, content, string(toks), "q", "ns", "agent", recalledAt); err != nil {
|
||||
t.Fatalf("insert recall row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const usageTestMem = "家庭服务器IP固定为192.168.123.11,星空组网走VPN 192.168.188.10"
|
||||
const usageTestCommit = "家庭服务器IP是192.168.123.11,星空组网用VPN 192.168.188.10 访问"
|
||||
|
||||
func usagePendingBy(t *testing.T, db *sql.DB, where string, args ...interface{}) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE `+where, args...).Scan(&n); err != nil {
|
||||
t.Fatalf("count(%s): %v", where, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AC5:窗口外 1000 行 pending + 窗口内 1 行 ⇒ 窗口内那 1 行必须被消费。
|
||||
// (旧实现在此场景恒 skip:pending 1001 > cap 500 ⇒ 每 commit「backlog > cap」——正是本次熔断本身。)
|
||||
func TestUsageCursor_A5OldBacklogMustNotBlockNewRow(t *testing.T) {
|
||||
setupUsageTest(t)
|
||||
db := usageOpen()
|
||||
if db == nil {
|
||||
t.Fatal("usageOpen failed")
|
||||
}
|
||||
mlc := NewMemLanceClient(nil)
|
||||
now := time.Now()
|
||||
old := now.Add(-72 * time.Hour).Format(time.RFC3339)
|
||||
fresh := now.Format(time.RFC3339)
|
||||
for i := 0; i < 1000; i++ {
|
||||
insertRecallRowAt(t, db, "mem_old"+strconv.Itoa(i), "旧内容占位记忆"+strconv.Itoa(i), old)
|
||||
}
|
||||
insertRecallRowAt(t, db, "mem_new", usageTestMem, fresh)
|
||||
|
||||
start := time.Now()
|
||||
flags := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
|
||||
t.Logf("1001 行积压下 单轮耗时 %v", time.Since(start))
|
||||
if len(flags) != 1 || flags[0].MemoryID != "mem_new" {
|
||||
t.Fatalf("窗口内 1 行必须被消费(旧积压闸已删),got %+v", flags)
|
||||
}
|
||||
// 窗口外 1000 行:同轮被 expire 通道标 consumed=2(skipped_expired,留证据不物理删)
|
||||
if c2 := usagePendingBy(t, db, "consumed=2"); c2 != 1000 {
|
||||
t.Fatalf("窗口外 pending 应全标 consumed=2, actual %d", c2)
|
||||
}
|
||||
if p := usagePendingBy(t, db, "consumed=0"); p != 0 {
|
||||
t.Fatalf("不应再有 consumed=0 残留, actual %d", p)
|
||||
}
|
||||
// 行仍在库里(证据保留):总量 = 1001
|
||||
if total := usagePendingBy(t, db, "1=1"); total != 1001 {
|
||||
t.Fatalf("expire 只标记不删除, total=%d", total)
|
||||
}
|
||||
}
|
||||
|
||||
// tail 只吃游标之上的有界批次(LAG),落后行由 sweep 逐轮补齐;全部行最终各消费一次。
|
||||
func TestUsageCursor_TailBoundedSweepCovers(t *testing.T) {
|
||||
setupUsageTest(t)
|
||||
t.Setenv("ZHIYI_USAGE_BATCH", "3")
|
||||
t.Setenv("ZHIYI_USAGE_SWEEP_BATCH", "2")
|
||||
db := usageOpen()
|
||||
mlc := NewMemLanceClient(nil)
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for i := 1; i <= 10; i++ {
|
||||
insertRecallRowAt(t, db, "mem_c"+strconv.Itoa(i), usageTestMem, now)
|
||||
}
|
||||
// call1:tail 取最新 3 行(ids 10,9,8);sweep 从头取 2 行(ids 1,2)⇒ 5 flags
|
||||
if f := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit); len(f) != 5 {
|
||||
t.Fatalf("call1 期望 5 flags (tail3+sweep2), got %d", len(f))
|
||||
}
|
||||
if c := usagePendingBy(t, db, "consumed=1"); c != 5 {
|
||||
t.Fatalf("call1 后应消费 5 行, actual %d", c)
|
||||
}
|
||||
// 继续调用直至全部消费;每次调用都是新 commit,行只能消费一次
|
||||
for round := 2; round <= 10; round++ {
|
||||
CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
|
||||
}
|
||||
if c := usagePendingBy(t, db, "consumed=1"); c != 10 {
|
||||
t.Fatalf("多轮后应全部 10 行消费, actual %d", c)
|
||||
}
|
||||
if p := usagePendingBy(t, db, "consumed=0"); p != 0 {
|
||||
t.Fatalf("不应有 pending 残留, actual %d", p)
|
||||
}
|
||||
// 每行只 1 条 hit event(无重复累加)
|
||||
for i := 1; i <= 10; i++ {
|
||||
ev, _ := mlc.GetHitEvents("mem_c"+strconv.Itoa(i), 10)
|
||||
if len(ev) != 1 {
|
||||
t.Fatalf("mem_c%d 应恰 1 条 hit event, got %d", i, len(ev))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 失败熔断(ZHIYI_USAGE_BACKLOG_MAX 新语义):单轮失败 ≥cap ⇒ 停,不再全表跳过。
|
||||
// 反证旧行为:pending 6 > cap 2 在旧实现 = 「backlog > cap skip」0 行处理;
|
||||
// 新实现必须实际处理(失败行记退避,坏行不再每 commit 热重试,也不再拖死好行)。
|
||||
func TestUsageCursor_FailureBreakerReplacesBacklogSkip(t *testing.T) {
|
||||
setupUsageTest(t)
|
||||
t.Setenv("ZHIYI_USAGE_BACKLOG_MAX", "2")
|
||||
db := usageOpen()
|
||||
mlc := NewMemLanceClient(nil)
|
||||
flaky := &flakyLance{LanceDB: mlc, failAppend: true}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for i := 1; i <= 6; i++ {
|
||||
insertRecallRowAt(t, db, "mem_bk"+strconv.Itoa(i), usageTestMem, now)
|
||||
}
|
||||
flags := CheckCommittedForUse(flaky, "agent", "ns", usageTestCommit)
|
||||
if len(flags) != 0 {
|
||||
t.Fatalf("恒失败不应有 flag, got %d", len(flags))
|
||||
}
|
||||
// tail DESC 先吃 ids 6,5 → 2 次失败即熔断;sweep 被单次调用熔断跳过 ⇒ 仅 2 行记退避
|
||||
if n := usagePendingBy(t, db, "retry_count=1"); n != 2 {
|
||||
t.Fatalf("熔断应只记 2 行退避(单轮 cap=2), actual %d", n)
|
||||
}
|
||||
if n := usagePendingBy(t, db, "retry_count=0 AND consumed=0"); n != 4 {
|
||||
t.Fatalf("熔断后其余 4 行应未被触碰, actual %d", n)
|
||||
}
|
||||
if n := usagePendingBy(t, db, "consumed=1"); n != 0 {
|
||||
t.Fatalf("失败行不得消费, actual %d", n)
|
||||
}
|
||||
// 关键反证:旧「pending(6)>cap(2)⇒skip」在此场景 0 行处理;新实现推进了 2 行状态。
|
||||
}
|
||||
|
||||
// retry 通道:游标推过头的失败行,退避到期后仍能被重试消费(不依赖 tail 位置)。
|
||||
func TestUsageCursor_RetryPassAfterBackoff(t *testing.T) {
|
||||
setupUsageTest(t)
|
||||
db := usageOpen()
|
||||
mlc := NewMemLanceClient(nil)
|
||||
flaky := &flakyLance{LanceDB: mlc, failAppend: true}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
insertRecallRowAt(t, db, "mem_retry", usageTestMem, now)
|
||||
|
||||
if f := CheckCommittedForUse(flaky, "agent", "ns", usageTestCommit); len(f) != 0 {
|
||||
t.Fatalf("第一次应失败, got %d", len(f))
|
||||
}
|
||||
if c := usagePendingBy(t, db, "consumed=1"); c != 0 {
|
||||
t.Fatalf("失败不应消费, actual %d", c)
|
||||
}
|
||||
// 手动把 next_retry_at 拨到过去(模拟退避到期;包级 backoff 参数在 init 时已定)
|
||||
if _, err := db.Exec(`UPDATE usage_recall SET next_retry_at='2000-01-01T00:00:00Z' WHERE memory_id='mem_retry'`); err != nil {
|
||||
t.Fatalf("backdate retry: %v", err)
|
||||
}
|
||||
flaky.failAppend = false // 故障恢复
|
||||
f := CheckCommittedForUse(mlc, "agent", "ns", usageTestCommit)
|
||||
if len(f) != 1 || f[0].MemoryID != "mem_retry" {
|
||||
t.Fatalf("退避到期后 retry 通道必须消费该行, got %+v", f)
|
||||
}
|
||||
if c := usagePendingBy(t, db, "consumed=1 AND retry_count=0"); c != 1 {
|
||||
t.Fatalf("成功消费应把 retry_count 归零, actual %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// expire + TTL:出窗 pending 标 2(72h,未到 7d 不删);consumed>=1 且 >7d 删除;新鲜行不动。
|
||||
func TestUsageCursor_ExpireMarkAndTTL(t *testing.T) {
|
||||
setupUsageTest(t)
|
||||
db := usageOpen()
|
||||
mlc := NewMemLanceClient(nil)
|
||||
now := time.Now()
|
||||
insertRecallRowAt(t, db, "mem_expired_pending", "旧内容占位记忆甲", now.Add(-72*time.Hour).Format(time.RFC3339))
|
||||
insertRecallRowAt(t, db, "mem_stale_consumed", "旧内容占位记忆乙", now.Add(-8*24*time.Hour).Format(time.RFC3339))
|
||||
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1 WHERE memory_id IN ('mem_stale_consumed')`); err != nil {
|
||||
t.Fatalf("mark consumed: %v", err)
|
||||
}
|
||||
insertRecallRowAt(t, db, "mem_fresh_consumed", "旧内容占位记忆丙", now.Add(-1*time.Hour).Format(time.RFC3339))
|
||||
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1 WHERE memory_id='mem_fresh_consumed'`); err != nil {
|
||||
t.Fatalf("mark consumed: %v", err)
|
||||
}
|
||||
insertRecallRowAt(t, db, "mem_fresh_pending", "旧内容占位记忆丁", now.Add(-1*time.Hour).Format(time.RFC3339))
|
||||
|
||||
// 不相关内容 ⇒ 无判定,但 expire/TTL 维护通道照常运行
|
||||
CheckCommittedForUse(mlc, "agent", "ns", "今天天气很好出去散步")
|
||||
|
||||
if n := usagePendingBy(t, db, "memory_id='mem_expired_pending' AND consumed=2"); n != 1 {
|
||||
t.Fatalf("出窗 pending 应标 consumed=2(且 72h<7d 不得删除), rows=%d", n)
|
||||
}
|
||||
if n := usagePendingBy(t, db, "memory_id='mem_stale_consumed'"); n != 0 {
|
||||
t.Fatalf("consumed>=1 且 recalled_at>7d 应被 TTL 删除, rows=%d", n)
|
||||
}
|
||||
if n := usagePendingBy(t, db, "memory_id='mem_fresh_consumed' AND consumed=1"); n != 1 {
|
||||
t.Fatalf("新鲜 consumed 行不得删, rows=%d", n)
|
||||
}
|
||||
if n := usagePendingBy(t, db, "memory_id='mem_fresh_pending' AND consumed=0"); n != 1 {
|
||||
t.Fatalf("窗内 pending 不得被动, rows=%d", n)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -154,6 +155,20 @@ func usageOpen() *sql.DB {
|
||||
}
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_mem ON usage_recall(memory_id)`)
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_time ON usage_recall(recalled_at)`)
|
||||
// 消费游标(t_9143c968):单行表,id 恒为 1。tail_pos=尾部已消费位点,sweep_pos=轮巡位点。
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS usage_cursor(
|
||||
id INTEGER PRIMARY KEY CHECK(id=1),
|
||||
tail_pos INTEGER NOT NULL DEFAULT 0,
|
||||
sweep_pos INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT)`); err != nil {
|
||||
logUsage("create cursor table: %v", err)
|
||||
}
|
||||
// 部分索引(t_9143c968):让消费端单轮扫描代价 O(batch),与表总量解耦。
|
||||
// ⚠️ 生产 5M+ 行库的这三条索引须在停机窗口预建(sqlite3 CLI),
|
||||
// 否则首次 usageOpen 会在请求路径里建索引卡几秒;IF NOT EXISTS 双保险。
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_pending ON usage_recall(id, recalled_at) WHERE consumed=0`)
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_pending_time ON usage_recall(recalled_at) WHERE consumed=0`)
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_usage_retry ON usage_recall(id) WHERE next_retry_at IS NOT NULL`)
|
||||
usageDB = db
|
||||
})
|
||||
return usageDB
|
||||
@@ -198,44 +213,72 @@ func RecordRecalled(recs []RecalledMemory) {
|
||||
}
|
||||
}
|
||||
|
||||
// CheckCommittedForUse commit 写侧钩子:检查新写入的内容是否携带了
|
||||
// 最近被召回记忆的独特 token;命中则判定「被用上」并落 outcome(门控)。
|
||||
// 返回本次判定出的「被用上」记忆(供上报/取证)。
|
||||
func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []UsageFlag {
|
||||
if !usageTrackEnabled || ldb == nil || content == "" {
|
||||
return nil
|
||||
}
|
||||
db := usageOpen()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
// 积压上限量化(t_20bfbd9a 治本②):未消费待重试行超过上限 ⇒ 本轮跳过扫描,
|
||||
// 防止「一条永远失败的坏行 + 高 commit 频率」在每次 commit 都全量重扫、叠成风暴。
|
||||
// 上限 env ZHIYI_USAGE_BACKLOG_MAX,默认 500。
|
||||
backlogMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
|
||||
if backlogMax > 0 {
|
||||
var pending int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE consumed=0`).Scan(&pending); err == nil && pending > backlogMax {
|
||||
logUsage("backlog %d > cap %d: skip scan this commit", pending, backlogMax)
|
||||
return nil
|
||||
// ─── 消费游标与批量消费(t_9143c968 熔断修复,2026-10-30)────────────
|
||||
//
|
||||
// 根因(卡 body + comment #1757/#1758 两次修正):旧实现扫描前用无窗 count(consumed=0)
|
||||
// 作积压熔断(cap 500,t_20bfbd9a)。写入量涨到 40~200 万行/日后 pending 恒百万级
|
||||
// ⇒ 每次 commit 都命中「backlog > cap ⇒ skip」——消费侧永久短路(10-19 起 consumed=0)。
|
||||
// cap 是安全阀不是 bug;真 bug 是「消费无增量位点,只能每次 commit 全量扫窗」。
|
||||
//
|
||||
// 新方案(卡推荐 A:增量游标;单轮代价 O(batch),与表总量解耦):
|
||||
// - expire 过期标记:consumed=0 且已出窗(recalled_at<cutoff)的行标 consumed=2
|
||||
// (skipped_expired,不物理删留证据),chunk=ZHIYI_USAGE_EXPIRE_CHUNK(默认 5000)。
|
||||
// - retry 退避重试:next_retry_at 已到期的失败行优先重试(不受游标位置影响)。
|
||||
// - tail 尾部消费:从 tail 游标向上取最多 ZHIYI_USAGE_BATCH(默认 200)条未消费
|
||||
// 行(DESC 先吃最新)——上一轮 commit 以来最新一批召回,与本次 commit 内容
|
||||
// 因果对齐(主信号面);游标推进到表尾 max(id)。
|
||||
// - sweep 轮巡复查:升序扫未消费行,游标到表尾后回绕 0,batch=ZHIYI_USAGE_SWEEP_BATCH
|
||||
// (默认 500,0 关闭)。保留旧「一次召回、多个后续 commit 逐个命中」语义
|
||||
// (usage_live_demo_test 即此形态);expire 先行使游标只在窗内 pending 区推进。
|
||||
// - TTL consumed>=1(含 2)且 recalled_at<now-7d 的行删除,
|
||||
// chunk=ZHIYI_USAGE_TTL_CHUNK(默认 5000,0 关闭),防止无界增长。
|
||||
// - ZHIYI_USAGE_BACKLOG_MAX(默认 500)保留但语义收窄:不再是「全表积压跳过」,
|
||||
// 改为单次调用失败熔断——一轮内失败行数 ≥ cap 即结束本轮,
|
||||
// 保留 t_20bfbd9a「持久性坏行不叠重试风暴」的原始意图。
|
||||
//
|
||||
// 取舍(写进交付注释):commit 频率(实测 ~91 次/日)≪ 召回写入(40~200 万行/日),
|
||||
// 任何方案都不可能对每行复查窗口内全部后续 commit。tail 面抓「recall→紧接着写」
|
||||
// 的主场景;窗口内其余行由 sweep 轮巡兜低频复查;出窗行标 2 留证,7 日后 TTL 清。
|
||||
|
||||
// usageMu 串行化消费全流程:commit 钩子是异步 goroutine,可能并发进入;
|
||||
// 同库读-改-写(游标推进/消费标记)串行后无竞态,单轮 O(batch) 毫秒级,无排队风险。
|
||||
var usageMu sync.Mutex
|
||||
|
||||
func usageCursorGet(db *sql.DB) (tail, sweep int64) {
|
||||
_ = db.QueryRow(`SELECT tail_pos, sweep_pos FROM usage_cursor WHERE id=1`).Scan(&tail, &sweep)
|
||||
return // 无行/出错 → (0,0):首轮 tail 从 0 向上扫最新一批,sweep 从最老行开始
|
||||
}
|
||||
|
||||
func usageCursorSet(db *sql.DB, tail, sweep int64) {
|
||||
_, _ = db.Exec(`INSERT INTO usage_cursor(id,tail_pos,sweep_pos,updated_at) VALUES(1,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET tail_pos=excluded.tail_pos, sweep_pos=excluded.sweep_pos, updated_at=excluded.updated_at`,
|
||||
tail, sweep, time.Now().Format(time.RFC3339))
|
||||
}
|
||||
|
||||
// usageEnvInt 读整型 env;缺失/非法/≤0 → 默认值。
|
||||
func usageEnvInt(name string, def int) int {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(usageWindowHours * float64(time.Hour))).Format(time.RFC3339)
|
||||
scanNow := time.Now()
|
||||
// 退避过滤(t_20bfbd9a 治本①):next_retry_at 为 NULL(首次待处理)或已到重试时间,
|
||||
// 才进本轮重扫。持久性失败的坏行被指数退避挡在窗口外,不再每 commit 热重试。
|
||||
rows, err := db.Query(
|
||||
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
|
||||
FROM usage_recall WHERE consumed=0 AND recalled_at>=?
|
||||
AND (next_retry_at IS NULL OR next_retry_at<=?)`,
|
||||
cutoff, scanNow.Format(time.RFC3339))
|
||||
return def
|
||||
}
|
||||
|
||||
// usageScanBatch 执行一条批次查询并逐行判定,返回(判定样本, 失败行数, 本批最大 id)。
|
||||
// query 须选出 id,memory_id,content,tokens,namespace,agent,retry_count 七列。
|
||||
// 单行失败(AppendHitEvent/Update 终态失败)⇒ 不消费 + 指数退避(t_20bfbd9a 语义不变);
|
||||
// 失败累计 ≥ ZHIYI_USAGE_BACKLOG_MAX(>0 时)⇒ 熔断:本轮提前结束。
|
||||
func usageScanBatch(db *sql.DB, ldb LanceDB, agent, namespace string, contentSet map[string]bool,
|
||||
query string, args ...interface{}) (flags []UsageFlag, failed int, maxID int64) {
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
logUsage("query recall log: %v", err)
|
||||
return nil
|
||||
logUsage("query recall batch: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
contentSet := tokenSet(content)
|
||||
flags := make([]UsageFlag, 0)
|
||||
breakerMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var memID, memContent, tokensJSON, ns, ag string
|
||||
@@ -243,6 +286,9 @@ func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []Usage
|
||||
if err := rows.Scan(&id, &memID, &memContent, &tokensJSON, &ns, &ag, &retryCount); err != nil {
|
||||
continue
|
||||
}
|
||||
if id > maxID {
|
||||
maxID = id
|
||||
}
|
||||
// 归属过滤:同一 agent,或同一 namespace,或共享/全局。跨域不强求。
|
||||
if ag != "" && agent != "" && ag != agent && ns != "" && namespace != "" && ns != namespace {
|
||||
continue
|
||||
@@ -256,43 +302,159 @@ func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []Usage
|
||||
continue
|
||||
}
|
||||
// 判定「被用上」:落 outcome + 累加 recall_verified_count + 消费该 recall 行。
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
// t_7793a484:outcome 事件失败不再静默吞(修复前 `_ =` 丢错 + 单次 rpc 无重试 ⇒
|
||||
// 一次冲突即终态丢,10-16 实测 42 条丢失走此路径)。现:AppendHitEvent 内置
|
||||
// 有界重试(同 Update 规格);终态失败(耗尽/非可重试)⇒ 不消费(consumed 保持 0)
|
||||
// ⇒ 下一轮 commit 重扫重试,事件不静默丢。失败可查(logUsage 计数 + 计数器
|
||||
// zhiyi_hit_event_append_failed_total),禁静默。
|
||||
// t_7793a484:outcome 终态失败 ⇒ 不消费 + 指数退避(t_20bfbd9a),下一轮重扫
|
||||
// 重试,事件不静默丢。t_28aa278e 方案①:Update 成功后才消费,计数增量不丢。
|
||||
if err := ldb.AppendHitEvent(memID, HitOutcomePos, HitReasonDownstreamUse); err != nil {
|
||||
logUsage("hit event append FAILED (id=%s, NOT consumed, will retry next commit) : %v", memID, err)
|
||||
// t_20bfbd9a 治本①:持久性失败(如坏行 memID 已从 LanceDB 删除)若不加退避,
|
||||
// 会在每次 commit 都热重试,commit 风暴时叠成重扫风暴。加指数退避:
|
||||
// 第 n 次失败后 next_retry_at = now + min(2^(n-1) * base, cap),退避期外不再重扫。
|
||||
// 成功消费时把 retry_count 归零;每轮只推进 retry_count 一次(不指数级暴涨)。
|
||||
_, _ = db.Exec(
|
||||
`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
|
||||
logUsage("hit event append FAILED (id=%s, NOT consumed, will retry) : %v", memID, err)
|
||||
_, _ = db.Exec(`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
|
||||
time.Now().Add(usageBackoff(retryCount+1)).Format(time.RFC3339), id)
|
||||
failed++
|
||||
if breakerMax > 0 && failed >= breakerMax {
|
||||
logUsage("failure breaker: %d failures >= cap %d, abort this call", failed, breakerMax)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
// t_28aa278e 方案①:更新成功后才消费 usage_recall 行。
|
||||
// 旧实现无条件消费 ⇒ Update 冲突耗尽时该 recall_verified_count 增量被永久丢弃
|
||||
// (下轮不再重扫)。现改为:Update 返回非 nil(冲突重试耗尽/非可重试错误)
|
||||
// ⇒ 不消费(consumed 保持 0)⇒ 下一轮 commit 重扫该窗口会重试,计数增量不静默丢失。
|
||||
// 失败数可查(logUsage 计数),禁静默。
|
||||
if err := ldb.Update("memories", memID, map[string]any{
|
||||
"recall_verified_count": map[string]string{"$inc": "1"},
|
||||
}); err != nil {
|
||||
logUsage("recall_verified_count update FAILED (id=%s, NOT consumed, will retry next commit) : %v", memID, err)
|
||||
_, _ = db.Exec(
|
||||
`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
|
||||
logUsage("recall_verified_count update FAILED (id=%s, NOT consumed, will retry) : %v", memID, err)
|
||||
_, _ = db.Exec(`UPDATE usage_recall SET retry_count=retry_count+1, next_retry_at=? WHERE id=?`,
|
||||
time.Now().Add(usageBackoff(retryCount+1)).Format(time.RFC3339), id)
|
||||
failed++
|
||||
if breakerMax > 0 && failed >= breakerMax {
|
||||
logUsage("failure breaker: %d failures >= cap %d, abort this call", failed, breakerMax)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE usage_recall SET consumed=1, consumed_at=?, retry_count=0, next_retry_at=NULL WHERE id=?`, now, id); err != nil {
|
||||
logUsage("consume row id=%d failed: %v", id, err)
|
||||
continue
|
||||
}
|
||||
_, _ = db.Exec(`UPDATE usage_recall SET consumed=1, consumed_at=?, retry_count=0, next_retry_at=NULL WHERE id=?`, now, id)
|
||||
flags = append(flags, UsageFlag{
|
||||
MemoryID: memID, Content: memContent, Criterion: "carry_forward",
|
||||
MatchedTokens: toks, HitCount: hits, RunesMatched: runes, ConsumedAt: now,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// CheckCommittedForUse commit 写侧钩子:检查新写入的内容是否携带了
|
||||
// 最近被召回记忆的独特 token;命中则判定「被用上」并落 outcome(门控)。
|
||||
// 返回本次判定出的「被用上」记忆(供上报/取证)。
|
||||
// 消费调度(t_9143c968):expire→retry→tail→sweep→ttl,全程 O(batch),无全表计数。
|
||||
func CheckCommittedForUse(ldb LanceDB, agent, namespace, content string) []UsageFlag {
|
||||
if !usageTrackEnabled || ldb == nil || content == "" {
|
||||
return nil
|
||||
}
|
||||
db := usageOpen()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
usageMu.Lock()
|
||||
defer usageMu.Unlock()
|
||||
|
||||
batch := usageEnvInt("ZHIYI_USAGE_BATCH", 200)
|
||||
sweepBatch := usageEnvInt("ZHIYI_USAGE_SWEEP_BATCH", 500) // 0=关闭轮巡
|
||||
breakerMax := int(envFloat("ZHIYI_USAGE_BACKLOG_MAX", 500))
|
||||
cutoff := time.Now().Add(-time.Duration(usageWindowHours * float64(time.Hour))).Format(time.RFC3339)
|
||||
scanNow := time.Now().Format(time.RFC3339)
|
||||
contentSet := tokenSet(content)
|
||||
var scanned int
|
||||
|
||||
// ── expire:出窗未消费行标 consumed=2(skipped_expired,留证据不物理删)──
|
||||
if chunk := usageEnvInt("ZHIYI_USAGE_EXPIRE_CHUNK", 5000); chunk > 0 {
|
||||
if res, err := db.Exec(`UPDATE usage_recall SET consumed=2
|
||||
WHERE id IN (SELECT id FROM usage_recall WHERE consumed=0 AND recalled_at<? LIMIT ?)`,
|
||||
cutoff, chunk); err != nil {
|
||||
logUsage("expire mark: %v", err)
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
logUsage("expire mark: %d rows -> consumed=2 (skipped_expired)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── retry:退避到期的失败行优先(t_20bfbd9a 退避语义不变)──
|
||||
rf, rFailed, _ := usageScanBatch(db, ldb, agent, namespace, contentSet,
|
||||
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
|
||||
FROM usage_recall
|
||||
WHERE consumed=0 AND next_retry_at IS NOT NULL AND next_retry_at<=? AND recalled_at>=?
|
||||
ORDER BY id LIMIT ?`, scanNow, cutoff, batch)
|
||||
scanned += len(rf) + rFailed
|
||||
|
||||
// ── tail:游标向上 ≤batch 行(DESC 先吃最新;与本次 commit 因果对齐的主信号面)──
|
||||
var top int64
|
||||
_ = db.QueryRow(`SELECT COALESCE(MAX(id),0) FROM usage_recall`).Scan(&top)
|
||||
tail, sweep := usageCursorGet(db)
|
||||
tf, tFailed, _ := usageScanBatch(db, ldb, agent, namespace, contentSet,
|
||||
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
|
||||
FROM usage_recall
|
||||
WHERE consumed=0 AND id>? AND id<=? AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
|
||||
ORDER BY id DESC LIMIT ?`, tail, top, cutoff, scanNow, batch)
|
||||
scanned += len(tf) + tFailed
|
||||
newTail := top // 游标推进到表尾;本批未触达的落后行留给 sweep/expire(代价有界)
|
||||
if newTail < tail {
|
||||
newTail = tail // 库被外部重置等异常:游标不回退
|
||||
}
|
||||
|
||||
// ── sweep:轮巡复查未消费行;游标后无行 ⇒ 游标归 0 并【本轮立即从 0 补扫一次】
|
||||
// (「一次召回、后续多个 commit 逐个命中」依赖这条;至多两次有界查询,不整窗扫)──
|
||||
var sf []UsageFlag
|
||||
sFailed, sMax := 0, int64(0)
|
||||
newSweep := sweep
|
||||
if sweepBatch > 0 && breakerMax > 0 && rFailed+tFailed >= breakerMax {
|
||||
// 单次调用失败熔断(ZHIYI_USAGE_BACKLOG_MAX 收窄后的新语义):
|
||||
// 前面已积累 ≥cap 失败 ⇒ 本轮不再开新失败面(旧「全表积压跳过」已删除)。
|
||||
logUsage("sweep skipped by failure breaker (%d failures >= cap %d)", rFailed+tFailed, breakerMax)
|
||||
} else if sweepBatch > 0 {
|
||||
sf, sFailed, sMax = usageScanBatch(db, ldb, agent, namespace, contentSet,
|
||||
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
|
||||
FROM usage_recall
|
||||
WHERE consumed=0 AND id>? AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
|
||||
ORDER BY id LIMIT ?`, sweep, cutoff, scanNow, sweepBatch)
|
||||
if sMax == 0 && sweep > 0 { // 到表尾:回绕 0,本轮补扫
|
||||
var f2 []UsageFlag
|
||||
var x2 int
|
||||
f2, x2, sMax = usageScanBatch(db, ldb, agent, namespace, contentSet,
|
||||
`SELECT id, memory_id, content, tokens, namespace, agent, retry_count
|
||||
FROM usage_recall
|
||||
WHERE consumed=0 AND id>0 AND recalled_at>=? AND (next_retry_at IS NULL OR next_retry_at<=?)
|
||||
ORDER BY id LIMIT ?`, cutoff, scanNow, sweepBatch)
|
||||
sf = append(sf, f2...)
|
||||
sFailed += x2
|
||||
}
|
||||
scanned += len(sf) + sFailed
|
||||
if sMax > 0 {
|
||||
newSweep = sMax
|
||||
} else {
|
||||
newSweep = 0 // 表尾到达:回绕
|
||||
}
|
||||
}
|
||||
|
||||
usageCursorSet(db, newTail, newSweep)
|
||||
|
||||
// ── TTL:consumed>=1(含 2)且 recalled_at<7d 删除,chunk 化防长事务 ──
|
||||
if chunk := usageEnvInt("ZHIYI_USAGE_TTL_CHUNK", 5000); chunk > 0 {
|
||||
ttlCutoff := time.Now().Add(-7 * 24 * time.Hour).Format(time.RFC3339)
|
||||
if res, err := db.Exec(`DELETE FROM usage_recall
|
||||
WHERE id IN (SELECT id FROM usage_recall WHERE consumed>=1 AND recalled_at<? LIMIT ?)`,
|
||||
ttlCutoff, chunk); err != nil {
|
||||
logUsage("ttl purge: %v", err)
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
logUsage("ttl purged %d rows (consumed>=1, recalled_at<7d)", n)
|
||||
// 定期截断 WAL,防删除型事务把 -wal 撑大
|
||||
_, _ = db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||
}
|
||||
}
|
||||
|
||||
flags := make([]UsageFlag, 0, len(rf)+len(tf)+len(sf))
|
||||
flags = append(flags, rf...)
|
||||
flags = append(flags, tf...)
|
||||
flags = append(flags, sf...)
|
||||
if scanned > 0 || len(flags) > 0 {
|
||||
logUsage("usage scan: scanned=%d flagged=%d failed=%d tail=%d->%d sweep=%d->%d",
|
||||
scanned, len(flags), rFailed+tFailed+sFailed, tail, newTail, sweep, newSweep)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
|
||||
@@ -150,18 +150,22 @@ func TestUsageBackoff_ExponentialGrowthAndCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t_20bfbd9a 治本②:积压上限量化 —— 未消费待重试行超过 ZHIYI_USAGE_BACKLOG_MAX 时本轮跳过扫描。
|
||||
// t_20bfbd9a 治本②(旧):积压超 ZHIYI_USAGE_BACKLOG_MAX ⇒ 本轮 skip。
|
||||
// t_9143c968(2026-10-30)作废该语义:写入量涨到 40~200 万行/日后「无窗 pending>cap」
|
||||
// 恒成立 ⇒ 每 commit skip ⇒ 消费侧永久熔断(10-19 起 consumed=0)。cap 收窄为
|
||||
// 「单次调用失败熔断」(见 TestUsageCursor_FailureBreakerReplacesBacklogSkip)。
|
||||
// 本用例改为验证:积压再大也不再 skip —— 窗口内有可消费行就必须消费,
|
||||
// 窗口外 pending 不再阻塞(被 expire 标 2)。这正是卡体 AC1 的必要条件。
|
||||
func TestCheckCommittedForUse_BacklogCapSkipsScan(t *testing.T) {
|
||||
setupUsageTest(t)
|
||||
db := usageOpen()
|
||||
if db == nil {
|
||||
t.Fatal("usageOpen failed")
|
||||
}
|
||||
// 显式压小积压上限,验证超过即跳过
|
||||
// 压小 cap 到 2,且制造远超 cap 的 pending —— 旧实现此处必 skip。
|
||||
t.Setenv("ZHIYI_USAGE_BACKLOG_MAX", "2")
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
// 塞 3 条未消费召回行(超过上限 2)
|
||||
for i := 0; i < 3; i++ {
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := db.Exec(`INSERT INTO usage_recall(memory_id, content, tokens, q_hash, namespace, agent, recalled_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
"mem_bk"+string(rune('0'+i)), "牧尘今天要求处理看板任务验收"+string(rune('0'+i)),
|
||||
@@ -169,15 +173,15 @@ func TestCheckCommittedForUse_BacklogCapSkipsScan(t *testing.T) {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
// 无需真实 LanceDB(ldb=nil 直接返回 nil flags);本用例只验证「超限即提前跳过」不 panic 且不消费
|
||||
flags := CheckCommittedForUse(nil, "agent", "ns", "牧尘今天要求处理看板任务验收0")
|
||||
if flags != nil {
|
||||
t.Fatalf("ldb=nil 应直接返回 nil flags(超限跳过不应触及消费路径), got %v", flags)
|
||||
// 窗口内有可消费行 ⇒ 即使 pending(5)≫cap(2) 也必须消费它(不再 skip)。
|
||||
mlc := NewMemLanceClient(nil)
|
||||
flags := CheckCommittedForUse(mlc, "agent", "ns", "牧尘今天要求处理看板任务验收")
|
||||
if len(flags) == 0 {
|
||||
t.Fatalf("积压 > cap 不再触发 skip:窗口内可消费行必须被判定, got 0 flags")
|
||||
}
|
||||
// 判据:超限跳过时所有行仍 consumed=0(没有消费行为发生)
|
||||
var pending int
|
||||
_ = db.QueryRow(`SELECT COUNT(*) FROM usage_recall WHERE consumed=0`).Scan(&pending)
|
||||
if pending != 3 {
|
||||
t.Fatalf("超限跳过扫描后应 3 行全部未消费(no consumption), got %d", pending)
|
||||
// 被消费的行 consumed=1;旧实现这里会 5 行全 consumed=0。
|
||||
if c := usagePendingBy(t, db, "consumed=1"); c == 0 {
|
||||
t.Fatalf("修复后应至少消费 1 行(旧 skip 语义已废), actual %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,3 +14,7 @@ a2be088cba59c95901b22d855dea9b6df3bf3a80
|
||||
4341fac4c28a4f2825f7373ea0ec94d07c6452f0
|
||||
b101b464471f6283c438f089e9ec269c8f2b1644
|
||||
01d8f34
|
||||
a8e15c9ceee7978272d57e9dc7bf8f29c1b7970c
|
||||
6952bdb2138bb7b14479f64553fb911b24e9dda4
|
||||
ebe6384b733e52a910cd72aaf7afdb94128c72a1
|
||||
dbdb2ed08e0ba4a93cf43127e0e739eee114b4f6
|
||||
|
||||
Reference in New Issue
Block a user