Files
HomeAgent/internal/memory/pipeline/pipeline.go
JianFeeeee b37141f3f5 fix(memory): doc→graph 补回常用词闸门 + 存量噪音/孤立节点清理
问题:图记忆里堆着「结果(192) / 什么(59) / 哪个(99) / 待命(176) / 报告(174) /
context_archived(43)」这类节点,mention_count 冲到几百、度数只有 1~2——
占着热实体位、挤满召回预算,却不带任何结构。

根因:这层过滤原本存在,后来被换掉没补回。1cb3e87(NLP 三元组提取系统)
把 doc→graph 从「CutExact 滑窗词链」换成依存句法提取器时,CutExact
(去停用词 324 条 + validEntityName + 去重,注释至今还写着「用于 doc→graph
蒸馏」)失去了唯一生产调用点,只剩 cut_test.go 在调它。此后落库闸门只剩
validEntityName——它挡的是「不像名字的字符串」(2–50 字符、含字母),
完全不挡「像名字的常用词」。

改动:
- 新增 internal/memory/noise.go:IsNoiseEntity 收敛判定(停用词 /
  context_archived / 模板摘要回声),FilterNoiseTriples 给自动填充路用,
  PurgeNoise + PurgeOrphans + cmd/memgc 供存量清理(默认 dry-run)。
  判定刻意保守:只拦三类客观噪音,开放类词(报告/对话/处理)不拦——
  它们挡不挡是领域决策,见函数注释。
- docToTriples 接上闸门(用户点名的「文档常用词」路)。
  对话蒸馏路 extractKeyTriples **不接**:CutExact 当年也只挂 doc→graph,
  且 pipeline_test 明确断言「我 --读书--> 杭州」必须抽出(代词主语是该路
  既定行为),是否拦属行为决策,已在代码注释里写明并留给使用者定夺。
- cut.go 补全封闭类常用词:咱俩/咱们(我们/你们/他们 早有)、任何/此/本/
  其中/以及/那么/这样/那样/一样/还有/还要/只是/老是/全部/所有/有些/一些/
  别的/其他/其余/各自/本身/方位词/部分/方面。
  「不能/不会」试过又撤回:它们是 embedder tokenize 的实词路径,
  加进停用词会让 static_embedder 的领域聚类用例翻转(今天天气句与股票句
  的相似度大小关系反了),属真回归,不留。
- graph.go:CleanupOrphanedSentences 拆出 Locked 版供 PurgeNoise 复用。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:IsNoiseEntity 判定表、FilterNoiseTriples 顺序与边界、
PurgeNoise(统计/幂等/dry-run 不写库/不误删仍被媒体块边引用的句子)、
PurgeOrphans(识别/不误判有边实体/幂等)、docToTriples 噪音闸门与
模板锚点不被误杀。

存量清理(生产库 /home/newqqagent/memory/graph.db,先用 sqlite3 .backup 备份
到 graph.db.bak-20260915-073210):
- 噪音实体 29 个 + 其关系 59 条
- 零关系孤立实体 25 个
- 结果:实体 778→724,关系 639→580;sentences 3 条与 block_edges 3 条原样保留
  (媒体块引用不被误删),PRAGMA integrity_check=ok、无悬空关系/块边。
- 运行中的 homed 无需重启:下一个 archive 心跳会 Indexer.Sync 重建实体名向量索引。
2026-09-15 07:47:16 +08:00

465 lines
12 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.

