mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-04 08:17:32 +00:00
记忆层: - 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 缺失。
446 lines
13 KiB
Go
446 lines
13 KiB
Go
// 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)
|
||
}
|