Files
HomeAgent/cmd/homed-predicate-migrate/main.go
JianFeeeee b850feaef2 feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
2026-10-29 23:53:48 +08:00

446 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Command homed-predicate-migrate 把存量关系谓词收敛到**封闭集**,默认只统计不写库。
//
// 背景:生产库 relations 表 964 条 active 边对应 690 种不同 relation_type,
// 其中只出现 1 次的有 559 种(80%)。同一关系千种写法 ⇒ memory_recall
// 不可能靠关系名命中。谓词不封闭是召回质量差的直接原因之一。
//
// 数据来源:封闭集与映射表由外部起草后交付(JSON),本工具只消费、不改它。
//
// closed-predicate/predicates.md 30 个语义角色谓词(人读)
// closed-predicate/mapping.json 690 种旧谓词 → 新谓词 或 DROP(机器读)
// closed-predicate/invert.json 属性名反转规则(可选,机器读)
//
// 安全设计(与 homed-kb-migrate 同一套惯例):
// 1. **默认只报告**。-apply 才写库。谓词改写会影响召回,不可逆。
// 2. 冲突检查为 0 才允许 -apply:同一旧谓词映射到多个新谓词属歧义,
// 真写库会造成不可预测结果。
// 3. count 求和必须等于 active 边数:不等说明 mapping 与库已不同步
// (库被写过 / mapping 基于旧快照),此时拒绝 apply。
//
// 用法:
//
// homed-predicate-migrate -db /data/memory/graph.db -mapping closed-predicate
// homed-predicate-migrate -db ... -mapping ... -apply
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
_ "github.com/mattn/go-sqlite3"
)
// mapEntry 是 mapping.json 的一条:旧谓词 → 新谓词(new 为空表示丢弃)。
type mapEntry struct {
Old string `json:"old"`
Count int `json:"count"`
New *string `json:"new"`
Reason string `json:"reason"`
// verdict 是 dry-run 判定的最终去向,由 invert.json 参与后决定:
// mapped / debris / pending_invert / unmapped
verdict string
// invertTo 是反转后的谓词(仅 pending_invert 有值)
invertTo string
}
// invertSpec 是 invert.json 的**规则式**格式(不是 old→new 直映)。
//
// 为什么是规则:抽取侧把「值」提到了谓词位(如「教室」其实是属性名、
// 真正的谓词空缺),需要按**宾语形态**反推真实关系,而不是按谓词查表。
// 规则里 applies_to 指明它作用于哪类 reason(drop_unmapped / drop_funcword)。
type invertSpec struct {
Version string `json:"version"`
Rules []invertRule `json:"rules"`
// AttributeNounWhitelist 是「旧谓词本身是属性名词」的白名单。
AttributeNounWhitelist []string `json:"attribute_noun_whitelist"`
// ObjectValueSignals 是值形态信号(含数字、长度区间、拒收前缀)。
ObjectValueSignals struct {
ContainsDigit bool `json:"contains_digit"`
LengthMin int `json:"length_min"`
LengthMax int `json:"length_max"`
RejectPrefix []string `json:"reject_prefix"`
} `json:"object_value_signals"`
}
type invertRule struct {
ID string `json:"id"`
AppliesTo []string `json:"applies_to"`
New *string `json:"new"` // null 表示该规则不反转(仍丢弃)
When struct {
RelationTypeIn []string `json:"relation_type_in"`
RelationTypeInWhitelist bool `json:"relation_type_in_whitelist"`
ObjectIsValue bool `json:"object_is_value"`
} `json:"when"`
}
func main() {
dbPath := flag.String("db", "", "graph.db 路径")
mapDir := flag.String("mapping", "", "封闭集目录(含 mapping.json / invert.json)")
apply := flag.Bool("apply", false, "真正写库(默认只报告)")
topN := flag.Int("top", 40, "丢弃明细里按 count 列出的条数(0=全列)")
flag.Parse()
if *dbPath == "" || *mapDir == "" {
fmt.Fprintln(os.Stderr, "必须提供 -db 与 -mapping")
os.Exit(2)
}
entries, err := loadMapping(*mapDir)
if err != nil {
fail("读取 mapping: %v", err)
}
inverts, err := loadInvert(*mapDir)
if err != nil {
// invert.json 可选:未到位时只报告,不阻断
fmt.Fprintf(os.Stderr, "[warn] invert.json 未就位(%v),pending_invert 将无法解析\n", err)
}
// dry-run 用只读 URI(mattn 驱动要求 file: 前缀);-apply 才可写。
dsn := *dbPath
if !*apply {
dsn = "file:" + *dbPath + "?mode=ro"
}
db, err := sql.Open("sqlite3", dsn)
if err != nil {
fail("打开库: %v", err)
}
defer db.Close()
// ① 库侧真实分布(mapping 的 count 只是快照,必须与库核对)
dbCounts, total, err := loadDBCounts(db)
if err != nil {
fail("读库: %v", err)
}
sumCounts := 0
for _, e := range entries {
sumCounts += e.Count
}
fmt.Printf("库:%d 种 relation_type,%d 条 active 边\n", len(dbCounts), total)
fmt.Printf("mapping:%d 条,count 求和 %d\n", len(entries), sumCounts)
// 判据 1:mapping 与库同步
syncOK := sumCounts == total
if !syncOK {
fmt.Printf("❌ 判据1 失败:mapping count 求和 %d != 库 active 边 %d(mapping 与库不同步)\n", sumCounts, total)
} else {
fmt.Printf("✅ 判据1:mapping 与库同步(%d == %d)\n", sumCounts, total)
}
// ② 判定去向
applyInvert(entries, inverts)
verdicts := tally(entries)
// 判据 2:无歧义(同一旧谓词只能有一个去向)
conflicts := findConflicts(entries)
if len(conflicts) > 0 {
fmt.Printf("❌ 判据2 失败:%d 个旧谓词映射有歧义\n", len(conflicts))
for _, c := range conflicts {
fmt.Printf(" %s\n", c)
}
} else {
fmt.Printf("✅ 判据2:无歧义映射\n")
}
// ③ 四张表
printByNewPredicate(entries)
printDrops(entries, *topN)
printCoverage(entries, total, verdicts)
// 判据 3:写库前复核(写前再查一次,防 dry-run 到 apply 之间库被改)
if *apply {
if !syncOK || len(conflicts) > 0 {
fail("拒绝 apply:判据未全绿(见上)")
}
if err := applyChanges(db, entries); err != nil {
fail("写库: %v", err)
}
fmt.Println("\n已写库。")
} else {
fmt.Println("\n(dry-run:未写库。确认无误后加 -apply)")
}
}
func loadMapping(dir string) ([]*mapEntry, error) {
b, err := os.ReadFile(filepath.Join(dir, "mapping.json"))
if err != nil {
return nil, err
}
var raw []*mapEntry
if err := json.Unmarshal(b, &raw); err != nil {
return nil, err
}
return raw, nil
}
func loadInvert(dir string) (*invertSpec, error) {
b, err := os.ReadFile(filepath.Join(dir, "invert.json"))
if err != nil {
return nil, err
}
var spec invertSpec
if err := json.Unmarshal(b, &spec); err != nil {
return nil, err
}
return &spec, nil
}
// resolveInvert 按规则判定某条旧谓词是否应反转,返回反转后的谓词。
//
// 判定只用**谓词本身**能否命中规则条件(宾语形态规则需要读边表才能判,
// dry-run 阶段只统计「谓词可判定」的部分,宾语相关的留给 apply 前的逐条检查)。
func (sp *invertSpec) resolveInvert(e *mapEntry) (string, bool) {
if sp == nil {
return "", false
}
whitelist := make(map[string]bool, len(sp.AttributeNounWhitelist))
for _, w := range sp.AttributeNounWhitelist {
whitelist[w] = true
}
for _, r := range sp.Rules {
// applies_to 必须包含本条的 reason
match := false
for _, a := range r.AppliesTo {
if a == e.Reason {
match = true
break
}
}
if !match {
continue
}
// 规则要求"谓词在属性名词白名单里"
if r.When.RelationTypeInWhitelist && whitelist[e.Old] {
if r.New != nil && *r.New != "" {
return *r.New, true
}
return "", false
}
// 规则枚举了具体谓词(如系词)
for _, w := range r.When.RelationTypeIn {
if w == e.Old {
if r.New != nil && *r.New != "" {
return *r.New, true
}
return "", false
}
}
}
return "", false
}
func loadDBCounts(db *sql.DB) (map[string]int, int, error) {
rows, err := db.Query(`SELECT relation_type, COUNT(*) FROM relations WHERE status='active' GROUP BY relation_type`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := map[string]int{}
total := 0
for rows.Next() {
var k string
var c int
if err := rows.Scan(&k, &c); err != nil {
return nil, 0, err
}
out[k] = c
total += c
}
return out, total, rows.Err()
}
// applyInvert 决定每条 mapping 的最终去向。
//
// 有 new ⇒ mapped;无 new 但 invert 表里命中 ⇒ pending_invert;
// 否则按 reason 分 debris(碎片)或 unmapped(待判断)。
func applyInvert(entries []*mapEntry, sp *invertSpec) {
for _, e := range entries {
if e.New != nil && *e.New != "" {
e.verdict = "mapped"
continue
}
if to, ok := sp.resolveInvert(e); ok {
e.verdict = "pending_invert"
e.invertTo = to
continue
}
if e.Reason == "drop_funcword" || e.Reason == "drop_broken_punct" ||
e.Reason == "drop_too_short" || e.Reason == "drop_too_long" {
e.verdict = "debris"
} else {
e.verdict = "unmapped"
}
}
}
func tally(entries []*mapEntry) map[string]int {
m := map[string]int{}
for _, e := range entries {
m[e.verdict]++
}
return m
}
// findConflicts 检查同一旧谓词是否有多个去向(mapping 里出现重复 old)。
func findConflicts(entries []*mapEntry) []string {
seen := map[string]string{}
var out []string
for _, e := range entries {
to := e.verdict
if e.New != nil {
to = "new:" + *e.New
}
if prev, ok := seen[e.Old]; ok && prev != to {
out = append(out, fmt.Sprintf("%s: %s vs %s", e.Old, prev, to))
}
seen[e.Old] = to
}
return out
}
func printByNewPredicate(entries []*mapEntry) {
type agg struct{ types, edges int }
m := map[string]*agg{}
for _, e := range entries {
if e.verdict == "mapped" {
if m[*e.New] == nil {
m[*e.New] = &agg{}
}
m[*e.New].types++
m[*e.New].edges += e.Count
}
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return m[keys[i]].edges > m[keys[j]].edges })
fmt.Printf("\n## 表1 按新谓词聚合(%d 个有收编)\n", len(m))
fmt.Printf("%-20s %8s %8s\n", "新谓词", "旧种数", "边数")
for _, k := range keys {
fmt.Printf("%-20s %8d %8d\n", k, m[k].types, m[k].edges)
}
}
func printDrops(entries []*mapEntry, topN int) {
var drops []*mapEntry
for _, e := range entries {
if e.verdict != "mapped" {
drops = append(drops, e)
}
}
sort.Slice(drops, func(i, j int) bool { return drops[i].Count > drops[j].Count })
fmt.Printf("\n## 表2 丢弃/待反转明细(%d 种,按 count 降序)\n", len(drops))
if topN > 0 && len(drops) > topN {
fmt.Printf("(仅列前 %d 条,-top 0 可全列)\n", topN)
}
fmt.Printf("%6s %-14s %-26s %s\n", "count", "去向", "旧谓词", "说明")
for i, e := range drops {
if topN > 0 && i >= topN {
break
}
note := e.Reason
if e.verdict == "pending_invert" {
note = "反转→" + e.invertTo
}
fmt.Printf("%6d %-14s %-26s %s\n", e.Count, e.verdict, truncate(e.Old, 24), note)
}
}
// printCoverage 算两版覆盖率:只算 mapped,与 mapped+pending_invert。
func printCoverage(entries []*mapEntry, total int, verdicts map[string]int) {
mapped, invert := 0, 0
for _, e := range entries {
switch e.verdict {
case "mapped":
mapped += e.Count
invert += e.Count
case "pending_invert":
invert += e.Count
}
}
fmt.Printf("\n## 表3 覆盖率\n")
fmt.Printf("库 active 边总数 %5d\n", total)
fmt.Printf("mapped 保留 %5d (%.1f%%)\n", mapped, pct(mapped, total))
fmt.Printf("mapped+invert 保留 %5d (%.1f%%)\n", invert, pct(invert, total))
fmt.Printf("真正丢弃 %5d (%.1f%%)\n", total-invert, pct(total-invert, total))
fmt.Printf("\n判定去向分布:")
for k, v := range verdicts {
fmt.Printf(" %s=%d", k, v)
}
fmt.Println()
}
func applyChanges(db *sql.DB, entries []*mapEntry) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
upd, err := tx.Prepare(`UPDATE relations SET relation_type=?, updated_at=datetime('now') WHERE relation_type=? AND status='active'`)
if err != nil {
return err
}
defer upd.Close()
del, err := tx.Prepare(`UPDATE relations SET status='deleted', updated_at=datetime('now') WHERE relation_type=? AND status='active'`)
if err != nil {
return err
}
defer del.Close()
nUpd, nDel := 0, 0
for _, e := range entries {
switch e.verdict {
case "mapped":
r, err := upd.Exec(*e.New, e.Old)
if err != nil {
return fmt.Errorf("更新 %s: %w", e.Old, err)
}
c, _ := r.RowsAffected()
nUpd += int(c)
case "debris":
r, err := del.Exec(e.Old)
if err != nil {
return fmt.Errorf("删除 %s: %w", e.Old, err)
}
c, _ := r.RowsAffected()
nDel += int(c)
}
}
if err := tx.Commit(); err != nil {
return err
}
fmt.Printf("更新 %d 条,标记删除 %d 条\n", nUpd, nDel)
return nil
}
func pct(a, b int) float64 {
if b == 0 {
return 0
}
return float64(a) / float64(b) * 100
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func fail(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}