package pipeline
import (
"bufio"
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
)
type RawRecord struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Distilled bool `json:"distilled"`
// persisted 表示该记录已经写在磁盘 raw 文件里。
// 未导出:只影响本进程的落盘行为,不进 JSON。
// 作用flush 只写**尚未落盘**的记录,否则 loadExisting 载入的记录
// 会被再写一份,重启后同一批记录从新旧两个文件各读回一次。
persisted bool
}
type DistillerConfig struct {
Interval time.Duration `json:"interval"`
RetentionDays int `json:"retention_days"`
BatchSize int `json:"batch_size"`
}
type Distiller struct {
mu sync.Mutex
db *memory.GraphDB
rawPath string
records []RawRecord
nextID int64
cfg DistillerConfig
ctx context.Context
cancel context.CancelFunc
onMemory func(input, response string)
embedder nlp.Vectorizer
}
func (d *Distiller) SetEmbedder(ev nlp.Vectorizer) { d.embedder = ev }
func NewDistiller(db *memory.GraphDB, dataDir string, cfg DistillerConfig) *Distiller {
ctx, cancel := context.WithCancel(context.Background())
return &Distiller{
db: db,
rawPath: filepath.Join(dataDir, "memory", "raw"),
cfg: cfg,
ctx: ctx,
cancel: cancel,
}
}
func (d *Distiller) OnMemoryCandidate(fn func(input, response string)) {
d.onMemory = fn
}
func (d *Distiller) Start() {
if err := os.MkdirAll(d.rawPath, 0755); err != nil {
log.Printf("[memory] create raw path: %v", err)
}
d.loadExisting()
log.Printf("[memory] distiller started (interval: %v, retention: %d days)", d.cfg.Interval, d.cfg.RetentionDays)
go d.distillLoop()
}
func (d *Distiller) Stop() {
d.cancel()
d.flush()
}
// Stopped 报告蒸馏循环是否已被 Stop() 取消。
//
// Stop() 里的 cancel() 是同步生效的,所以本方法在 Stop() 返回后立即为 true
// 不受循环 goroutine 何时退出的影响。启动自检、健康检查用它确认
// 「Start 之后没有被立即 Stop 掉」——历史回归main() 拆分时
// initMemoryStack 里残留一句 defer distiller.Stop(),函数一返回就把刚起的
// 循环杀了10min 心跳从不运行。Start() 之前返回 false尚未被停
func (d *Distiller) Stopped() bool {
select {
case <-d.ctx.Done():
return true
default:
return false
}
}
func (d *Distiller) Append(sessionID string, role string, content string) {
d.mu.Lock()
defer d.mu.Unlock()
d.nextID++
d.records = append(d.records, RawRecord{
ID: d.nextID, SessionID: sessionID, Role: role,
Content: content, CreatedAt: time.Now(),
})
}
func (d *Distiller) flush() {
d.mu.Lock()
defer d.mu.Unlock()
d.flushLocked()
}
// flushLocked 把**尚未落盘**的记录追加写入一个新的 raw 文件(原子写)。
//
// 只写 !persisted 的记录loadExisting 载入的记录已经在磁盘上,若 flush 再把
// 它们整体重写一份,重启后同一批记录会同时从旧文件与新文件被读回,
// 实体 mention_count 与关系被重复蒸馏。
func (d *Distiller) flushLocked() {
var pending []RawRecord
for _, r := range d.records {
if !r.persisted {
pending = append(pending, r)
}
}
if len(pending) == 0 {
return
}
path := filepath.Join(d.rawPath, fmt.Sprintf("raw_%d.tsv", time.Now().UnixNano()))
var sb strings.Builder
for _, r := range pending {
fmt.Fprintf(&sb, "%d\t%s\t%s\t%s\t%d\n", r.ID, r.SessionID, r.Role, r.Content, r.CreatedAt.Unix())
}
if err := writeFileAtomic(path, []byte(sb.String())); err != nil {
log.Printf("[memory] flush error: %v", err)
return
}
for i := range d.records {
d.records[i].persisted = true
}
}
func (d *Distiller) loadExisting() {
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -d.cfg.RetentionDays)
type fileInfo struct {
name string
mod time.Time
}
var files []fileInfo
for _, entry := range entries {
ext := filepath.Ext(entry.Name())
if ext != ".tsv" && ext != ".jsonl" {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(d.rawPath, entry.Name()))
continue
}
files = append(files, fileInfo{name: entry.Name(), mod: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })
loaded := 0
const maxStartupRecords = 5000
for _, entry := range files {
if loaded >= maxStartupRecords {
break
}
path := filepath.Join(d.rawPath, entry.name)
f, err := os.Open(path)
if err != nil {
continue
}
func() {
defer f.Close()
scanner := bufio.NewScanner(f)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
if loaded >= maxStartupRecords {
break
}
line := scanner.Text()
parts := splitLine(line)
if len(parts) < 5 {
continue
}
ts, err := strconv.ParseInt(parts[4], 10, 64)
if err != nil {
continue
}
createdAt := time.Unix(ts, 0)
if createdAt.Before(cutoff) {
continue
}
d.records = append(d.records, RawRecord{
ID: d.nextID, SessionID: parts[1], Role: parts[2], Content: parts[3], CreatedAt: createdAt,
persisted: true,
})
d.nextID++
loaded++
}
}()
}
if loaded >= maxStartupRecords {
log.Printf("[memory] distiller startup load capped at %d recent records", loaded)
}
}
func (d *Distiller) distillLoop() {
ticker := time.NewTicker(d.cfg.Interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
d.distillOnce()
case <-d.ctx.Done():
return
}
}
}
func (d *Distiller) distillOnce() {
d.mu.Lock()
batchSize := d.cfg.BatchSize
if batchSize <= 0 {
batchSize = 50
}
// 每 tick 取前 N 条未蒸馏记录(无 RetentionDays 门槛),蒸馏成功才标记/移除
var toDistill []RawRecord
var remaining []RawRecord
for _, r := range d.records {
if !r.Distilled && len(toDistill) < batchSize {
toDistill = append(toDistill, r)
} else {
remaining = append(remaining, r)
}
}
d.records = remaining
d.mu.Unlock()
if len(toDistill) == 0 {
return
}
distilled := 0
for i := 0; i < len(toDistill); i += batchSize {
end := i + batchSize
if end > len(toDistill) {
end = len(toDistill)
}
if d.distillBatch(toDistill[i:end]) {
distilled += end - i
// 成功即从磁盘 raw 文件里删除这些行,否则重启后 loadExisting
// 会把它们当未蒸馏记录重新读回,每次启动重蒸同一批历史。
d.removeRawRecords(toDistill[i:end])
} else {
// 蒸馏失败:记录写回待处理队列,下次 tick 重试
d.mu.Lock()
d.records = append(toDistill[i:end], d.records...)
d.mu.Unlock()
}
}
d.cleanupRawFiles()
if distilled > 0 {
log.Printf("[memory] distilled %d records", distilled)
}
}
// distillBatch 蒸馏一批记录,全部成功返回 true任一失败返回 false调用方重试
func (d *Distiller) distillBatch(batch []RawRecord) bool {
var userContent, assistantContent string
sessionIDs := make(map[string]bool)
for _, r := range batch {
sessionIDs[r.SessionID] = true
if r.Role == "user" {
userContent += r.Content + " "
} else {
assistantContent += r.Content + " "
}
}
triples := extractKeyTriples(userContent, assistantContent, d.embedder)
if len(triples) > 0 {
sessionID := ""
for sid := range sessionIDs {
sessionID = sid
break
}
if _, _, err := d.db.Commit(triples, sessionID, 0); err != nil {
log.Printf("[memory] distill commit: %v", err)
return false
}
}
return true
}
func (d *Distiller) cleanupRawFiles() {
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
cutoff := time.Now().AddDate(0, 0, -(d.cfg.RetentionDays + 1))
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(d.rawPath, entry.Name()))
}
}
}
// writeFileAtomic 写临时文件再 rename避免进程在写一半时崩溃留下半个文件。
func writeFileAtomic(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// rawKey 唯一标识一条原始记录,用于在 raw 文件里按内容定位并删除。
//
// 为什么按内容而不是 IDloadExisting 读回记录时会重新分配连续 ID
// 文件里的 ID 与内存 ID 并不一一对应。
func rawKey(session, role, content string, ts int64) string {
return session + "\x00" + role + "\x00" + content + "\x00" + strconv.FormatInt(ts, 10)
}
// removeRawRecords 从磁盘 raw 文件中删除已成功蒸馏的记录。
//
// 蒸馏成功后记录若只从内存移除、磁盘文件不动,下次启动 loadExisting 会把
// 它们当未蒸馏记录重新读回,导致每次重启都重蒸同一批历史(实体
// mention_count 膨胀,且 distillBatch 的 sessionID 取自 map 首个键,
// 不确定性会放大重复)。
func (d *Distiller) removeRawRecords(batch []RawRecord) {
if len(batch) == 0 {
return
}
drop := make(map[string]bool, len(batch))
for _, r := range batch {
drop[rawKey(r.SessionID, r.Role, r.Content, r.CreatedAt.Unix())] = true
}
entries, err := os.ReadDir(d.rawPath)
if err != nil {
return
}
for _, entry := range entries {
ext := filepath.Ext(entry.Name())
if ext != ".tsv" && ext != ".jsonl" {
continue
}
path := filepath.Join(d.rawPath, entry.Name())
data, err := os.ReadFile(path)
if err != nil {
continue
}
var kept []string
removed := false
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
if line == "" {
continue
}
parts := splitLine(line)
if len(parts) >= 5 {
if ts, err := strconv.ParseInt(parts[4], 10, 64); err == nil &&
drop[rawKey(parts[1], parts[2], parts[3], ts)] {
removed = true
continue
}
}
kept = append(kept, line)
}
if !removed {
continue
}
if len(kept) == 0 {
os.Remove(path)
continue
}
if err := writeFileAtomic(path, []byte(strings.Join(kept, "\n")+"\n")); err != nil {
log.Printf("[memory] rewrite raw %s: %v", entry.Name(), err)
}
}
}
func extractKeyTriples(userContent, assistantContent string, embedder nlp.Vectorizer) []memory.Triple {
var triples []memory.Triple
e := nlp.NewExtractor(nil)
if embedder != nil {
e.SetEmbedder(embedder)
}
text := userContent
if assistantContent != "" {
text += assistantContent
}
result := e.Extract(text)
if result != nil {
for _, nt := range result.Triples {
mt := nlp.ToMemoryTriple(nt)
if mt.Subject != "" && mt.Relation != "" && mt.Object != "" {
triples = append(triples, mt)
}
}
}
// 注意:这里**不**做噪音过滤。对话蒸馏的抽取器(与 doc→graph 共用一个
// NLP 提取器历史上就没有常用词闸门CutExact 那层当年只挂在 doc→graph 上。
// 而且 pipeline_test 明确断言「我 --读书--> 杭州」必须被抽出(代词作主语是
// 该路的既定行为)。要不要在对话路也拦常用词是行为决策,不在此处擅改,
// 参见 memory.IsNoiseEntity 的说明。
return triples
}
func truncate(s string, max int) string {
if len(s) > max {
return s[:max] + "..."
}
return s
}
func splitLine(line string) []string {
if line == "" {
return nil
}
return strings.SplitN(line, "\t", 5)
}
func (d *Distiller) GetRecentRecords(limit int) []RawRecord {
d.mu.Lock()
defer d.mu.Unlock()
n := len(d.records)
if n == 0 {
return nil
}
if limit > 0 && limit < n {
n = limit
}
result := make([]RawRecord, n)
copy(result, d.records[len(d.records)-n:])
return result
}
func (d *Distiller) Stats() map[string]interface{} {
d.mu.Lock()
defer d.mu.Unlock()
return map[string]interface{}{
"raw_records": len(d.records),
"interval": d.cfg.Interval.String(),
"retention_days": d.cfg.RetentionDays,
}
}