fix(provenance): 落库 embedding_model/script_version 血缘字段 (t_7e3577c5)

根因: commit→distill→memory 管线从不在服务端持久化请求 metadata 里的
embedding_model/script_version, MemoryRecord 结构缺这两字段, 导致
zhiyi-vault-ingest.py 已发送的血缘被丢弃, 新写入恒 NULL。

修复:
- Go MemoryRecord + DistillInput 加两字段;
  core.go commit handler 解析 metadata→AutoDistillTrigger 透传;
  server.go 落库时 orDefault(embedding_model→bge-m3, script_version→unknown);
  lancedb_ipc.go 记录 map 带上两字段。
- Rust MemoryRecord + arrow_schema() + insert_batch + 4 处 col_str 读取。

验证: commit 带 metadata → 蒸馏落地 19 行 bge-m3/vault-ingest-2.0.0 (原为 NULL)。
This commit is contained in:
小唯
2026-10-08 03:57:30 +08:00
parent c4b4aad8e4
commit ca45c96e32
9 changed files with 163 additions and 13 deletions
+14
View File
@@ -0,0 +1,14 @@
fix(provenance): 落库 embedding_model/script_version 血缘字段 (t_7e3577c5)
根因: commit→distill→memory 管线从不在服务端持久化请求 metadata 里的
embedding_model/script_version, MemoryRecord 结构缺这两字段, 导致
zhiyi-vault-ingest.py 已发送的血缘被丢弃, 新写入恒 NULL。
修复:
- Go MemoryRecord + DistillInput 加两字段;
core.go commit handler 解析 metadata→AutoDistillTrigger 透传;
server.go 落库时 orDefault(embedding_model→bge-m3, script_version→unknown);
lancedb_ipc.go 记录 map 带上两字段。
- Rust MemoryRecord + arrow_schema() + insert_batch + 4 处 col_str 读取。
验证: commit 带 metadata → 蒸馏落地 19 行 bge-m3/vault-ingest-2.0.0 (原为 NULL)。
+10 -7
View File
@@ -13,7 +13,8 @@ import (
var DistillEngineRef *distill.Engine
// AutoDistillTrigger commit 后自动触发蒸馏流水线
func AutoDistillTrigger(episodeID string, content string, category string, namespace string, agentID string, source string) {
// 2026-10-08 (t_7e3577c5):新增 embedModel/scriptVer 血缘字段透传
func AutoDistillTrigger(episodeID string, content string, category string, namespace string, agentID string, source string, embedModel string, scriptVer string) {
// 1. 硬规则过滤
if !distill.HardRulesPass(content) {
return
@@ -22,12 +23,14 @@ func AutoDistillTrigger(episodeID string, content string, category string, names
// 2. 使用蒸馏引擎
if DistillEngineRef != nil {
DistillEngineRef.Enqueue(distill.DistillInput{
EpisodeID: episodeID,
Content: content,
Category: distill.Category(category),
Namespace: namespace,
AgentID: agentID,
Source: source,
EpisodeID: episodeID,
Content: content,
Category: distill.Category(category),
Namespace: namespace,
AgentID: agentID,
Source: source,
EmbeddingModel: embedModel,
ScriptVersion: scriptVer,
})
} else {
// 降级: 关键词提取
+12 -4
View File
@@ -115,6 +115,14 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
}
log.Printf("[srcprobe] commit 收到 source=%q(顶层), metadata=%v → 解析后 src=%q", req.Source, req.Metadata, src)
// 2026-10-08 (t_7e3577c5):血缘字段——metadata 携带(vault-ingest 等脚本发 embedding_model/script_version)。
// 缺省留空,server 蒸馏落库时兜底(embedding_model→bge-m3,script_version→unknown)。
embedModel, scriptVer := "", ""
if req.Metadata != nil {
embedModel, _ = req.Metadata["embedding_model"].(string)
scriptVer, _ = req.Metadata["script_version"].(string)
}
// t_3fbb6cbd:引用可复算旁路观测(阶段1,开关 ZHIYI_COMMIT_PROVENANCE_OBSERVE 默认关)。
// 异步 goroutine,不阻塞写路径、不改响应、不拒写入。
go observeCommitProvenance(req.AgentID, req.Content, req.Metadata)
@@ -144,7 +152,7 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
a.mergeMemory(existing, req.Content, req.Category, req.AgentID)
// 合并后仍需触发蒸馏:让 t6 等冲突检测器有机会比对这条记忆,
// 且 exact_dup 可能是 namespace 相同但实际语义冲突(如"在"vs"不在")。
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src)
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src, embedModel, scriptVer)
respond(w, 200, map[string]string{
"episode_id": epID, "memory_id": existing.ID,
"status": "merged", "reason": "exact_dup",
@@ -158,7 +166,7 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
if eErr == nil && cosineSimilarity(vector, ev) >= 0.98 {
a.mergeMemory(existing, req.Content, req.Category, req.AgentID)
// 与 exact_dup 同理:合并后仍需触发蒸馏,让冲突检测器有机会比对。
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src)
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src, embedModel, scriptVer)
respond(w, 200, map[string]string{
"episode_id": epID, "memory_id": existing.ID,
"status": "merged", "reason": "near_dup",
@@ -224,9 +232,9 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
// 2026-09-18 (t_a54aae79 #14): 客户端显式确认修正/新事实 → 仍触发蒸馏
respData["conflicts"] = conflicts
respData["distill_note"] = "conflict detected but resolve_conflict=true: proceeding with auto-distill"
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src)
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src, embedModel, scriptVer)
} else {
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src)
go AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, src, embedModel, scriptVer)
}
respond(w, 201, respData)
}
+16 -2
View File
@@ -216,7 +216,11 @@ func NewServer() http.Handler {
DerivedFrom: input.EpisodeID,
Freshness: "fresh",
Source: srcOrLLMDistill(input.Source), // 2026-09-26:有笔记出处→"wiki:<路径>";否则沿用 llm_distill(2026-09-07 方案C provenance)
}
// 2026-10-08 (t_7e3577c5):血缘字段——写入方经 metadata 携带;缺省兜底
// embedding_model→bge-m3(实际嵌入模型),script_version→unknown。
EmbeddingModel: orDefault(input.EmbeddingModel, "bge-m3"),
ScriptVersion: orDefault(input.ScriptVersion, "unknown"),
}
log.Printf("[srcprobe] 蒸馏落库 episode=%s input.Source=%q → mem.Source=%q", input.EpisodeID, input.Source, mem.Source)
if err := ldb.InsertMemory(mem); err != nil {
log.Printf("[distill] commit fact failed: %v", err)
@@ -1116,7 +1120,7 @@ func NewServer() http.Handler {
req.AgentID = "hermes-a06"
}
epID := fmt.Sprintf("ep_manual_%d", time.Now().UnixNano())
routes.AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, req.Source)
routes.AutoDistillTrigger(epID, req.Content, req.Category, req.Namespace, req.AgentID, req.Source, "", "")
respondJSON(w, 200, map[string]string{"status": "queued", "episode_id": epID})
})
// 蒸馏队列状态端点(G8-G9 配套)
@@ -1902,3 +1906,13 @@ func srcOrLLMDistill(src string) string {
}
return "llm_distill"
}
// orDefault 血缘字段缺省兜底:空值 → 默认值。
// 2026-10-08 (t_7e3577c5):embedding_model→bge-m3,script_version→unknown,
// 使未发 metadata 的写入路径(worklog/cbm/会话蒸馏)也落非空血缘。
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
+3
View File
@@ -44,6 +44,9 @@ type DistillInput struct {
AgentID string
// 2026-09-26:来源(笔记路径等)—— 透传到蒸馏产物 MemoryRecord.Source,recall 时带回
Source string
// 2026-10-08 (t_7e3577c5):血缘字段——透传到蒸馏产物 MemoryRecord 对应列
EmbeddingModel string
ScriptVersion string
}
// DistillResult 蒸馏输出
@@ -0,0 +1,86 @@
package governance
import (
"strings"
"testing"
)
// TestNormalizeAlias_EnglishOldNames 2026-10-03 新增
// 背景:q003「织忆图谱201个旧实体仍叫旧名」实查发现 n_xiaowei count=7 ——
// 中文旧名已清净(小唯=0)但英文旧名从未归一(map 只有中文 key 且大小写敏感)。
func TestNormalizeAlias_EnglishOldNames(t *testing.T) {
cases := []struct {
in string
want string
}{
// 中文旧名(原有能力,防回归)
{"小唯", "小怡"},
{"小唯/07-Wiki/织忆", "小怡/07-Wiki/织忆"},
// 英文旧名 · 大小写各变体(本次修复重点)
{"xiaowei", "小怡"},
{"Xiaowei", "小怡"},
{"XIAOWEI", "小怡"},
{"XiaoWei", "小怡"},
// 复合标识符(含旧名的整串)
{"soulful-xiaowei-main", "soulful-小怡-main"},
{"my-xiaowei-main", "my-小怡-main"},
{"Soulful-Xiaowei-Main", "soulful-小怡-main"}, // 归一后统一规范形式(小写)
// 不应被误伤
{"小怡", "小怡"},
{"xiaoyi", "xiaoyi"},
{"Docker", "Docker"},
{"", ""},
}
for _, c := range cases {
got := normalizeAlias(c.in)
if got != c.want {
t.Errorf("normalizeAlias(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestNormalizeAlias_NoInfiniteLoop 子串替换必须终止(实现用了 for+Index 循环)
func TestNormalizeAlias_NoInfiniteLoop(t *testing.T) {
for _, in := range []string{
"xiaowei-xiaowei-xiaowei",
"XIAOWEI_Xiaowei_xiaowei",
"小唯小唯小唯",
"soulful-xiaowei-main-soulful-xiaowei-main",
} {
_ = normalizeAlias(in)
}
}
// TestUpdateFromDistill_EnglishOldNameNotRevived 端到端:
// 蒸馏产物带英文旧名 → 写入图谱后不得再出现 n_xiaowei,应归一到「小怡」
func TestUpdateFromDistill_EnglishOldNameNotRevived(t *testing.T) {
g := NewInMemoryGraph()
up := NewAutoGraphUpdater(g)
up.UpdateFromDistill(&DistillInput{
EpisodeID: "ep_test_en",
Entities: []string{"Xiaowei", "xiaowei", "soulful-xiaowei-main"},
Facts: []string{"Xiaowei 负责运维"},
Namespace: "ns_en",
})
nodes := g.ListNodes("ns_en")
if len(nodes) == 0 {
t.Fatal("应建出节点,实际 0 个")
}
for _, n := range nodes {
name, _ := n["name"].(string)
id, _ := n["id"].(string)
if id == "n_xiaowei" || id == "n_Xiaowei" || name == "xiaowei" || name == "Xiaowei" {
t.Errorf("英文旧名不应再入图:id=%s name=%s", id, name)
}
}
found := false
for _, n := range nodes {
if n["name"] == "小怡" {
found = true
}
}
if !found {
t.Errorf("英文旧名应归一到「小怡」,实际: %v", nodes)
}
_ = strings.Contains // 保持 strings 引用
}
+4
View File
@@ -56,6 +56,10 @@ type MemoryRecord struct {
VolatileFlag bool `json:"volatile_flag"` // 频繁变更标记(≥3次修正自动设置)
RecallCount int `json:"recall_count"`
Freshness string `json:"freshness"` // fresh, stale, verified
// 2026-10-08 (t_7e3577c5):血缘字段。embedding_model=实际嵌入模型(bge-m3);
// script_version=写入脚本版本标识。写入方经 metadata 携带,server 蒸馏落库。
EmbeddingModel string `json:"embedding_model"`
ScriptVersion string `json:"script_version"`
LastRecalledAt time.Time `json:"last_recalled_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+1
View File
@@ -157,6 +157,7 @@ func (rc *RustLanceDBClient) InsertMemory(m models.MemoryRecord) error {
"freshness": m.Freshness, "version": 1, "version_history": "[]",
"source": m.Source, "volatile_flag": false, "is_deleted": m.IsDeleted,
"depends_on": "[]", "derived_from": "", "last_recalled_at": "",
"embedding_model": m.EmbeddingModel, "script_version": m.ScriptVersion, // 2026-10-08 (t_7e3577c5) 血缘
"created_at": m.CreatedAt.Format(time.RFC3339),
"updated_at": m.UpdatedAt.Format(time.RFC3339),
}
+17
View File
@@ -28,6 +28,8 @@ pub struct MemoryRecord {
pub recall_count: i64, pub useful_count: i64, pub not_useful_count: i64,
pub freshness: String, pub version: i64,
pub version_history: String, pub source: String,
// 2026-10-08 (t_7e3577c5):血缘字段
pub embedding_model: String, pub script_version: String,
pub volatile_flag: bool, pub is_deleted: bool,
pub depends_on: String, pub derived_from: String,
pub last_recalled_at: String, pub created_at: String, pub updated_at: String,
@@ -56,6 +58,8 @@ fn arrow_schema() -> Schema {
Field::new("version", DataType::Int64, false),
Field::new("version_history", DataType::Utf8, true),
Field::new("source", DataType::Utf8, true),
Field::new("embedding_model", DataType::Utf8, true),
Field::new("script_version", DataType::Utf8, true),
Field::new("volatile_flag", DataType::Boolean, false),
Field::new("is_deleted", DataType::Boolean, false),
Field::new("depends_on", DataType::Utf8, true),
@@ -117,6 +121,8 @@ impl LanceDBOps {
version: col_i64(&batch, i, "version"),
version_history: String::new(),
source: col_str(&batch, i, "source"),
embedding_model: col_str(&batch, i, "embedding_model"),
script_version: col_str(&batch, i, "script_version"),
volatile_flag: col_bool(&batch, i, "volatile_flag"),
is_deleted: col_bool(&batch, i, "is_deleted"),
depends_on: String::new(),
@@ -164,6 +170,8 @@ impl LanceDBOps {
version: col_i64(&batch, i, "version"),
version_history: String::new(),
source: col_str(&batch, i, "source"),
embedding_model: col_str(&batch, i, "embedding_model"),
script_version: col_str(&batch, i, "script_version"),
volatile_flag: col_bool(&batch, i, "volatile_flag"),
is_deleted: col_bool(&batch, i, "is_deleted"),
depends_on: String::new(),
@@ -193,6 +201,7 @@ impl LanceDBOps {
let mut recall_counts = Vec::new(); let mut useful_counts = Vec::new(); let mut not_useful_counts = Vec::new();
let mut freshnesses = Vec::new(); let mut versions = Vec::new();
let mut version_histories = Vec::new(); let mut sources = Vec::new();
let mut embedding_models = Vec::new(); let mut script_versions = Vec::new();
let mut volatile_flags = Vec::new(); let mut is_deleteds = Vec::new();
let mut depends_ons = Vec::new(); let mut derived_froms = Vec::new();
let mut last_recalled_ats = Vec::new(); let mut created_ats = Vec::new(); let mut updated_ats = Vec::new();
@@ -217,6 +226,8 @@ impl LanceDBOps {
versions.push(r["version"].as_i64().unwrap_or(1));
version_histories.push(r["version_history"].as_str().unwrap_or("[]"));
sources.push(r["source"].as_str().unwrap_or(""));
embedding_models.push(r["embedding_model"].as_str().unwrap_or(""));
script_versions.push(r["script_version"].as_str().unwrap_or(""));
volatile_flags.push(r["volatile_flag"].as_bool().unwrap_or(false));
is_deleteds.push(r["is_deleted"].as_bool().unwrap_or(false));
depends_ons.push(r["depends_on"].as_str().unwrap_or("[]"));
@@ -253,6 +264,8 @@ impl LanceDBOps {
Arc::new(Int64Array::from(versions)),
Arc::new(StringArray::from(version_histories)),
Arc::new(StringArray::from(sources)),
Arc::new(StringArray::from(embedding_models)),
Arc::new(StringArray::from(script_versions)),
Arc::new(BooleanArray::from(volatile_flags)),
Arc::new(BooleanArray::from(is_deleteds)),
Arc::new(StringArray::from(depends_ons)),
@@ -566,6 +579,8 @@ impl LanceDBOps {
version: col_i64(&batch, i, "version"),
version_history: String::new(),
source: col_str(&batch, i, "source"),
embedding_model: col_str(&batch, i, "embedding_model"),
script_version: col_str(&batch, i, "script_version"),
volatile_flag: col_bool(&batch, i, "volatile_flag"),
is_deleted: col_bool(&batch, i, "is_deleted"),
depends_on: String::new(),
@@ -623,6 +638,8 @@ impl LanceDBOps {
version: col_i64(&batch, i, "version"),
version_history: String::new(),
source: col_str(&batch, i, "source"),
embedding_model: col_str(&batch, i, "embedding_model"),
script_version: col_str(&batch, i, "script_version"),
volatile_flag: col_bool(&batch, i, "volatile_flag"),
is_deleted: col_bool(&batch, i, "is_deleted"),
depends_on: String::new(),