Files
HomeAgent/internal/memory/distill/drift.go
JianFeeeee f256098c9f refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent

仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:

- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
  plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
  目录内的 module/app 描述同步;**证书路径原样保留**
  (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author

★ 必须保持原样的两处(改了会坏):
  · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
    —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
  · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
    —— 那是 SDK 契约里的值,属独立仓库,本仓不改

## ② 修 21 处乱码(U+FFFD)

这些是历史编辑留下的编码破坏,全在注释里,例如:
  「赌注重下���遍」→「赌注重下一遍」
  「「不��滤」」  →「「不过滤」」
  「**待��退场**」→「**待退场**」

★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
  U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。

## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)

实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:

  【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
  【三层记忆】   1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)

收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":

  工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
  三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
           关系用 memory_recall / 成文知识用 knowledge_search

判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。

## 判据

prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
  黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
  向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)

变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
2026-10-22 20:49:44 +08:00

223 lines
8.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 (
"fmt"
"regexp"
"sort"
"strings"
)
// 字段名漂移的自动发现。
//
// ★ 为什么不能靠补词表
// --------------------
// 实测(真库 188 条里 40 条,真实 qwen3:1.7b)漂移有 8 个未归一名字:
//
// 观察比例 观察时长 峰值 峰值范围 批次 批次号 批次范围
//
// 而且 drift 不总是「模型叫错了」——**同一份语义,模型在不同记录里给出
// 不同名字**:
//
// 第112批 灰度10%观察48分后放50% → 字段名 "发布窗口"
// 第113批 灰度5%观察86分后放50% → 字段名 "发布窗口"
// 第114批 灰度10%观察70分后放50% → 字段名 "观察时长"
//
// 三条同构记录、同一语义,两个名字。补词表能补到这一次,补不到下一次。
// 而且补的时候人在猜 —— 「观察时长」该并到「发布窗口」还是「停机时长」?
// 猜错就把两个真维度合并了。
//
// ★ 判据不靠字段名,靠**值的形态**
// -------------------------------
// 「字段名是不是同一个维度」正是要判定的问题,拿字段名去判是循环论证。
// 这里用的是两条独立信号:
//
// 信号1 同 subject —— 同一个实体上出现的属性
// 信号2 同值形态 —— 值走同一个模板(百分比/时长/版本/日期/区间…)
//
// 两条同时成立**只是候选**,不是结论:仍可能同形态却是不同维度
// (「停机4分」和「观察48分」都是时长,但确实是两个维度)。
// 所以 Discover 返回的是**候选报告**,由人确认后才写进 dimensionAliases。
// 自动合并会把真维度错并,代价(信息丢失)远大于收益(少写几行词表)。
// valueShape 是值的形态模板。
//
// 判据的核心:把具体值抽象成「它长什么样」。百分比和百分比同形,
// 时长和时长同形,版本和版本同形 —— 跨形态几乎必然是不同维度。
type valueShape string
const (
shapePercent valueShape = "百分比" // 10% 5%
shapeDuration valueShape = "时长" // 4分 48分 30分钟
shapeVersion valueShape = "版本" // v2.28.2 第4版
shapeRange valueShape = "区间" // 4%~17% 30~84批 56~63批
shapeNumber valueShape = "数字" // 8861 4324
shapeDate valueShape = "日期" // 10月 周四
shapeTime valueShape = "时刻" // 凌晨2点
shapeRatio valueShape = "比例词" // 均仅 / 全部
shapeText valueShape = "文本" // 兜底
)
var (
// reISODate 先匹配 ISO 日期:**年-月**(2026-10)与**年月日**(2026-10-02)。
//
// ★ 必须在 reRange 之前判,且必须覆盖年-月:
// 1. 区间正则收了连字符(13-17%),ISO 日期会被它吃掉。
// 2. 只匹配年月日会漏掉 "2026-10" —— 它同样被 reRange 命中
// (实测 reRange("2026-10") = true)。而年-月在迁移数据里很常见:
// memory_blocks.created_at 就是 "2026-10" 这种形态。
reISODate = regexp.MustCompile(`^\d{4}-\d{1,2}(-\d{1,2})?$`)
// reRange 匹配「A~B」与「A-B」两种区间形态。
//
// ★ 三条实测约束,都是踩出来的:
// 1. 两端要容许单位后缀:初版写成 \d+\s*[~~-]\s*\d+ 时
// "4%~17%" 不匹配 —— 而它才是真库里最常见的区间形态(错误率峰值)。
// 只匹配纯数字对会把最该发现的那类漂移(峰值 vs 峰值范围)漏掉。
// 2. **连字符必须收进来**:修 1 时把字符类写成 [~~](漏了 -),
// "13-17%" 就退回文本。真库里两种区间写法都有(13~17% 与 13-17%)。
// 3. 结尾锚定(^…$):不锚定会让 "10月-12月" 之类被判成区间,
// 而它属于日期。前缀也锚定,否则 "v2-3" 会被当成区间。
// 4. 结尾要容许**中文单位**尾巴:初版只放行 [%%],结果真库里高频的
// "30~84批"、"56~63批" 全部退回文本 —— 而那正是批次区间,
// 正是要靠它发现漂移的形态。
reRange = regexp.MustCompile(`^\d+(?:\.\d+)?\s*[%%]?\s*[~~-]\s*\d+(?:\.\d+)?\s*[%%]?[\p{Han}A-Za-z]*$`)
rePercent = regexp.MustCompile(`^\d+(\.\d+)?\s*[%%]$`)
reDuration = regexp.MustCompile(`^\d+(\.\d+)?\s*(分|分钟|秒|小时|min|s)$`)
// reVersion 容许点号与连字符两种分隔:真库里 "v2.28.2" 和 "v2-3" 都见过,
// 且 "v2-3" 含连字符 —— 不认的话它会掉进 reRange(区间)或文本。
reVersion = regexp.MustCompile(`^[vV]\d+(?:[.\-]\d+)*$|^第\d+版$`)
reNumber = regexp.MustCompile(`^\d+$`)
reDate = regexp.MustCompile(`^\d{1,2}月(\d{1,2}日)?$|^周[一二三四五六日]$`)
reTime = regexp.MustCompile(`(凌晨|上午|下午|晚上|中午)?\d{1,2}[点::]\d{0,2}`)
)
// classifyValue 把一个值归到形态模板。
func classifyValue(v string) valueShape {
v = strings.TrimSpace(v)
switch {
case v == "":
return shapeText
case reISODate.MatchString(v):
// 必须在区间之前:区间正则含连字符,ISO 日期会被它吃掉。
return shapeDate
case reRange.MatchString(v):
return shapeRange
case rePercent.MatchString(v):
return shapePercent
case reDuration.MatchString(v):
return shapeDuration
case reVersion.MatchString(v):
return shapeVersion
case reNumber.MatchString(v):
return shapeNumber
case reTime.MatchString(v):
return shapeTime
case reDate.MatchString(v):
return shapeDate
default:
return shapeText
}
}
// dimObservation 是一次观测:某 subject 上某字段名出现过某形态的值。
type dimObservation struct {
Subject string
Dim string
Shape valueShape
Samples []string
}
// DriftCandidate 是一组疑似同义字段名。
type DriftCandidate struct {
// Shape 是这组字段名共有的值形态(判别的依据)。
Shape valueShape
// Dims 是疑似同义的字段名(按出现次数降序)。
Dims []string
// Subjects 是共同出现过这些字段名的 subject(判为「同一实体」)。
Subjects []string
// Evidence 是支持这个候选的观测样例,供人工确认。
Evidence []string
}
// String 便于报告与日志。
func (c DriftCandidate) String() string {
return fmt.Sprintf("[%s] %s(%d 个 subject,%d 条证据)",
c.Shape, strings.Join(c.Dims, " / "), len(c.Subjects), len(c.Evidence))
}
// DiscoverDimensionDrift 从观测里找出疑似同义的字段名。
//
// 返回**候选**而不是归并结果 —— 理由见文件头:同形态不同维度很常见
// (停机4分 vs 观察48分),自动合并会丢信息。确认后由人写进 dimensionAliases。
//
// 判据(三条同时成立才列为候选):
// 1. 值形态相同(classifyValue 相同)
// 2. 共同至少一个 subject(同实体上的属性)
// 3. 字段名不同(否则不是漂移)
func containsDim(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}
func DiscoverDimensionDrift(observations []dimObservation) []DriftCandidate {
// (shape) → (subject) → dim → 样例
type shapeKey struct {
shape valueShape
subject string
}
grouped := map[shapeKey]map[string][]string{}
for _, o := range observations {
k := shapeKey{shape: o.Shape, subject: o.Subject}
if grouped[k] == nil {
grouped[k] = map[string][]string{}
}
grouped[k][o.Dim] = append(grouped[k][o.Dim], o.Samples...)
}
// shape → 候选(跨 subject 聚合)
byShape := map[valueShape]*DriftCandidate{}
for k, dims := range grouped {
if len(dims) < 2 {
continue // 只有一种叫法,不是漂移
}
cand, ok := byShape[k.shape]
if !ok {
cand = &DriftCandidate{Shape: k.shape}
byShape[k.shape] = cand
}
for dim, samples := range dims {
// ★ 去重:byShape 是跨 subject 累积的,同一 subject 里同一个
// 字段名出现多次会重复进 Dims(实测真库跑出
// 「发布窗口 / 发布窗口 / 发布窗口 …」五个重复项)。
if !containsDim(cand.Dims, dim) {
cand.Dims = append(cand.Dims, dim)
}
if len(samples) > 0 {
cand.Evidence = append(cand.Evidence,
fmt.Sprintf("%s·%s=%s", k.subject, dim, samples[0]))
}
}
if !containsDim(cand.Subjects, k.subject) {
cand.Subjects = append(cand.Subjects, k.subject)
}
}
var out []DriftCandidate
for _, cand := range byShape {
sort.Strings(cand.Dims)
sort.Strings(cand.Subjects)
sort.Strings(cand.Evidence)
out = append(out, *cand)
}
// 按证据数降序:证据多的先看
sort.Slice(out, func(i, j int) bool {
return len(out[i].Evidence) > len(out[j].Evidence)
})
return out
}