Files
HomeAgent/internal/memory/distill/blocks.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

240 lines
9.5 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 distill
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
// 拆解产物的落库形态:句子 + 文本块 + 结构边。
//
// 为什么不是 entities/relations(本会话最初的错误做法)
// --------------------------------------------------
// 三元组在图里的正确形态是【节点】【关系边】【节点】,而节点已从纯文本实体
// 升级为带 vector/fingerprint/modality 的 memory_blocks(46f833c)。把拆解
// 结果 Commit 成 entities 是把新产出灌进正在退场的旧形态。
//
// 端点 kind 的既有设计(block.go:175 validGraphNodeKind):
//
// block / entity / sentence / document
//
// 句子的职责由 sentences 表承担(它已有 id、text、created_at),
// 块的职责是**可向量化的最小子项目**。所以正确的挂接是:
//
// sentence(原句) --contains--> block(字段内容, 带向量)
//
// 与媒体块完全同构(graphmedia.go:73 的 sentence→block contains 边)。
// 不把原句也做成块 —— 那会与 sentences 表重复,且原句不需要单独向量
// (它的语义由它包含的字段块共同表达)。
// BlockPayload 是一次拆解的产物:原句 + 字段块 + 连接关系。
type BlockPayload struct {
// Sentence 是原始记录文本(写入 sentences 表,作为结构边的源端点)。
Sentence string
// Fields 是按维度拆出的字段块。
Fields []FieldBlock
}
// FieldBlock 是一个字段块:**所属主语** + 维度名 + 原样值。
//
// ★ Subject 不能省(实测踩出来的):一句多主语时
// 「admin服务端口8861·billing服务端口8499·oauth服务端口8271」
// 会拆出三条同维度不同值的字段:
//
// {admin服务, 端口, 8861} {billing服务, 端口, 8499} {oauth服务, 端口, 8271}
//
// 少了 Subject 就只剩「端口=8861」,三个服务的端口落库后无法区分 ——
// 查「billing 的端口」时块文本里根本没有 billing。
// 单主语时 Subject 是那条主语(如「值班室分机号」),它同样要进块文本,
// 否则「值班分机号=4324」查不到「值班室分机号是多少」。
type FieldBlock struct {
// Subject 是这条字段所属的主语。空串表示无主语(不该出现 ——
// Split 的闸门会挡掉无主语的字段)。
Subject string
Dimension string
Value string
}
// Blocks 把一条记录拆成块形态。
//
// 与 Split 的区别:Split 返回 Triple(旧形态,供对照期使用);
// Blocks 返回块形态(新形态)。两者共用同一套拆解逻辑与闸门,
// 不重复实现——闸门(值原样校验、维度归一、长度上限、自环过滤)
// 是拆解质量的全部保障,分两处实现必然漂移。
func (e *Extractor) Blocks(ctx context.Context, record string) (*BlockPayload, error) {
triples, err := e.Split(ctx, record)
if err != nil {
return nil, err
}
if len(triples) == 0 {
return &BlockPayload{Sentence: record}, nil
}
payload := &BlockPayload{Sentence: record}
for _, t := range triples {
payload.Fields = append(payload.Fields, FieldBlock{
Subject: t.Subject,
Dimension: t.Relation,
Value: t.Object,
})
}
return payload, nil
}
// BlockID 由句子与字段内容派生,保证同一记录重复拆解得到同一个块 ID。
//
// 为什么用内容摘要而不是随机 ID:蒸馏是**可重试**的(distillOnce 失败会
// 把记录写回队列下次重试),随机 ID 会让每次重试都产生一批新块,
// 同一句记忆在库里堆成 N 份。内容派生 ID 配合 PutMemoryBlocks 的
// ON CONFLICT 语义天然幂等。
//
// ★ subject 必须参与 ID 派生:三个服务的端口
// 「admin服务端口8861·billing服务端口8499」若不含 subject,
// 「端口=8861」与另一个主语的「端口=8861」会撞成同一个块 ——
// 那是静默的数据丢失(后者被覆盖,且不会有任何报错)。
func BlockID(sentence, subject, dimension, value string) string {
h := sha256.Sum256([]byte(sentence + "\x00" + subject + "\x00" +
dimension + "\x00" + value))
return "blk_" + hex.EncodeToString(h[:12])
}
// SentenceBlockID 由原句文本派生原句块的 ID。
//
// ★ 实现在 memory 包(memory.SentenceBlockID)——
//
// 迁移与蒸馏两条路径都要用它,而 distill 已导入 memory,
// 放在 distill 会形成导入环。这里只做转发以便包内调用方便。
func SentenceBlockID(sentence string) string { return memory.SentenceBlockID(sentence) }
// SentenceBlock 构造承载原句的块(转发到 memory.NewSentenceBlock)。
//
// 刻意不带向量:原句块是溯源锚点,长整句会稀释向量召回。
func SentenceBlock(sentence string) memory.MemoryBlock {
return memory.NewSentenceBlock(sentence, time.Time{}, time.Time{})
}
// ToMemoryBlock 把一个字段块转成可入库的 MemoryBlock。
//
// Vector/Fingerprint 由调用方填充(需要 embedding provider)。**provider
// 不可用时留空而不是编造** —— 无向量的块仍可被 BlocksForNode 按边查到,
// 只是不参与向量召回;编造一个零向量会让它参与检索并永远排在最后,
// 那是静默的错误记忆。
func (f FieldBlock) ToMemoryBlock(sentence string) memory.MemoryBlock {
// 块文本形态:<主语>|<维度>=<值>
//
// ★ 主语必须进文本(不只是进 BlockID):向量是基于 text_content 算的,
// 「billing服务 端口=8499」能被「billing 服务的端口是多少」召回,
// 而只有「端口=8499」的那个块召回不到 —— 三个服务的端口同维度时
// 光靠维度+值无法区分归属(实测形态见 FieldBlock 的注释)。
//
// 分隔符用 '|'(不在维度名/值/主语的字符集里):维度名已受控词表归一
// 不含 '|=',主语是实体名同样不含。而文本只有一个 '=' 时
// Dimension/Value 的切分才可靠,所以主语与维度之间用 '|' 隔开。
text := f.Dimension + "=" + f.Value
if f.Subject != "" {
text = f.Subject + "|" + text
}
return memory.MemoryBlock{
ID: BlockID(sentence, f.Subject, f.Dimension, f.Value),
Modality: memory.BlockText,
Text: text,
Source: "distill",
}
}
// Dimension 从块文本里取回维度名('=' 前)。取不到时返回空串。
//
// 用途:召回后要告诉模型「这条记忆是关于哪个维度的」而不只是原文串。
func Dimension(blockText string) string {
i := strings.Index(blockText, "=")
if i <= 0 {
return ""
}
// ★ 去掉主语前缀(「<主语>|<维度>=…」形态)。
// 不去掉的话 Dimension 会返回「billing服务|端口」,
// 维度名与主语混在一起 —— 而维度名是受控词表归一过的,
// 混进去之后边就建不起来了。
if j := strings.LastIndex(blockText[:i], "|"); j >= 0 {
return blockText[j+1 : i]
}
return blockText[:i]
}
// Value 从块文本里取回值('=' 后)。
func Value(blockText string) string {
i := strings.Index(blockText, "=")
if i < 0 {
return blockText
}
return blockText[i+1:]
}
// BlockSubject 从块文本里取回所属主语(首个 '|' 前)。没有则返回空串。
//
// 命名:subject.go 里 Subject 已是推导出的主语类型,这里是「从块文本
// 反解出主语」的函数,不能同名。
//
// 用途:召回后要告诉模型「这条记忆是关于谁的」——
// 一句多主语时(三个服务各自的端口)没有主语就分不清归属。
func BlockSubject(blockText string) string {
i := strings.Index(blockText, "|")
if i <= 0 {
return ""
}
return blockText[:i]
}
// WritePayload 把拆解产物写入图:句子行 + 字段块 + contains 边。
//
// embed 为 nil 时块不带向量(不编造)。返回写入的块数。
//
// 顺序与失败语义:句子必须先落(边的一端要存在,AddMemoryBlockEdge 会校验),
// 而 PutMemoryBlocks 本身是事务、AddMemoryBlockEdge 也是事务 —— 这里
// 逐块提交而非整批原子:一次拆解里某个块写失败不该让其余块回滚,
// 因为每条边都是独立事实(块 A 与块 B 之间没有依赖)。
func WritePayload(ctx context.Context, g *memory.GraphDB, payload *BlockPayload,
embed func(string) ([]float64, string)) (int, error) {
if g == nil || payload == nil || strings.TrimSpace(payload.Sentence) == "" {
return 0, fmt.Errorf("distill: empty payload")
}
if len(payload.Fields) == 0 {
return 0, nil
}
// ★ 原句写成块,不再依赖 sentences 表(方案 A 的最后依赖点)。
//
// 之前:先写 sentences 行,再 sentence --contains--> block
// 现在:SentenceBlock → 原句块 --contains--> 字段块
//
// 两者对召回完全等价(召回只看字段块),差别在退场路径:
// 前者要保留 sentences 表,后者删掉即可。
src := SentenceBlock(payload.Sentence)
if err := g.PutMemoryBlocks([]memory.MemoryBlock{src}); err != nil {
return 0, fmt.Errorf("distill: put sentence block: %w", err)
}
written := 0
for _, f := range payload.Fields {
b := f.ToMemoryBlock(payload.Sentence)
if embed != nil {
if vec, fp := embed(b.Text); len(vec) > 0 {
b.Vector = vec
b.Fingerprint = fp
}
}
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
return written, fmt.Errorf("distill: put block %s: %w", b.ID, err)
}
if err := g.AddMemoryBlockEdge("block", src.ID,
"block", b.ID, "contains"); err != nil {
return written, fmt.Errorf("distill: link block %s: %w", b.ID, err)
}
written++
}
return written, nil
}