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

397 lines
15 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 implements triple extraction from raw conversation records
// using a small generation model.
//
// 设计依据(2026-10-02 实测,60 条 order-gw 真实运维记录)
//
// 为什么不用现有的 nlp.Extractor:实测 extractKeyTriples 产出 0 条
// (defaultParser 为 nil,POS 模板抽不出),库里 188 个实体全部由模型主动调
// memory_commit 写入整句复合值。后果是跨维度检索全失效 ——
// 「第181批的值班手册是第几版」这类问题基线 0/5。
//
// 为什么必须用 JSON Schema 约束(这是小模型能用的唯一机制):
//
// format: schema 对象 6/6 记录零幻觉,30 对字段
// format: "json" 字符串 只输出单个对象就 stop(0 对)
// 零样本文本提示 聊天腔「根据您提供的信息…」(0 对)
// few-shot 续写 结构不匹配时复读示例答案(100% 幻觉)
//
// 三道不可省的闸门:
// 1. 值必须原样出现在原文(唯一的幻觉闸门 —— 生成侧没有向量可验证)
// 2. 维度名归一(不归一就建不了边:实测「停机」/「停机时间」并存)
// 3. 主语锚定(拆开后主语与值变成独立实体,答案会丢 —— 规则拆分实测 0/20)
package distill
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/JianFeeeee/HomeAgent/internal/memory"
"github.com/JianFeeeee/HomeAgent/pkg/generation"
)
// fieldsSchema 约束解码目标。实测这是 1.7b 零幻觉的唯一形态。
const fieldsSchema = `{
"type": "object",
"properties": {
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"value": {"type": "string"}
},
"required": ["name", "value"]
}
}
},
"required": ["fields"]
}`
// promptTemplate 零样本 + **三示例**(每个示例代表一种记录形态)。
//
// ★ 为什么是三个而不是一个 —— 实测(真实 qwen3:1.7b + 6 条真库记录)
//
// 提示词形态 分机号新值 分机号旧值 复合句 批次型 纯叙述
// 单示例(批次型) 0 0 0 4 ✓ 0 ✓
// 单示例(属性型) 3 ✓ 2 ✓ 0 0 0 ✓
// 无示例 0 2 0 0 0 ✓
// ★ 三示例 4 ✓ 2 ✓ 3 ✓ 4 ✓ 0 ✓
//
// 单示例不管换成哪种形态都会**压掉另一半**:模型把示例的输出形态当成范本,
// 不像示例的句子直接交白卷 `{"fields":[]}`。三示例把三种形态都摆出来,
// 才不会锚定到某一类。这是 1.7b 的能力边界,不是提示词没调好 ——
// 无示例那一行同样说明「去掉示例」并不等于「去掉锚定」。
//
// 另外两条实测约束(都是踩出来的):
//
// 1. **不能用 <record> 之外的裸文本**:无标签时实测 0 字段。
// 2. **不能写「批次号是主语,不要单独列成字段」这类"负向指令"**:
// 实测加上这条规则后,同一条记录从 4 个字段变成 0 个 —— 模型把
// 「值班手册第4版」也当成批次号相关而一并丢弃。1.7b 处理不了
// 「不要做 X」这种否定式约束,改成只给正例(示例里就没有批次号字段)。
// 主语锚定由 Split() 在 Go 侧做,不靠提示词约束模型。
const promptTemplate = `把下面的记录拆成字段,输出 JSON。
规则:
1. fields 每项是 {"name": 字段名, "value": 数值}
2. value 必须从记录里**原样照抄**,不许改写、换算、补全
3. 同一个含义在不同记录里必须用**完全一样**的字段名
4. 没有可拆的字段就输出 {"fields":[]}
记录:第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月
输出:{"fields":[{"name":"告警规则数","value":"9条"},{"name":"值班手册版本","value":"第4版"},{"name":"容量预警","value":"70%"},{"name":"排期","value":"10月"}]}
记录:值班室分机号 4324,值班 老周
输出:{"fields":[{"name":"值班分机号","value":"4324"},{"name":"值班人","value":"老周"}]}
记录:admin服务端口8861·billing服务端口8499·oauth服务端口8271
输出:{"fields":[{"name":"端口","value":"8861"},{"name":"端口","value":"8499"},{"name":"端口","value":"8271"}]}
记录:连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500
输出:{"fields":[{"name":"连接池容量","value":"32/64"},{"name":"连接池容量","value":"128/256"},{"name":"等待队列告警阈值","value":"300~500"}]}
<record>__RECORD__</record>
输出:`
type Field struct {
Name string
Value string
}
// Extractor 把一条记录拆成 (主语, 维度, 值) 三元组。
type Extractor struct {
gen generation.Provider
// subjectFrom 由记录文本推导主语(通常是批次号)。返回空则不产出三元组,
// 只把字段当作无主语的属性。
subjectFrom func(record string) string
// subjectsFrom 是多主语推导钩子。nil 时用内置规则 DeriveSubjects。
subjectsFrom func(record string) []Subject
}
// deriveSubjects 解析本条记录的主语列表。
//
// 优先级:注入的 subjectsFrom → 内置规则 DeriveSubjects → 老的 subjectFrom。
// 保留 subjectFrom 是为了兼容既有调用方(NewExtractor 的第二个参数)。
func (e *Extractor) deriveSubjects(record string) []Subject {
if e.subjectsFrom != nil {
return e.subjectsFrom(record)
}
if subs := DeriveSubjects(record); len(subs) > 0 {
return subs
}
if e.subjectFrom != nil {
if s := e.subjectFrom(record); s != "" {
return []Subject{{Name: s}}
}
}
return nil
}
// SetSubjectsFrom 注入自定义的多主语推导(供测试与特殊场景)。
func (e *Extractor) SetSubjectsFrom(f func(record string) []Subject) {
e.subjectsFrom = f
}
// NewExtractor 构造一个拆分器。subjectFrom 为 nil 时默认按「批次号」推导。
func NewExtractor(gen generation.Provider, subjectFrom func(string) string) *Extractor {
if subjectFrom == nil {
subjectFrom = defaultSubject
}
return &Extractor{gen: gen, subjectFrom: subjectFrom}
}
// defaultSubject 从记录里取批次号作主语(如「第183批」)。
//
// 为什么必须有主语:规则拆分实测 0/20 —— 拆开后「第183批」和
// 「值班手册第4版」是两个互不相关的实体,图中无边可循,答案就丢了。
// 主语是把这个绑定显式建成边的锚。
func defaultSubject(record string) string {
// 取最靠前的「第N批」或「第N/M批」形态。
//
// 按 rune 迭代:中文是 3 字节,按 byte 比较会因溢出得到错误的结论。
runes := []rune(record)
for i := 0; i < len(runes); i++ {
if runes[i] != '第' {
continue
}
j := i + 1
for j < len(runes) && (isDigit(runes[j]) || runes[j] == '~' ||
runes[j] == '/' || runes[j] == '、') {
j++
}
if j < len(runes) && runes[j] == '批' {
return string(runes[i : j+1])
}
}
return ""
}
func isDigit(r rune) bool { return r >= '0' && r <= '9' }
// Split 把一条记录拆成三元组。
//
// 返回的 triples 已过三道闸门:值原样校验、维度名归一、字段名长度限制。
// 全部字段被闸门拦下时返回空切片而非错误 —— 「这条记录没有可拆的字段」
// 是正常结果(例如「第129~143批均仅评审通过」这类纯叙述句),
// 与「模型没跑起来」是两回事,调用方必须能区分。
func (e *Extractor) Split(ctx context.Context, record string) ([]memory.Triple, error) {
if strings.TrimSpace(record) == "" {
return nil, nil
}
if e.gen == nil {
return nil, fmt.Errorf("distill: no generation provider")
}
if !e.gen.Info().SupportsJSONSchema {
// 静默忽略 schema 会让调用方拿到自由文本去 parse JSON ——
// 那种失败查不出是哪一层的错。必须显式报。
return nil, fmt.Errorf("distill: provider %q cannot honor json schema: %w",
e.gen.Info().Model, generation.ErrSchemaUnsupported)
}
resp, err := e.gen.Generate(ctx, generation.Request{
Prompt: strings.Replace(promptTemplate, "__RECORD__", record, 1),
MaxTokens: 256,
Temperature: 0,
JSONSchema: fieldsSchema,
})
if err != nil {
return nil, fmt.Errorf("distill: generate: %w", err)
}
fields, err := parseFields(resp.Text)
if err != nil {
// ★ 错误里必须带上原始响应。
//
// 实测故障(生产蒸馏 60 条 → 0 字段,837 秒):判据只显示
// 「零字段 60 条」,看不出是数据没得拆还是模型返回了垃圾。
// 而根因是 ollama schema 污染 —— 模型返回
// {"fields":[{"name":"text","value":"commit f91b27a…"}]}
// 这种「字段名=字段类型、值=整句」的垃圾,被三道闸门全拦掉。
//
// ★ **判据能显示 0,是因为它只看得到 0。**
// 观测面不够 ⇒ 只能看到失败,看不到原因。
raw := resp.Text
if len(raw) > 400 {
raw = raw[:400] + "…"
}
if resp.Truncated {
// 截断的 JSON 不可救,也不该救 —— 重试比丢弃更划算(模型是随机的)。
return nil, fmt.Errorf(
"distill: output truncated at max tokens(原始输出 %d 字): %w\n 原始: %s",
len(resp.Text), err, raw)
}
return nil, fmt.Errorf(
"distill: parse fields(原始输出 %d 字): %w\n 原始: %s",
len(resp.Text), err, raw)
}
subjects := e.deriveSubjects(record)
var triples []memory.Triple
for _, f := range fields {
name := NormalizeDimension(f.Name)
if name == "" {
continue
}
// 闸门 1:值必须原样来自原文。
if f.Value == "" || !strings.Contains(record, f.Value) {
continue
}
// 闸门 3:字段名上限。超长的多半是把整句抄了进来当字段名。
if len([]rune(name)) > 12 {
continue
}
// 无主语时写不出 subject,直接跳过 —— 宁可少记也不写悬空属性。
if len(subjects) == 0 {
continue
}
// 字段归属到哪个主语?一句多主语时(如三个服务各自的端口),
// 按值在原句里的位置落段:值落在哪个主语的锚点之后,就归那个主语。
// 落不进任何区间时归最后一个 —— 宁可归属存疑,也不要丢字段。
owner := subjects[len(subjects)-1]
if valPos := indexOfRunes([]rune(record), []rune(f.Value)); valPos >= 0 {
for _, sub := range subjects {
if valPos >= sub.Anchor {
owner = sub
}
}
}
// 自环过滤:主语已在三元组的位置,字段名等于主语则这条没有新信息。
//
// 提示词里写「批次号是主语,不要单独列成字段」会让 1.7b 把**所有**
// 字段都当成批次号相关而丢弃(实测 4 字段 → 0 字段),所以那条
// 负向指令已从提示词移除;代价就是它会把批次号也拆成字段。
// 过滤放在 Go 侧,不依赖模型听懂否定指令。
if name == owner.Name || f.Value == owner.Name {
continue
}
triples = append(triples, memory.Triple{
Subject: owner.Name,
Relation: name,
Object: f.Value,
// 记原句,便于日后从图谱回到原文。
SentenceText: record,
})
}
return triples, nil
}
func parseFields(text string) ([]Field, error) {
var envelope struct {
Fields []Field `json:"fields"`
}
if err := json.Unmarshal([]byte(text), &envelope); err != nil {
return nil, fmt.Errorf("invalid json: %w", err)
}
return envelope.Fields, nil
}
// dimensionAliases 把实测出现的同义异名归一到受控词表。
//
// 不归一的后果是建不了边:实测同一含义出现「停机」/「停机时间」两种命名,
// 跨记录查询时两种都要查,且边连不上。
//
// 加词时的判据:这个说法在真实语料里出现过,且确实是同一含义。
// 归一失败保持原名(不丢弃)—— 丢弃会丢信息。
var dimensionAliases = map[string]string{
"停机时间": "停机时长",
"停机时长": "停机时长",
"停机": "停机时长",
"回滚版本": "回滚版本",
"回滚": "回滚版本",
"灰度比例": "灰度比例",
"灰度": "灰度比例",
"发布窗口": "发布窗口",
"发布时间": "发布窗口",
"时间": "发布窗口",
"值班手册": "值班手册版本",
"值班手册版本": "值班手册版本",
"值班手册版": "值班手册版本",
"告警规则": "告警规则数",
"告警规则数": "告警规则数",
"容量预警": "容量预警",
"容量": "容量预警",
"排期": "排期",
"批次号": "批次号",
"批号": "批次号",
"端口": "端口",
"验证状态": "验证状态",
"评审状态": "评审状态",
"版本数": "版本数",
"日期": "日期",
}
// NormalizeDimension 把字段名归一到受控词表。未命中时返回裁剪后的原名。
func NormalizeDimension(name string) string {
name = strings.TrimSpace(name)
name = strings.Trim(name, "*·•-—::,, ")
if name == "" {
return ""
}
if canonical, ok := dimensionAliases[name]; ok {
return canonical
}
// 包含式匹配:模型有时输出「停机时长(分钟)」这类带后缀的写法。
//
// 挑**最长**匹配别名而不是最短:「停机时长」比「停机」长但更具体,
// 先命中它才不会退化成更泛的「停机时长」(虽然此处二者同 canonical,
// 但「排期」vs「排期月」这类一对多映射下,最长匹配才是对的规则)。
//
// ★ 但「最长匹配」本身有个反例,必须加防误伤:
//
// NormalizeDimension("连接池容量") → "容量预警" ← 错,两个不同维度
//
// 原因:「容量」是 2 字符泛词,命中了「连接池**容量**」的后半截。
// 泛词跨词边界吞掉更具体的名字,会把两个真维度静默合并 —— 而维度名
// 是建边的依据,合并后边就建错了。
//
// 判据:泛别名若只是**尾部子串**(name 的前缀是别的词),且前缀本身
// 不在词表里,就认为是误伤,保持原名不归一。宁可留一个未归一的维度名
// (还能被漂移发现机制报出来),也不要把两个维度错并成一个。
bestAlias := ""
for alias := range dimensionAliases {
if len(alias) <= len(bestAlias) {
continue
}
if !strings.Contains(name, alias) {
continue
}
if isCrossWordTailMatch(name, alias) {
continue
}
bestAlias = alias
}
if bestAlias != "" {
return dimensionAliases[bestAlias]
}
return name
}
// isCrossWordTailMatch 判断这次包含匹配是否是「跨词边界吞掉泛词」。
//
// 命中位置 > 0 且前缀不含任何词表条目 ⇒ 前缀是另一个独立词,
// 于是整个 name 是「<独立词><泛词>」结构,属于被误伤,不该归一。
//
// 例:
//
// "连接池容量" + "容量" → 前缀「连接池」不在维度词表 → 误伤
// "停机时长(分钟)" + "停机时长" → 位置 0 → 不是尾部误伤 → 正常归一
// "值班手册第8版" + "值班手册" → 位置 0 → 正常归一
func isCrossWordTailMatch(name, alias string) bool {
i := strings.Index(name, alias)
if i <= 0 {
return false
}
prefix := name[:i]
// 前缀若命中任何词表条目,说明 name 整体属于那个更长的词,不算误伤。
for other := range dimensionAliases {
if other != alias && strings.Contains(prefix, other) {
return false
}
}
// 泛词(≤3 字符)才容易误伤;更长的别名更可能是完整的维度名
return len([]rune(alias)) <= 3
}