Files
HomeAgent/internal/memory/pipeline/pipeline.go
JianFeeeee 6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00

602 lines
18 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"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/internal/memory/distill"
"github.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
// embed 把块文本变成向量与指纹;nil 表示不带向量(不编造)。
embed EmbedFunc
// splitter 是小模型拆分器(pkg/generation 提供的 provider)。
//
// ★ 为什么要有它:jieba/ONNX 那条自动蒸馏路实测产出 **0 条**
//(defaultParser 为 nil,POS 模板抽不出),于是图记忆里的 188 个实体
// 全部是模型主动调 memory_commit 写进来的**整句复合值**
//(admin服务端口8861·billing服务端口8499·oauth服务端口8271)。
// 后果是跨维度检索全失效:严格判据下基线 0/5。
//
// splitter 为 nil 时回退 extractKeyTriples(现有 jieba 路),
// 保持蒸馏不停摆 —— 哪怕产出 0 条也比整体失败好。
splitter RecordSplitter
}
// RecordSplitter 把一条原始记录拆成三元组。
//
// 抽成接口是为了让 pipeline 不依赖 internal/memory/distill(那个包依赖
// pkg/generation,而 pipeline 在早期启动阶段不该拉起生成侧依赖)。
type RecordSplitter interface {
Split(ctx context.Context, record string) ([]memory.Triple, error)
}
// SetSplitter 注入小模型拆分器。为 nil 时蒸馏回退 jieba 路。
func (d *Distiller) SetSplitter(s RecordSplitter) { d.splitter = s }
// SetEmbedFunc 注入块向量计算函数(由持有 embedding provider 的一方提供)。
func (d *Distiller) SetEmbedFunc(fn EmbedFunc) { d.embed = fn }
// SetEmbedder 注入词嵌入器,供既有 jieba/ONNX 抽取路做 TransE 语义验证。
// 与 SetSplitter 并存:splitter 存在时优先生效,embedder 仍用于回退路径。
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)
}
}
// distillSplitTimeout 是单次小模型拆分调用的上限。
//
// 实测单条 4-9s(qwen3:1.7b + schema 约束,CPU)。超时不能太短,
// 否则每条都超时 = splitter 恒失败 = 静默退化成 0 产出的 jieba 路;
// 也不能太长,否则一批 50 条会把 30 分钟的蒸馏间隔吃穿。
const distillSplitTimeout = 90 * time.Second
// 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 + " "
}
}
// 块路径优先(正确形态),Triple 路保留为对照期回退。
//
// ★ 为什么改(本会话认知纠错):拆解产物的正确落库形态是
//【句子】【contains 边】【块】—— 节点已从纯文本实体升级为带向量的
// memory_blocks(46f833c)。把拆解结果 Commit 成 entities 是把新产出
// 灌进正在退场的旧形态;而旧 jieba 路实测产出 0 条,保留它只为
//「块路不可用时记忆不丢」的底线。
if d.splitter != nil {
if wb, ok := d.splitter.(BlockSplitter); ok {
if d.writeBlocks(wb, userContent, assistantContent) {
return true
}
// 块路失败(模型错误/超时):不要在这里 return false ——
// 那会让同一批记录无限重试。落回 Triple 路(jieba),
// 失败原因已在 writeBlocks 里记日志。
}
}
triples := d.extractTriples(userContent, assistantContent)
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
}
// BlockSplitter 是能产出块形态的拆解器(distill.Extractor 实现)。
//
// 与 RecordSplitter 并存的原因:RecordSplitter.Split 返回 Triple(对照期
// 仍被 memoryface 接口使用),块形态经 Blocks 返回。两个方法由同一个
// Extractor 实现,共用同一套闸门,不重复实现。
type BlockSplitter interface {
RecordSplitter
Blocks(ctx context.Context, record string) (*distill.BlockPayload, error)
}
// EmbedFunc 由持有 embedding provider 的一方注入;nil 表示不带向量
// (块仍入库,只是不参与向量召回——不编造零向量)。
type EmbedFunc func(text string) (vec []float64, fingerprint string)
// writeBlocks 逐条拆解并落块。全部成功返回 true;任一条失败记日志并
// 返回 false(调用方落回 Triple 路),已成功的块保留(幂等 ID 保证
// 重试不会产生重复)。
func (d *Distiller) writeBlocks(bs BlockSplitter, userContent, assistantContent string) bool {
text := userContent
if assistantContent != "" {
text += " " + assistantContent
}
// 按句切分:块的语义单位是句子,整段混合会让 contains 边失去指向。
for _, sent := range splitSentences(text) {
sent = strings.TrimSpace(sent)
if len([]rune(sent)) < 4 {
continue
}
ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout)
payload, err := bs.Blocks(ctx, sent)
cancel()
if err != nil {
log.Printf("[memory] distill blocks: %v", err)
return false
}
// 用 d.ctx 而非 context.Background():Distiller.Stop() 会 cancel 它,
// 用 Background 会让停机时正在写库的蒸馏循环继续跑。
if _, err := distill.WritePayload(d.ctx, d.db, payload, d.embed); err != nil {
log.Printf("[memory] distill write blocks: %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 文件里按内容定位并删除。
//
// 为什么按内容而不是 ID:loadExisting 读回记录时会重新分配连续 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
}
// extractTriples 按「有 splitter 用 splitter,否则回退 jieba」的顺序抽取。
//
// 必须能区分「splitter 跑了但没拆出东西」与「splitter 没跑/失败」:
// - 跑出 0 条 = 正常(这类记录本就无字段可拆,例如纯叙述句)
// - 报错 = 异常,回退 jieba 路并保留记录等下次重试
func (d *Distiller) extractTriples(userContent, assistantContent string) []memory.Triple {
if d.splitter != nil {
text := userContent
if assistantContent != "" {
text += " " + assistantContent
}
ctx, cancel := context.WithTimeout(d.ctx, distillSplitTimeout)
defer cancel()
triples, err := d.splitter.Split(ctx, text)
if err == nil {
return triples
}
// 回退前记一笔:splitter 长期失败会静默退化成 jieba 路的 0 条产出,
// 那种情况从外部看不出来(蒸馏照常「成功」,只是没写进任何东西)。
log.Printf("[memory] splitter failed, falling back to jieba path: %v", err)
}
return extractKeyTriples(userContent, assistantContent, d.embedder)
}
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,
}
}
// splitSentences 按中英文句读切分文本。
//
// 蒸馏的块语义单位是句子;这里只做粗切(。!?;\n),不做 NLP 级
// 句法分析 —— 粗切足够给块提供「同一场对话里的一个片段」边界。
func splitSentences(text string) []string {
return strings.FieldsFunc(text, func(r rune) bool {
return r == '。' || r == '!' || r == '?' || r == ';' ||
r == '\n' || r == '?' || r == '!'
})
}