Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd3793336 | ||
|
|
d8c525b544 | ||
|
|
93a11c2fb7 | ||
|
|
1774c310b1 | ||
|
|
75abce7cca | ||
|
|
826272f981 | ||
|
|
3102be6f65 | ||
|
|
7870b849cc | ||
|
|
afdab8fb6b | ||
|
|
3964f0332c | ||
|
|
7562714819 |
@@ -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))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})()
|
||||
}
|
||||
@@ -750,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -43,16 +43,22 @@ type Config struct {
|
||||
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: 2,
|
||||
ClusterThresh: 0.85,
|
||||
MinClusterSize: 3,
|
||||
MaxRecords: 500,
|
||||
TopKPatternList: 10,
|
||||
MinComboFreq: 2,
|
||||
ClusterThresh: 0.85,
|
||||
MinClusterSize: 3,
|
||||
MaxRecords: 500,
|
||||
TopKPatternList: 10,
|
||||
WorkflowPatterns: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +222,9 @@ func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
|
||||
}
|
||||
|
||||
// 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 := ""
|
||||
@@ -241,6 +249,7 @@ func DetectFromRecords(records []models.MemoryRecord, cfg Config) []Pattern {
|
||||
SourceTasks: ids,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Pattern, 0, len(order))
|
||||
for _, n := range order {
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestDetect_BelowThreshold(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user