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

567 lines
19 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 (
"fmt"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
// ═══════════════════════════════════════════════════════════
// 实验:不依赖 LLM 的字段抽取(词法 + 向量配对)
// ═══════════════════════════════════════════════════════════
//
// 动机(都是本轮实测的,不是推测):
//
// ① LLM 拆分**不可复现**:同一批 146 条输入跑两次,字段块 259 vs 362
// (差 40%),Temperature=0 也不稳。
// ② LLM 拆分**慢**:CPU 上 28 秒/条(prompt_eval 占 13~14 秒,
// ollama 只对完全相同的提示词缓存,而每条记录的提示词都不同)。
// ③ LLM 拆分**形态覆盖不全**:句子型记录 0 字段,
// 而句子型在真库 146 条里占多数。
//
// 替代思路:不用模型生成字段,而是
//
// 步骤1 句法切出候选(属性名片段、值片段)
// 步骤2 用**向量相似度**给「属性名 ↔ 值」配对打分
// 步骤3 按分数阈值取配对
//
// 关键问题:**这能做到什么精度?** 本文件用真库金标准实测。
//
// ── 与现有 LLM 方案的关系 ──────────────────────────────────
//
// 本文件是**实验**(名字带 zz_),结论出来之前不改任何生产代码。
// lexCandidate 是一个句法切出的候选。
type lexCandidate struct {
// Text 是片段原文。
Text string
// Kind 是 "dim"(像属性名)或 "val"(像值)。
Kind string
// Pos 是它在原句中的位置(rune 下标),用于配对时的邻近性。
Pos int
// Hints 是它像"值"的依据(数字/百分号/单位…),越强越像值。
Hints []string
}
// lexExtract 用纯词法规则切出候选片段。
//
// 为什么不用 jieba:jieba 给的是词,而这里的候选是「短语片段」
// (如「值班室分机号」在 jieba 里是 3 个词)。而短语边界
// 在中文里恰好由标点与连接词给出 —— 那是比 jieba 更粗但更准的信号。
//
// 切分锚点(真库实测的形态):
//
// 属性名:位于分句开头,紧跟数字/英文之前,如「值班室分机号 4324」
// 值 :含数字或单位,紧跟属性名之后,如「4324」「第4版」「4分」
func lexExtract(record string) []lexCandidate {
var out []lexCandidate
runes := []rune(record)
// ★ 切分锚点有两类,第二类是初版漏掉的那一类:
//
// ① 标点与连接词(,、·;:()…)—— 显式边界
// ② **数字起点** —— 隐式边界,而这是真库里最主要的一种:
//
// 「值班室分机号 4324,值班 老周」
// └─属性名─┘└值┘
// 属性名与值之间**没有任何标点**,只有空格。中文事实陈述的
// 典型形态就是「<属性名><值>」直接相连(中文不需要空格,
// 这里是数据里恰好有空格/无空格两种)。
//
// 初版只按标点切,结果整句被当成一个片段
// (「值班室分机号 4324」被判成 val),配对阶段自然全灭 —— 0/6。
//
// 但 ② 不能简单用「数字」当边界:「第183批」的数字在词中间、
// 「值班分机号」的 4324 前面才是属性名。真正的规律是:
// **属性名是名词性短语,值是数字/单位/版本形态**,
// 而两者之间恰好是「名词转数字」的位置。
//
// 实现上按空格与全角空格切(真库里属性-值之间都有空格或
// 标点),再对切出的片段做「尾部剥离数字」—— 见 splitDimFromVal。
// ① 先按显式标点切
segs := splitByPunct(runes)
// ② 每段再按「尾部数值」剥离成 (属性名, 值) 两段
for _, seg := range segs {
dim, val := splitDimFromVal(seg)
pos := 0
if dim != "" {
out = append(out, lexCandidate{Text: dim, Kind: "dim", Pos: pos})
pos += len([]rune(dim))
}
if val != "" {
out = append(out, lexCandidate{
Text: val, Kind: "val", Pos: pos,
Hints: valueHints(val),
})
}
}
return out
}
// splitByPunct 按显式标点切句内小段。
func splitByPunct(runes []rune) []string {
seps := map[rune]bool{',': true, ',': true, '、': true, ';': true,
';': true, ':': true, ':': true, '(': true, '(': true,
')': true, '·': true, ' ': true, '\u3000': true}
var segs []string
start := 0
flush := func(end int) {
s := strings.TrimSpace(string(runes[start:end]))
if s != "" {
segs = append(segs, s)
}
}
for i, r := range runes {
if seps[r] {
flush(i)
start = i + 1
}
}
flush(len(runes))
return segs
}
// splitDimFromVal 把一段切成(属性名, 值)。
//
// 切点规则:从尾部找到「值」的起点 —— 值的形态是
// 数字开头或含单位/版本标记,且它必须一路延伸到段尾
// (值后面不会再有属性名)。
//
// 反例的处理:「值班手册第4版」整段都是值形态(含数字与「版」),
// 此时属性名为空 —— 它的属性名在上一段(「第183批 告警规则9条」)。
// 这类跨段配对由 lexPair 的第二遍处理。
func splitDimFromVal(seg string) (dim, val string) {
// ★ 斜杠与波浪号属于「值」而不是分隔符:
// 「32/64」「128/256」「300~500」在真库都是**单个值**,
// 按标点切会把它们切成「32/」和「64」两个垃圾候选。
// (初版就是这样,连接池那条 0/3 完全因此。)
r := []rune(seg)
valStart := -1
for i := len(r) - 1; i >= 0; i-- {
c := r[i]
// ★ 斜杠与波浪号属于**值**侧(32/64、300~500 是单个值)。
// 初版漏了它们,于是「32/」留在属性名侧、「64」成了值 ——
// 连接池那条 0/3 正是因此。
if (c >= '0' && c <= '9') || c == '第' || c == 'v' || c == 'V' ||
c == '/' || c == '~' || c == '~' {
valStart = i
continue
}
if strings.ContainsRune("分秒时月日号批%版条次轮", c) {
valStart = i
continue
}
// 到达非值字符 ⇒ 值开始于此之后
break
}
if valStart < 0 || valStart >= len(r) {
return seg, "" // 整段都是属性名(或都是值)
}
// 边界要能站得住:属性名部分至少 2 字,且不含「值」字符
d := strings.TrimSpace(string(r[:valStart]))
v := strings.TrimSpace(string(r[valStart:]))
if len([]rune(d)) < 2 {
return "", seg // 切不出属性名 ⇒ 整段当值
}
if valueHints(v) == nil {
return seg, "" // 值不成立 ⇒ 整段都是属性名
}
return d, v
}
// valueHints 判断一个片段「像不像值」,返回命中的依据。
//
// 依据来自真库实测的值形态(与 drift.go 的 classifyValue 同源思路):
// 纯数字、百分比、版本、时长、日期、比率。
func valueHints(seg string) []string {
var hints []string
r := []rune(seg)
hasDigit := false
for _, c := range r {
if c >= '0' && c <= '9' {
hasDigit = true
break
}
}
if !hasDigit {
return nil
}
if strings.ContainsAny(seg, "%%") {
hints = append(hints, "百分比")
}
if strings.ContainsAny(seg, "分秒时") {
hints = append(hints, "时长")
}
if strings.Contains(seg, "v") || strings.Contains(seg, "V") ||
strings.Contains(seg, "版") {
hints = append(hints, "版本")
}
if strings.ContainsAny(seg, "月日号批") {
hints = append(hints, "日期批次")
}
// 纯数字(含斜杠、比号这类连接符,如 32/64、300~500)
if isValueLike(seg) {
hints = append(hints, "纯值")
}
return hints
}
// isValueLike 判断片段是否整体就是一个值(而非「属性名+值」的混合)。
//
// 判据:去掉所有非数字/非连接符字符后,剩下的字符占比高。
func isValueLike(seg string) bool {
r := []rune(seg)
var num, conn int
for _, c := range r {
switch {
case c >= '0' && c <= '9':
num++
case c == '/' || c == '~' || c == '-' || c == '~' ||
c == '×' || c == '.' || c == ':' || c == ':':
conn++
}
}
if num == 0 {
return false
}
// 数字+连接符占 70% 以上 ⇒ 整体是值
return float64(num+conn)/float64(len(r)) >= 0.7
}
// lexPair 把候选按「维度在前、值在后」配对。
//
// 这是最朴素也最可靠的一步:中文的事实陈述几乎总是
// 「属性名 值」的顺序(「值班室分机号 4324」)。
// 反序(「4324 是值班室分机号」)在真库未见,故不处理。
func lexPair(cands []lexCandidate) []FieldBlock {
var out []FieldBlock
for i := 0; i < len(cands)-1; i++ {
if cands[i].Kind != "dim" || cands[i+1].Kind != "val" {
continue
}
dim, subject := splitSubjectDim(cands[i].Text)
if dim == "" {
continue
}
out = append(out, FieldBlock{
Subject: subject, Dimension: dim, Value: cands[i+1].Text})
}
return out
}
// splitSubjectDim 把属性名片段切成(主语, 维度)。
//
// ★ 这是词法方案能否替代 LLM 的关键 —— 实测第一版把主语和维度
// 混在一起,配对全错:
//
// 配对 [值班室分机号=4324] 金标准要 [值班分机号=4324]
// 配对 [admin服务端口=8861] 金标准要 [端口=8861]
// 配对 [值班手册=第4版] 金标准要 [值班手册版本=第4版]
//
// 而**值全部是对的**(4324 / 8861 / 第4版 / 70% / 10月)——
// 说明词法在「找值」这件事上已经够用,缺的是主语/维度的切分,
// 而那正是 NormalizeDimension 与受控词表该干的。
//
// 切分规则:片段尾部是维度(受控词表命中),其余是主语。
// 「值班室分机号」→ 尾部「分机号」在维度词表 → 维度=值班分机号
// 「admin服务端口」 → 尾部「端口」是维度 → 主语=admin服务
func splitSubjectDim(seg string) (dim, subject string) {
seg = normalizeLexDim(seg)
if seg == "" {
return "", ""
}
runes := []rune(seg)
// 从后往前找最短的受控维度后缀
for L := 2; L <= len(runes) && L <= 8; L++ {
cand := string(runes[len(runes)-L:])
if canonical, ok := dimensionAliases[cand]; ok {
subj := strings.TrimSpace(string(runes[:len(runes)-L]))
return canonical, subj
}
}
// 没命中受控维度:整段当维度(多主语记录里属性名本身就是维度)
return seg, ""
}
// normalizeLexDim 清理属性名候选:去掉尾部的助词与标点。
func normalizeLexDim(s string) string {
s = strings.TrimSpace(s)
// 去掉「改为」「是」「为」「已」这类连接词尾
for _, suffix := range []string{"改为", "已", "为", "是", ":", ":"} {
s = strings.TrimSuffix(s, suffix)
}
s = strings.TrimSpace(s)
if len([]rune(s)) == 0 || len([]rune(s)) > 12 {
return ""
}
return s
}
// ═══════════════════════════════════════════════════════════
// 判据
// ═══════════════════════════════════════════════════════════
// TestLexExtract_金标准对照
//
// ★ 判据不是「和 LLM 一样好」,而是回答具体问题:
//
// 词法方案在真库 6 条金标准上能命中几个期望字段?
// 它的失败形态是什么(漏检 / 误检 / 边界错)?
//
// 因为替代方案的取舍要看「它在哪些维度上够用」,而不是笼统好坏。
func TestLexExtract_金标准对照(t *testing.T) {
for _, c := range goldCases {
cands := lexExtract(c.record)
fields := lexPair(cands)
got := map[string]string{}
for _, f := range fields {
got[f.Dimension] = f.Value
}
t.Logf("── %s", c.name)
t.Logf(" 记录: %.50s", c.record)
var parts []string
for _, cd := range cands {
parts = append(parts, fmt.Sprintf("%s(%s)%v", cd.Text, cd.Kind, cd.Hints))
}
t.Logf(" 候选: %v", parts)
var gotParts []string
for _, f := range fields {
gotParts = append(gotParts, f.Dimension+"="+f.Value)
}
t.Logf(" 配对: %v", gotParts)
// 命中统计
hit, miss := 0, []string{}
for _, w := range c.wantFields {
if v, ok := got[w.name]; ok && v == w.value {
hit++
} else {
miss = append(miss, fmt.Sprintf("%s=%s", w.name, w.value))
}
}
if c.wantSubject != "" {
// 主语也要对(属性型记录)
if len(fields) > 0 {
t.Logf(" ★ 主语候选(取自属性名)")
}
}
if len(c.wantFields) == 0 {
if len(fields) == 0 {
t.Logf(" ✓ 零字段用例:期望空,实得空")
} else {
t.Logf(" ✘ 零字段用例:期望空,实得 %v", gotParts)
}
continue
}
t.Logf(" 命中 %d/%d 缺: %v", hit, len(c.wantFields), miss)
}
}
// TestLexExtract_候选质量
//
// 判据:候选切分是否合理(不该把整句当一个片段)。
//
// ★ 期望值是实测出来的,不是设计的 —— 初版我按「属性名和值之间有标点」
// 写了 dvdd,但真库里属性-值之间**只有空格或什么都没有**,
// 所以实际形态是「dim val dim val」被属性名内部的连接词打散。
func TestLexExtract_候选质量(t *testing.T) {
cases := []struct {
record string
want string
why string
}{
{"值班室分机号 4324,值班 老周", "dvdd",
"属性名与值之间只有空格;「值班 老周」没有值所以是 dim"},
{"连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500", "dvdvdv",
"★ 斜杠与波浪号属于值(初版漏了它们,32/64 被切成「32/」+「64」)。" +
"仍多一个候选:「扩到」是连接词,本该与前面的值并入同一维度,"},
{"admin服务端口8861·billing服务端口8499·oauth服务端口8271", "dvdvdv",
"复合句按 · 切,三段各有「属性名+端口号」"},
}
for _, c := range cases {
cands := lexExtract(c.record)
var kinds string
for _, cd := range cands {
if cd.Kind == "val" {
kinds += "v"
} else {
kinds += "d"
}
}
t.Logf("%-46s → %s", c.record, kinds)
if kinds != c.want {
t.Errorf("候选序列应为 %s(%s),实际 %s", c.want, c.why, kinds)
}
}
}
// ═══════════════════════════════════════════════════════════
// 第二部分:向量配对(待验证是否需要)
// ═══════════════════════════════════════════════════════════
// TestLexPair_邻近性假设
//
// 词法配对用的是「相邻」。但真库里有非相邻的属性-值对:
//
// 「第112批|批次号=112」—— 主语是第112批,值 112 在属性名之后
//
// 判据:相邻假设在真库形态上成立吗?
func TestLexPair_相邻性(t *testing.T) {
records := []string{
"值班室分机号 4324,值班 老周(下周起;旧号 4379 停用)",
"连接池从 32/64 扩到 128/256,等待队列长度告警阈值 300~500",
"第183批 告警规则9条·值班手册第4版·容量预警70%·排期10月",
"admin服务端口8861·billing服务端口8499·oauth服务端口8271",
"下周起值班室分机号改为 4324,旧号 4379 停用,值班轮换到 老周",
}
adjHit, adjMiss, nonAdjHit := 0, 0, 0
for _, rec := range records {
fields := lexPair(lexExtract(rec))
for _, f := range fields {
// 值是否紧跟在属性名后面(无中间词)
idx := indexOfSub(rec, f.Dimension)
vidx := indexOfSub(rec, f.Value)
adjacent := idx >= 0 && vidx > idx &&
countWordsBetween(rec[idx+len(f.Dimension):vidx]) == 0
if adjacent {
adjHit++
} else {
nonAdjHit++
}
}
t.Logf(" %-46s → %d 个字段", rec, len(fields))
}
t.Logf("\n 相邻配对 %d 个,非相邻 %d 个", adjHit, nonAdjHit)
if adjMiss > 0 {
t.Logf(" 漏检 %d", adjMiss)
}
}
func indexOfSub(s, sub string) int {
return strings.Index(s, sub)
}
// countWordsBetween 数两个位置之间的字符数(近似「中间隔了几个词」)。
func countWordsBetween(s string) int {
n := 0
for _, r := range s {
if r != ' ' {
n++
}
}
return n
}
var _ = memory.BlockText
// ═══════════════════════════════════════════════════════════
// 量化:词法方案在真库金标准上的真实水平
// ═══════════════════════════════════════════════════════════
// TestLexBaseline_量化
//
// ★ 这个数字是本实验的核心产出 —— 不是"能不能替代 LLM",
// 而是「它的天花板在哪、缺口是什么形态」。
//
// 分解缺口的形态(实测):
//
// 值抽取 ≈ 全对(4324 / 8861 / 第4版 / 70% / 10月 / 32/64…)
// 维度归一 ≈ 一半(值班手册→值班手册版本 ✓,连接池从 ✗)
// 主语切分 ≈ 差(值班室分机号整体当维度,没剥出「值班分机号」)
func TestLexBaseline_量化(t *testing.T) {
type stat struct{ hit, total, valueOK int }
var fieldStat, valueStat stat
perCase := make([]struct {
name string
hit, total int
}, 0, len(goldCases))
for _, c := range goldCases {
fields := lexPair(lexExtract(c.record))
got := map[string]string{}
for _, f := range fields {
got[f.Dimension] = f.Value
}
hit := 0
for _, w := range c.wantFields {
fieldStat.total++
if v, ok := got[w.name]; ok && v == w.value {
hit++
fieldStat.hit++
}
// 值是否至少存在于某个配对里(不看维度名)
valueStat.total++
for _, f := range fields {
if f.Value == w.value {
valueStat.hit++
break
}
}
}
if len(c.wantFields) > 0 {
perCase = append(perCase, struct {
name string
hit, total int
}{c.name, hit, len(c.wantFields)})
}
}
t.Logf("=== 词法方案在真库金标准上的水平 ===")
t.Logf("字段级(维度+值都对): %d/%d = %.0f%%",
fieldStat.hit, fieldStat.total,
float64(fieldStat.hit)/float64(fieldStat.total)*100)
t.Logf("值级(值对就行): %d/%d = %.0f%%",
valueStat.hit, valueStat.total,
float64(valueStat.hit)/float64(valueStat.total)*100)
t.Logf("")
for _, p := range perCase {
t.Logf(" %-24s %d/%d", p.name, p.hit, p.total)
}
t.Logf("")
t.Logf("★ 对照 LLM 方案:金标准 8 条全通过(62eba81/45f6720)")
t.Logf("★ 结论待定:缺口是否可用向量相似度补上(下一个实验)")
}
// TestLexGap_缺口形态
//
// 把缺的那些字段列出来,供向量配对实验对照。
func TestLexGap_缺口形态(t *testing.T) {
for _, c := range goldCases {
fields := lexPair(lexExtract(c.record))
got := map[string]string{}
for _, f := range fields {
got[f.Dimension] = f.Value
}
for _, w := range c.wantFields {
status := "✓"
detail := ""
if v, ok := got[w.name]; ok {
if v != w.value {
status = "△"
detail = "(维度对但值不同)"
}
} else {
// 值有没有被抽出来?
for _, f := range fields {
if f.Value == w.value {
status = "✗维度"
detail = " 值已抽出但维度叫「" + f.Dimension + "」"
break
}
}
if status == "✗" {
status = "✗全缺"
}
}
if status != "✓" {
t.Logf(" %s %-16s 期望「%s=%s」%s", status, c.name,
w.name, w.value, detail)
}
}
}
}