Author SHA1 Message Date
AskaEth c625f8342c Merge branch 'feat/memsens-sensitive-flag-on-memory-items-p' into dev
CI / Backend (build / vet / test / cross-compile) (push) Canceled after 0s
CI / Frontend (tsc + vite build) (push) Canceled after 0s
2026-10-30 09:41:15 +08:00
AskaEth 81a47720be test(loop): 钉住 FilterSharedVisible 真的看了 sensitive(review-m63 P1-1)
r1 复审的变异实验证明了一个静默回退缺口:把 memory_filter.go 那行改回
scope-only(等价于本片被 revert),`go test ./internal/runtime/loop/` 全绿,
而群聊/陌生人的候选池会重新放进主人的私事 —— P0-1 的唯一集成点没有回归覆盖。

新增 memory_filter_sensitive_test.go(两个层级):
- TestFilterSharedVisible_敏感共享行被排除:直接调真实过滤函数,断言
  sensitive=1 的 user / project 行被排除、非敏感 user/project 行存活、
  persona 行与 L4 意图记录照旧被排除(不本地复现过滤);
- TestInjectMemory_RestrictedEnv_排除敏感私事:走完整 assembleContext 注入路径
  (真实 Store + Retriever + 过滤),query 直译群里那句「她住哪」;先断言 owner
  车道读得到「花溪」(前提不空转),再断言受限车道读不到、且非敏感记忆与
  「群聊隔离」标记仍在。

变异验证(实测,已还原):把那行改回 scope-only 后
TestFilterSharedVisible_敏感共享行被排除 与 TestInjectMemory_RestrictedEnv_排除敏感私事
双双变红,而 loop 包其余用例仍绿 —— 缺口正是由本文件补上的。
2026-10-30 09:37:37 +08:00
AskaEth 44fbd29836 feat(memory): sensitive 标记 + 提取器敏感判据,私事退出受限车道召回
M63(M60 审计 P0-1/P0-2)。作用域 ≠ 敏感度:`scope='user'` 是共享域,里
面却装着住址(花溪 64 条 active)/私称(叶酱 63)/作息(早睡 38),于是受限车
道(群聊/陌生人)把共享域整锅注入上下文 —— 2026-10-29 群 531082168 的住址泄漏
即此路径。本片给 memory_items 加 `sensitive` 列,把「这条归谁」(scope)与
「这条能不能外说」(sensitive)拆成两根正交的轴。

- 迁移 0028:加列(INTEGER NOT NULL DEFAULT 0)+ **同迁移幂等回填**(精确短语
  白名单 花溪/叶酱/早睡,`AND sensitive=0`,不改 updated_at),down 只撤结构;
  memory 包内 `ensureSensitiveSchema` 同形前滚(任意 *sql.DB 自举)。
- 受限判据:新增 `MemoryItem.SharedVisible()` = 共享域 且 !sensitive,成为
  restricted 侧唯一起点;`loop.FilterSharedVisible` 与复用它的 knowledge 工具
  自动跟随。主人车道不过这道判据,照常读到全部记忆(sensitive 不是权限系统)。
- 提取器:prompt 追加 sensitive 位(住址/私人称呼/家人/健康/作息与行程 → true,
  口味/兴趣/项目约定 → false,含正反例);产出侧与落库侧共用
  `resolveSensitive`(模型的类目判断 ∨ 已实证短语白名单),只加标不撤标。
- 回填入口 `BackfillSensitive(ctx, db, dryRun)`:幂等、可 dry-run、返回候选池
  前后规模与逐短语计数(生产快照实测 3116 → 2948,标 725 行)。
- 读路径并到 15 列(scanItem + 7 处 SELECT);store.go 拆分 store_insert.go
  守住 500 行上限。

验收:go build/vet/test 全绿(memory + loop 单包复跑绿);删实现三处变异分别打红
(sensitiveColumn 恒 0 / SharedVisible 忽略 Sensitive / Extractor 跳过白名单);
生产库快照上跑 0028 后 Go 侧回填 marked=0(迁移 SQL 与 privateFacts 等价)。
2026-10-30 09:26:15 +08:00
AskaEth a8bc2b4470 Merge branch 'feat/embfix-embed-slice-dedup-and-tidy-increm' into dev
CI / Backend (build / vet / test / cross-compile) (push) Canceled after 0s
CI / Frontend (tsc + vite build) (push) Canceled after 0s
2026-10-30 09:11:14 +08:00
AskaEth 058fa141bf docs(memory): 请求上界按条数维+字符维两维算准(review-m61 P2-1)
embedding.Embed 同时受条数界(8)与字符界(480 字)约束 ⇒ 一次 embedOnce(B 条)
会发 C 个请求(C 由正文长度决定,1..B),因此:

  正常批            C
  含一条坏正文      ≤ C + B(短正文 9;均长 75 汉字的现实批 10;病理上限 2B=16)
  服务整体挂死      首个 chunk 即失败 + 逐条 B ⇒ 1 + B = 9(与字符维无关)

修正 embed_isolation.go 文件头的上界表、approxRequests 的「两者一致」表述、
embedReuseStats.Sent 的注释,以及 embed_resilience_test.go 的上界注释与日志文案。
零行为改动:判据、实现、请求数都未动,测试全绿。
2026-10-30 09:04:14 +08:00
AskaEth caa19d690b Merge branch 'feat/audience-public-presence-block-and-judge' into dev
CI / Backend (build / vet / test / cross-compile) (push) Canceled after 0s
CI / Frontend (tsc + vite build) (push) Canceled after 0s
2026-10-30 08:46:17 +08:00
AskaEth c34b9d195d feat(channelbridge): 群聊公域呈现块与判定器去人格(M62)
P0-3 受限车道(群聊/陌生人)的系统框架行并列注入【公域呈现】
(publicPresencePrompt):它是呈现层投影(AGENTS §9 行为模式=呈现),
不是人格切换也不是权限门——不称第三方「亲爱的」等主人专属称呼、
不做只对主人的亲近动作、主人的住址/姓名/作息/行程/健康/家人/关系
即使记忆里读到也不外说;主人私聊与 Web 主会话不注入。

P0-4 纵深防御:第三方边界提示词与 loop 受限前导片段各补两条硬规则
(不用主人专属亲昵称呼指代第三方 + 不外说主人私事)。

P1-2 判定器去人格:模型输入只吃「角色名 + 偏好摘要」,整份人格指令
不再进模型(审计 2026-10-30 §2.4 的 reason 亲昵口吻即由此而来)。

P1-3 测试补真:受限期呈现块存在性 + 主人车道不注入 + 判定器输入/
模型请求均不含人格正文;并纠正 memory_filter 的错误注释
(作用域 ≠ 敏感度,审计 §6 R1)。
2026-10-30 08:37:40 +08:00
AskaEth 8f7017fc0b test(memory): PG 索引的 Vectors 读口纳入 DB_PG_URL 真库往返断言(M61) 2026-10-30 08:32:09 +08:00
AskaEth 589e26c887 fix(memory): 增量向量化 + 单分片逐条隔离,召回请求数降一个数量级(M61)
M15(2026-10-13)登记的两条放大源落在记忆层,本次一并收口:

1. 整理轮/入库审查**只嵌增量**:新加 vector.Index.Vectors(SQLite/PG 实现,
   按 id 取已存向量、维度不符当未命中),Store.embedIncremental 先复用索引里的
   向量、只嵌缺的那些,并把手算出的向量回填索引 ⇒ 同一分区第二轮起零请求。
   判据写明为「向量存在性 + 维度一致」(正文在本仓不可就地改写,故无需哈希)。
   维度与当前 embedder 不一致时禁用复用并 WARN(不混两种表示)。
2. 嵌入链对单个慢分片有韧性:embedIsolated 整批失败后逐条隔离,坏的那几条
   记缺口、其余照常返回;一条都没拿到才报错降级。请求数上界 1+B(B=8 ⇒ 9),
   服务整体挂死时一次即收手,不用「请求放大」换韧性。
3. 缺口/复用/回填条数进日志(缺口走 WARN),review 的被审条目拿不到向量时
   如实返回错误 ⇒ 走既有词项余弦降级(不把「算不出来」说成「没有重复」)。

请求数(单测内假 OpenAI 兼容服务端计数,生产 API embedder + 真 SQLite 索引):
  24 条存量:修复前每轮 3 → 第一轮 3、第二轮 0
  12 条分区整理轮:第一轮 2 → 第二轮 0
  20 条候选的入库审查:26(201÷8)→ 第一条 3、第二条 0
  一条坏正文:保住 7/8 条,代价 9 个请求;整体性故障 9 个请求后收手
2026-10-30 08:30:06 +08:00
AskaEth 9d28477e3a fix(wire): inject speak VoiceSynth/VoiceStore into loop options
CI / Backend (build / vet / test / cross-compile) (push) Canceled after 0s
CI / Frontend (tsc + vite build) (push) Canceled after 0s
M57/M58 三片 scope 均不含 cmd/cyrene——组合根接线漏了,
voiceEnabled() 恒 false 导致 speak 从不被广告(她说不能发语音)。
synth 与网关同配置独立装配(emotionTTS 未启用时 nil=不广告);
store 与 channelbridge 同目录同表(引用计数单一事实来源)。
2026-10-28 17:38:06 +08:00
AskaEth 7c4424f857 Merge branch 'feat/m58-persistvoice-outbound-voice-column-w' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 22:13:33 +08:00
AskaEth 8ba4cde61d test(channelbridge): 握手回放也断言出站 voice(M58)
- dialOwnerWSHistory 交回 history 帧,replayVoiceOfID 按 id 取 voice 原文
- 成功与降级两条整链用例都补「重连 WS 回放」断言,与实时帧逐字相等
2026-10-27 22:08:38 +08:00
AskaEth 28afa11aeb feat(channelbridge): 出站语音结论落 voice 列并挂出站 Media(M58)
- voice.Ref 增 SourceTTS 与 FallbackReason,Validate/IsZero/validSource 同步
- 出站成功:INSERT -> 补写 voice 列 -> notify(首帧即带 voice)
- 出站降级:source=tts + status=failed + fallbackReason,正文原样
- 首条气泡挂出站 Media(新开条目,不复制 target.Media)
- gateway historyVoice 投影 fallbackReason
- 新增 outbound_voice.go 与整链 e2e 测试(含变异打红验证时序)
2026-10-27 22:08:38 +08:00
AskaEth 9f6243a729 Merge branch 'feat/m57-speaktool-speak-tool-with-rune-based' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 22:07:33 +08:00
AskaEth bdf1494319 docs(contract): v0.98 erratum - sixth speak error code is invalid_args not not_configured
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 22:01:50 +08:00
AskaEth 9c13d8a469 docs(contract): v0.98 outbound voice - speak tool budget, tts source, fallback marking
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- 模型侧 speak 工具(她的自主媒介决策)+ 字数预算公式 + 六错误码登记
- voice DTO: source 增 tts、fallbackReason(仅 failed 时)、四键同生共死含降级行
- 判据变更如实标注: voice 存在 && status != failed = 可播气泡; failed = 文字+降级小标
- QQ record 段真语音条(silk 待真机)与微信降级口径
2026-10-27 21:57:55 +08:00
AskaEth 27a661436c Merge branch 'feat/m59-qqrecord-outbound-voice-record-segme' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-27 20:57:40 +08:00
AskaEth eca1f33d8c feat(qq_obv11): 出站语音发真 record 段(base64 内联优先,读不到的 file 形态提前拒绝)
M59 QQRECORD:adapter.Media 里 Kind=voice 的条目落成 {"type":"record"} 段。正文仍是独立的 text 段排在 record 前面(QQ 语音是独立气泡,正文既不塞进 record 也不被吞掉)。

record 段 file 参数三档裁决(outboundRecordFile,本片核心决策):

1. Data 非空 => base64://+标准 base64。出站语音字节本就在本进程内产生(TTS),内联不依赖跨进程共享文件系统(Napcat 可能在容器/另一台机器),也不需要插件知道框架的 $DATA_DIR(插件没有那个配置面)。

2. 原样透传:已是 base64://、http(s)://、或 filepath.IsAbs 的本地绝对路径 —— 这三种 OneBot 实现自己能解析,一个字节都不改写。

3. 明确拒绝:服务端相对路径(/api/v1/media/<name>)、裸文件名、空值,提前失败并说明要什么。前缀判定必须排在 IsAbs 之前 —— Linux 上 /api/v1/media/x.wav 会被 IsAbs 判成绝对路径,透传只会得到一条看不出根因的平台错误。注:框架语音副本(voice.SaveAudio)与文档同目录、同内容寻址、同一张 documents 表,不存在「服务端相对路径 -> 本地绝对路径」的公开映射,所以拒绝该形态是正确裁决而非偷懒。

重构:outboundSegments 从 message.go 迁到 outbound_segments.go(message.go 489 行,再加代码会破 500 行上限),并收掉原实现里不可达的内层分支(len(segs)==0 时 Content 必为 "",补 text 段那条永远走不到)—— 行为不变。

分层:生产代码不 import internal/runtime/voice(渠道插件反向依赖运行时存储层会把分层倒过来),serverMediaPathPrefix 重复一份字面量,由测试交叉断言钉住同值。

测试:7 个用例(形状/顺序/透传/拒绝/零变化/前缀同值)。零变化用硬编码期望值而非快照文件。变异实测:删 MediaVoice 分支 => 5 个 voice 用例红;base64 编码换成空切片 => record file 断言红;删前缀判定 => server-relative 用例因文案不含 "server-relative" 红。均已恢复。

⚠️ 未在本机验证(无 Napcat 环境):WAV/MP3 直发是否被 Napcat 接受、silk 转码是否成功 —— 本片只钉拼装形状,需真机验证。

验收:go build ./... + go vet ./... + go test ./... 全绿(worktree wt-59)。
2026-10-27 20:44:18 +08:00
66 changed files with 4582 additions and 251 deletions

No files matched your search

+21 -9
View File
@@ -610,9 +610,9 @@
{ "name": "web_search", "status": "ok", "durationMs": 1200, { "name": "web_search", "status": "ok", "durationMs": 1200,
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" } "argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
], ],
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在 "voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写) "source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态) "status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略 "name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
"mime": "audio/ogg", "mime": "audio/ogg",
"size": 12345, "size": 12345,
@@ -649,9 +649,9 @@
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**: **`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。 - 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。 - **🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**(这条消息是一条可播放的语音)。🔴 **v0.98 变更**:出站降级行(她请求语音但 TTS 超时/失败转文字)与入站转写失败行都是 `status:"failed"` 且**不可播**——按旧判据(仅键存在)会被误渲染成语音气泡;`status=="failed"` 的行按**文字消息 + 降级小标**显示(`fallbackReason` 如 `"tts_timeout"` 可做特殊提示)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)。 - `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。 - `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。 - `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。 - `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
@@ -1583,7 +1583,19 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.97(当前契约版本) ### v0.98(当前契约版本)
**「她的出站语音」版(M57 / M58 / M59;一处判据变更如实标注,其余纯增量)**
- **模型侧新工具 `speak`**(不是客户端端点,客户端零新调用):她自主决定某条回复用**语音**发。参数 `{text, emotion?}`;执行 = 按字数算超时预算(`clamp(3000 + 25×rune, 3000, 20000)` ms,`utf8.RuneCountInString` 一字一算)→ emotionTTS(发音人按生效人格,v0.96 链不动)→ 成功落音频副本(内容寻址 0600);**超时/失败返回结构化错误**(`tts_timeout` / `tts_failed` / `tts_no_audio` / `tts_store_failed` / `text_too_long` / `invalid_args`,均带预算与实际耗时)——她能在后续输出里读到成败并自然改用文字。
- 🔴 **speak 豁免能力收缩**(quiet/awake 等状态也广告)但**不豁免安全门**:群聊/陌生人在工具策略面按「未知工具」deny(fail-closed,design/11 §3)、owner 恒放行;「群聊是否放行 speak」留待后续裁决。
- ⚠️ `OutboundVoice.DurationMs` 恒 `0`(本版不解析音频时长);客户端**不得**以 `durationMs==0` 判定失败。
- **出站 `voice` 落列与降级标记(M58)**:她请求语音且合成成功 ⇒ assistant 消息的 `voice` 列写 `{source:"tts", status:"transcribed", name, mime, size, durationMs}`(**与入站 DTO 形状一致**,`source` 新值 `"tts"` = 她说的合成语音;`url` 由网关派生,规则同入站);**她请求了但降级**(超时/失败)⇒ 写 `{source:"tts", status:"failed", fallbackReason}`(如 `"tts_timeout"`)——**`name`/`mime`/`size`/`url` 四键整体缺席**,正文照发文字。降级事实**随消息落库**(不只实时帧)——历史回看可见。
- 🔴 **判据变更(唯一 breaking,如实标注)**:🎤 判据从「`voice` 键存在」改为 **「`voice` 键存在 && `status != "failed"` = 可播语音气泡;`status == "failed"` = 文字消息 + 降级小标**(`fallbackReason` 供特殊显示,如「语音超时,已转文字」)。**入站同样适用**:入站转写失败的行本就是 `status:"failed"` 且不可播——判据统一后语义更干净。旧判据客户端会把降级行误渲染成语音气泡。
- **渠道侧(M59)**:QQ OneBot v11 以 `record` 段发**真语音条**(`base64://` 内联首选,Napcat 自行转 silk;真机 silk 效果**待验证**);微信 iLink 无语音发送能力 ⇒ 该渠道降级纯文本(但全端语音副本仍可见——IM 文本与 Web/安卓语音气泡并存)。**出站 Media 由语音事实单独构造**,绝不透传入站 Media(2026-10-04 事故纪律)。
- **全部读面自动生效**:`source` 闭集在 `voice` 包单点(`asr`/`platform`/`tts`);REST 读面 / 实时帧 / 握手回放 / `message_edited` 经同一投影点 `attachVoiceTo`,首帧即带(M44 顺序纪律沿用)。
### v0.97
**「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)** **「语音帧补全 + 上游错误出口收敛」版(M44 / M47 / M49 / M50;以纯增量为主,一处行为变更如实标注)**
@@ -2017,7 +2029,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **纯增量**: - **纯增量**:
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。 ① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。 ② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`(🔴 **v0.98 增 `tts`** = 她的出站合成语音,含 `fallbackReason` 降级标记与判据变更,见 §4.4 / §6 v0.98),`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在;**降级行四键整体缺席**);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在 && `status != "failed"`(v0.98 起)**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `history` 回放不带**(✅ **已由 v0.97(切片 M44)补齐**,见 §3.2、§3.4.4、§4.4 覆盖率)。
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。 ③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。 ④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。 ⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
@@ -2243,4 +2255,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
--- ---
*本文件由「以代码为真源」维护(当前 `v0.97`;`v0.96` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.97`(三面 —— M44 `voice` 进 WS 实时帧/握手回放 / M47 emotionTTS 自启(默认关)/ M49+M50+M48+M52 上游错误出口「构造即安全」)同步了 `internal/services/gateway/**`(`broadcast.go` / `history_voice.go` / `ws_history.go` 及 `broadcast_voice_test.go`)、`internal/services/channelbridge/**`(`persist.go` / `inbound_voice_copy.go` / `coalesce_batch.go` 及 `inbound_voice_gateway_e2e_test.go`)、`cmd/cyrene/**`(`voice_tts_launch.go` 及测试 / `main.go`)、`internal/config/runtimeconfig/voice_tts_launch.go`、`internal/runtime/voice/ttslaunch/**`、`internal/runtime/trace/**`(`upstream.go` 等)与本文件(§3.2 / §4.4 / §4.5 / §6 / §7.2 / §7.4);`v0.96` ~ `v0.82` 各版的文件级范围见各自 §6 条目;更早版本(`v0.81` ~ `v0.68`)的文件级范围按原记录保留如下:`v0.81` 同步了 backthink/flowgraph/gateway 的自排轮可见面;`v0.80` 同步了 flowgraph timeline、websearch 热重载、wire_channels 的 VoiceTranscoder 注入;`v0.79` 同步了 plugin actions、memory review、history_voice、media、CODE-TC-2;`v0.78` 同步了迁移 0020/0023、voice 包、channelbridge inbound_voice、panels、iLink 三态;`v0.77` 同步了 trace graph 分桶、loop/flow、memory tidy、admin_flow;`v0.76` 的未读面同步了 broadcast/session_read;`v0.75` ~ `v0.71` 同步了 memory tidy、proactive、flowgraph、loop 各面(详见归档)。* *本文件由「以代码为真源」维护(当前 `v0.98`;`v0.97` ~ `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.98`(她的出站语音 —— M57 `speak` 工具 + 字数预算 / M58 出站 voice 落列 + 降级标记 + 判据变更 / M59 QQ record 段)同步了 `internal/runtime/loop/**`(`speak_tool.go` / `voice.go` / `request.go` / `tools*.go` 及测试)、`internal/runtime/voice/voice.go`(`SourceTTS` / `Ref.FallbackReason`)、`internal/services/channelbridge/**`(`outbound_voice.go` 及 e2e / `bubbles.go` / `inbound*.go` / `persist*.go` / `proactive*.go`)、`internal/services/gateway/history_voice.go`、`plugins/channels/qq_obv11/outbound_segments.go` 及测试与本文件(§4.4 / §6 / 旧横幅加注);`v0.97` ~ `v0.82` 各版的文件级范围见各自 §6 条目。*
+4
View File
@@ -329,6 +329,10 @@ func (a *app) wireRuntime() error {
Tracer: tracer, Tracer: tracer,
Flow: flowPub, Flow: flowPub,
Guards: guards, Guards: guards,
// speak 的两个依赖(wire_speak.go):emotionTTS 未启用时 synth 为 nil,
// speak 不广告(voiceEnabled() 要求两者齐备)。
VoiceSynth: a.speakSynthForLoop(),
VoiceStore: a.speakStoreForLoop(docDataDir),
PersonaID: personaID, PersonaID: personaID,
PersonaName: a.personaName, PersonaName: a.personaName,
PersonaKnobs: a.personaKnobs, PersonaKnobs: a.personaKnobs,
+58
View File
@@ -0,0 +1,58 @@
package main
// speak 的组合根接线(M57/M58 三片合并后的集成缝隙修复,2026-10-27):
// `loop.VoiceSynth` / `VoiceStore` 两个依赖此前没有任何 wire 层注入 ⇒
// `voiceEnabled()` 恒 false ⇒ speak 从不被广告(症状:她说"不能发语音"、
// 查工具清单也看不到 speak)。本文件补上这一跳。
//
// 语义:speak 依赖 emotionTTS(发音人按人格解析在那条链上)——面板
// voice-tts.json 未启用时注入 nil,speak 不广告(比"广告了但永远
// not_configured 降级"诚实)。副本落盘与 channelbridge / 文档上传同源:
// 同一棵 `<DataDir>/uploads/documents` 目录、同一张 documents 表
// (引用计数与 GC 的单一事实来源不被旁路)。
import (
"context"
"path/filepath"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
const speakWireSource = "cmd/cyrene/speak"
// speakSynthForLoop 返回注入 loop 的合成能力:emotionTTS 按面板启用时是
// 一个独立装配的 voicetts.Service(与网关的实例同配置不同对象——装配期
// 一次性构造,无共享状态竞争);未启用返回 nil(speak 不广告)。
func (a *app) speakSynthForLoop() loop.VoiceSynth {
cfg, enabled := a.voiceTTSConfig()
if !enabled {
logger.WithSource(a.log, speakWireSource).Debug("speak stays unadvertised: emotionTTS not enabled",
"config", "voice-tts.json", "hint", "set {\"enabled\": true}")
return nil
}
client := voicetts.New(cfg, voicetts.WithLogger(logger.WithSource(a.log, speakWireSource)))
return voicetts.NewService(client, a.voice, voicetts.WithServiceLogger(logger.WithSource(a.log, speakWireSource)))
}
// speakStoreForLoop 返回注入 loop 的音频副本落盘能力:与 channelbridge 的
// DocumentStore 同目录同表(wire_channels.go 的口径),经 VoiceStoreFunc
// 做 documents.Ref → loop.VoiceRef 的窄转换(loop 不 import documents,
// 测试期 import cycle 的既定裁决)。
func (a *app) speakStoreForLoop(docDataDir string) loop.VoiceStore {
store := documents.NewStore(documents.StoreConfig{
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
Log: a.log,
})
return loop.VoiceStoreFunc(func(ctx context.Context, origName string, data []byte) (loop.VoiceRef, error) {
ref, err := store.SaveAudio(ctx, origName, data)
if err != nil {
return loop.VoiceRef{}, err
}
return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, nil
})
}
@@ -0,0 +1,17 @@
-- 0028 的回滚:只撤结构(删列),**不改写任何一行的其它数据**。
--
-- 回滚的代价(如实登记):`sensitive` 的标记值全部丢失 —— 所有行回到「非敏感」,
-- 于是受限车道(群聊 / 陌生人)会重新把主人的私事捞进候选池,即 2026-10-29 的
-- 泄漏面重新打开。这是**预期**的回滚代价,不是 bug:本列的值无法从其它列派生
-- (判据是内容白名单 + 提取器判断,见 up 文件),因此回滚必须显式承认它丢信息。
--
-- 回滚**不许**顺手把那些私事行改成别的形态(删行、改 scope、改 status):
-- - 删行 / 改 scope 是一次 schema 回滚**改数据**,且不可逆(内容就没了);
-- - 改 status 会把在册记忆藏起来,读侧闸看起来「干净」,但数据其实还在 ——
-- 那正是本片要消灭的「靠不可见假装安全」。
-- 要恢复保护就重跑 up(列以默认 0 重建)或执行 memory 包的 BackfillSensitive
-- (幂等、可 dry-run,见 internal/runtime/memory/sensitive_backfill.go)。
--
-- SQLite 的 DROP COLUMN 要求该列不被索引/视图/触发器引用:本列没有索引(见 up),
-- 因此无需先删索引(0026 的 down 因为带索引才以 DROP INDEX 开头)。
ALTER TABLE memory_items DROP COLUMN sensitive;
@@ -0,0 +1,68 @@
-- 0028_memory_sensitive:**主人的私事不许进受限车道**(切片 M63,M60 审计 P0-1)。
--
-- 依据:DevDocs/audit/群聊主人信息泄漏调查-2026-10-30.md §6 R1 + §7 P0-1;
-- 塔裁决 2026-10-30:私事用 sensitive 标记,**不迁 scope**(迁 persona 域会让其他角色
-- 可读,否决);「用户习惯可查」的既有裁决(design/03 §5)不动。
--
-- 为什么是**新列**而不是换作用域:作用域与敏感度是两根正交的轴。`scope` 回答
-- 「这条归谁、谁可写谁可读」,本列回答「这条能不能说给第三方听」。实证(只读实测,
-- 审计 §2):`scope='user'` 里装着住址(`花溪` 64 条 active)、私称(`叶酱` 63 条)、
-- 作息(`早睡` 38 条)—— 作用域是共享域,内容却是私事。把它们迁进 `persona` 域
-- 等于让其他角色可读(memory/rules.go 的跨角色只读),语义更差。
--
-- 🔴 **sensitive 是数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部
-- 记忆(含 sensitive=1);它只让**受限车道**(群聊 / 陌生人)的召回过滤器把这一条
-- 排除在候选池外(internal/runtime/memory/sensitive.go 的 SharedVisible →
-- internal/runtime/loop/memory_filter.go 的 FilterSharedVisible)。谁是主人仍只由
-- actor 元数据 + 白名单 + 策略引擎判定(AGENTS §9),本列不参与任何身份判定,
-- 也不改变任何一行的读/写权限。
--
-- 列值:0 = 非敏感(默认,含全部历史行)、1 = 敏感。闭集是 BOOL 语义,不是枚举。
--
-- 幂等性与形状:`ALTER TABLE ADD COLUMN` 在 SQLite 里没有 IF NOT EXISTS 形式,因此
-- 与 0019 / 0026 / 0027 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」
-- (cmd/cyrene/wire.go):迁移先落地,随后自举前滚
-- (internal/runtime/memory/sensitive_schema.go 的 ensureSensitiveSchema)走到
-- "duplicate column name" 是预期路径。两份 DDL 必须**逐字同形**
-- (INTEGER NOT NULL DEFAULT 0),由 internal/runtime/memory/sensitive_migration_test.go
-- 钉住;列一律追加在末尾,尾序因此变成 `… retention_tier, temporal, sensitive`。
--
-- 本列**不建索引**:受限车道的过滤在内存里做(候选集本来就已过 status / 时效 /
-- scope,规模在千级),而「按 sensitive 取样巡检」是人工低频查询。将来真出现按它
-- 筛的读路径,迁移与自举 DDL 两条真源一起加。
ALTER TABLE memory_items ADD COLUMN sensitive INTEGER NOT NULL DEFAULT 0;
-- 回填历史数据(本迁移唯一的一次性数据动作)。
--
-- 与 0026 / 0027「刻意不回填」的差别(这里必须说清,否则下一次会有人照着抄错):
-- 那两条不回填,是因为它们的「正确值」只能靠**再判一次**得到(0026 是留存规则、
-- 0027 是拿正文反推时间性)—— 把一次全表判定藏在迁移里,等于在启动路径上静默跑
-- 第二现场。本列的回填判据是**人工确认的精确短语白名单**(塔裁决 2026-10-30 批准):
-- 结论已定、不需要模型、结果可用一条只读查询复核,因此回填就该与建列一起落地 ——
-- 否则「列建好了、历史行还是敏感=0」这件事本身就是一个持续存在的泄漏面。
--
-- 白名单口径(判据与理由见 internal/runtime/memory/sensitive.go 的 privateFacts):
-- `花溪` 住址(审计 §2.1:全库 user 消息只在主人私聊出现过 1 次)
-- `叶酱` 主人私称(审计 §2.2)
-- `早睡` 作息(审计 §7 P0-1 的回填面数据)
-- 刻意**不**用泛类目关键词(「住」「称呼」「睡」「家人」「健康」):会把「住手」
-- 「喜欢睡懒觉的猫」这类习惯一起标掉,而受限车道少一条合法习惯是主人看不见、
-- 也不会去改回来的缺口 —— 缺口的可见性远低于泄漏,故宁窄勿宽。
--
-- 幂等:`sensitive = 0` 前置条件使重跑只补标、不撤标、不改已标行(连跑两次的第二次
-- 影响 0 行)。同口径的可重跑入口是 internal/runtime/memory 的 BackfillSensitive
-- (dry-run + 报告,白名单扩充后用它补跑;本迁移只在 0028 首次应用时跑一次)。
--
-- 覆盖**全部** scope 与 status,不限于 `user`+`active`:merged / archived 的行会被
-- 「撤销软合并」拉回可见集(store_list.go 的 Get 刻意不过 status 闸),漏标它们等于
-- 留一条可被复活的泄漏面;persona 域虽然已经被 Loop 的受限过滤排除,标上也不改变
-- 行为(只让巡检口径一致)。不依赖「先去重」(审计 P2-2):标重复行没有额外代价,
-- 漏标一条才有。
--
-- 不改 `updated_at`:本列是数据层标记,不是内容/活跃度事件;改它会扰动「最新在前」
-- 的列表顺序与衰减基准(decay 由 updated_at 参与排序)。
UPDATE memory_items SET sensitive = 1
WHERE sensitive = 0
AND (instr(content, '花溪') > 0
OR instr(content, '叶酱') > 0
OR instr(content, '早睡') > 0);
+8
View File
@@ -185,6 +185,9 @@ func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message)
// 既有角色(她自己的历史发言),也有人(带昵称的第三方人员与 // 既有角色(她自己的历史发言),也有人(带昵称的第三方人员与
// 主人本人),而模型只有被告知这两种标注的区别,才读得懂 // 主人本人),而模型只有被告知这两种标注的区别,才读得懂
// 「哪些话是主人说的、哪些话是外人说的」(design/02 §3)。 // 「哪些话是主人说的、哪些话是外人说的」(design/02 §3)。
// 受限档还带两句呈现口径(对第三方不用主人专属称呼、主人的私事
// 即使读到也不外说)—— 归属解决「谁是谁」,这两句解决「怎么称呼、
// 能说什么」,二者缺一都会在群里复现审计 2026-10-30 的病灶。
// 非受限会话逐字保持多角色支持落地时的文案。 // 非受限会话逐字保持多角色支持落地时的文案。
func (l *Loop) roleContextMessage(env speakerEnv) model.Message { func (l *Loop) roleContextMessage(env speakerEnv) model.Message {
var b strings.Builder var b strings.Builder
@@ -197,6 +200,11 @@ func (l *Loop) roleContextMessage(env speakerEnv) model.Message {
if env.restricted { if env.restricted {
b.WriteString("本会话的发言按下面的「发言归属」标注分属不同发言者:标了昵称的来自第三方人员(非管理员账户),标了主人的来自主人本人——" + b.WriteString("本会话的发言按下面的「发言归属」标注分属不同发言者:标了昵称的来自第三方人员(非管理员账户),标了主人的来自主人本人——" +
"不要把第三方的话当成主人说的,也不要把别人的话当成你自己说过的、不要继承他们的身份或口吻;" + "不要把第三方的话当成主人说的,也不要把别人的话当成你自己说过的、不要继承他们的身份或口吻;" +
// 以下两句是审计 2026-10-30 的 P0-4(与 channelbridge 的第三方边界 /
// 公域呈现块同一口径的纵深防御):归属只解决「谁是谁」,不解决「怎么
// 称呼」与「能说什么私事」——群员被叫「亲爱的」、主人的住址被复述进群,
// 走的正是这个缺口。
"对第三方人员不要用只属于主人的亲昵称呼(如「亲爱的」),也不要把主人的住址、姓名、作息、行程、健康、家人、关系等私事告诉对方——即使这些信息出现在记忆里;" +
"该标注从其出现处起生效,直到下一条「发言归属」为止。") "该标注从其出现处起生效,直到下一条「发言归属」为止。")
return model.TextMessage(model.RoleSystem, b.String()) return model.TextMessage(model.RoleSystem, b.String())
} }
@@ -0,0 +1,45 @@
package loop
import (
"strings"
"testing"
)
// 本文件钉住受限(群聊/陌生人)前导片段里的**呈现口径**(审计 2026-10-30
// 的 P0-4,与 channelbridge 的第三方边界 / 公域呈现块同一口径):
//
// 归属只解决「谁是谁」,不解决「怎么称呼、能说什么私事」。群员被叫
// 「亲爱的」、主人的住址被复述进群,走的正是后一个缺口。因此受限档的
// 角色声明必须同时带两句:不用主人专属的亲昵称呼指代第三方;主人的私事
// 即使出现在记忆里也不外说。
//
// 非受限档(多角色 Web 会话)**不带**这两句:那里没有第三方,多角色之间
// 的呈现不需要降档。
func TestAttributionRestrictedPreambleCarriesPrivacyRules(t *testing.T) {
t.Parallel()
l := groupLoop(t, nil)
restricted := messageText(l.roleContextMessage(speakerEnv{restricted: true, current: "xilian"}))
for _, want := range []string{
"亲昵称呼", "亲爱的",
"住址", "姓名", "作息", "行程", "健康", "家人", "关系",
"记忆",
} {
if !strings.Contains(restricted, want) {
t.Fatalf("受限前导片段 = %q, want the presentation rule keyword %q", restricted, want)
}
}
// 归属口径本身不许被这两句挤掉(裁决 06.4.1)。
for _, want := range []string{"发言归属", "第三方人员", "主人本人"} {
if !strings.Contains(restricted, want) {
t.Fatalf("受限前导片段 = %q, want the attribution rule to survive (%q)", restricted, want)
}
}
multi := messageText(l.roleContextMessage(speakerEnv{restricted: false, current: "xilian"}))
if strings.Contains(multi, "住址") || strings.Contains(multi, "亲昵称呼") {
t.Fatalf("非受限前导片段 = %q, must not carry the restricted presentation rules", multi)
}
}
+11 -15
View File
@@ -7,21 +7,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
) )
// FilterSharedVisible 返回群聊/陌生人(受限环境)可见的记忆子集:只保留 // FilterSharedVisible 返回群聊/陌生人(受限环境)可见的记忆子集:按**作用域**
// 非敏感的共享域(用户习惯 ScopeUser 与项目 ScopeProject,人人可读), // 只保留共享域(用户习惯 ScopeUser 与项目 ScopeProject,人人可读),剔除主人
// 并剔除 L4 意图记录(capacity.RecordIntent 的回写格式,内容是主人逐句 // 私有的角色日常(ScopePersona);再按**敏感度**剔除敏感标记为真的条目
// 原话的机器可读形态 —— 虽落在共享域以便 L4 闭环学习,但只该暴露 // (MemoryItem.SharedVisible,迁移 0028);并剔除 L4 意图记录
// 「不敏感的习惯结论」,不该暴露主人原话)。角色日常(ScopePersona) // (capacity.RecordIntent 的回写格式,内容是主人逐句原话的机器可读形态 —— 虽落在
// 是主人私有的敏感项,不在可见域内(design/03 §3/§5「不泄露主人敏感信息」)。 // 共享域以便 L4 闭环学习,但只该暴露「不敏感的习惯结论」,不该暴露主人原话)。
// //
// 它是**唯一**的隔离判据:循环的记忆注入(retrieveMemoryBlock)与模型 // 它是**唯一**的隔离判据:循环的记忆注入(retrieveMemoryBlock)与模型
// 可达的 knowledge 检索工具都调用它。knowledge 若自造一套过滤(或不过滤), // 可达的 knowledge 检索工具都调用它。knowledge 若自造一套过滤(或不过滤),
// 一旦管理面板把它放行给群聊/陌生人,就会返回角色日常与主人原话 // 一旦管理面板把它放行给群聊/陌生人,就会返回角色日常与主人原话
// (审计 2026-10-05:knowledge 绕过群聊/陌生人召回过滤,P0 红线)。 // (审计 2026-10-05:knowledge 绕过群聊/陌生人召回过滤,P0 红线)。
//
// 🔴 **它只决定「受限车道看不看得见」**:主人车道(`!env.Restricted()`)根本不过
// 这道过滤,照常读到全部记忆(含 sensitive=1)。sensitive 是数据层标记,不是
// 权限系统(切片 M63、审计 2026-10-30 §7 P0-1)。
func FilterSharedVisible(results []memory.Result) []memory.Result { func FilterSharedVisible(results []memory.Result) []memory.Result {
visible := make([]memory.Result, 0, len(results)) visible := make([]memory.Result, 0, len(results))
for _, r := range results { for _, r := range results {
if !sharedScope(r.Item.Scope) { if !r.Item.SharedVisible() {
continue continue
} }
if capacity.IsIntentRecord(r.Item.Content) { if capacity.IsIntentRecord(r.Item.Content) {
@@ -32,14 +36,6 @@ func FilterSharedVisible(results []memory.Result) []memory.Result {
return visible return visible
} }
// sharedScope 报告 scope 是否为群聊/陌生人可查的非敏感共享域:
// 用户习惯(user,全局共享)与项目记忆(project,人人可读)。
// 角色日常(persona)是主人私有的敏感项,不在其列
// (design/03 §3/§5 的敏感度划分即作用域本身)。
func sharedScope(s memory.Scope) bool {
return s == memory.ScopeUser || s == memory.ScopeProject
}
// formatMemoryBlock 由检索结果构建前导系统块。非空 // formatMemoryBlock 由检索结果构建前导系统块。非空
// marker 作为该块的前缀,使隔离的群聊读取可与 // marker 作为该块的前缀,使隔离的群聊读取可与
// 主人的完整读取区分开。 // 主人的完整读取区分开。
@@ -0,0 +1,134 @@
package loop
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住 **P0-1 的唯一集成点**:`FilterSharedVisible` 真的看了 `sensitive`
// (切片 M63、审计 2026-10-30 §7 P0-1;r1 复审 P1-1)。
//
// 为什么必须单独有这一条:memors 包的用例钉的是 `MemoryItem.SharedVisible()` 这个
// **判据**,它管不到 loop 有没有调用它。r1 复审的变异实验证明了缺口的具体形态 ——
// 把 `memory_filter.go` 里那行改回 `sharedScope(r.Item.Scope)`(等价于本片被
// revert),`go test ./internal/runtime/loop/` **全绿**,而群聊/陌生人的候选池会
// 重新放进主人的私事。因此这里的用例必须走**真实函数/真实注入路径**,
// 不许在本地复现过滤(memory 包那份 restrictedVisible 只作数据层判据用)。
//
// 两条用例分别守两层:
// 1. TestFilterSharedVisible_敏感共享行被排除 —— 直接调真实 FilterSharedVisible;
// 2. TestInjectMemory_RestrictedEnv_排除敏感私事 —— 走 assembleContext 的完整注入
// 路径(真实 Store + 真实 Retriever + 真实过滤),并把「群里问『她住哪』」写成
// query,同时断言 owner 车道照常读得到(本片红线:sensitive 不是权限系统)。
// resultIDs 返回命中集的 id 列表(顺序保留),供集合断言用。
func resultIDs(results []memory.Result) []string {
ids := make([]string, 0, len(results))
for _, r := range results {
ids = append(ids, r.Item.ID)
}
return ids
}
// TestFilterSharedVisible_敏感共享行被排除 直接调真实过滤函数,钉住三条判据:
// 作用域(persona 不在共享域)、敏感度(sensitive=1 一律排除)、意图记录(L4 原话)。
func TestFilterSharedVisible_敏感共享行被排除(t *testing.T) {
t.Parallel()
results := []memory.Result{
{Item: memory.MemoryItem{ID: "private-user", Scope: memory.ScopeUser, Sensitive: true,
Content: "用户居住在花溪(贵阳花溪区),不在上海工作"}},
{Item: memory.MemoryItem{ID: "habit-user", Scope: memory.ScopeUser,
Content: "用户喜欢冰美式"}},
{Item: memory.MemoryItem{ID: "plain-project", Scope: memory.ScopeProject,
Content: "项目记忆:Cyrene 用 Go 编写"}},
{Item: memory.MemoryItem{ID: "private-project", Scope: memory.ScopeProject, Sensitive: true,
Content: "称呼约定:主人只叫「叶酱」"}},
{Item: memory.MemoryItem{ID: "persona-daily", Scope: memory.ScopePersona,
Content: "角色日常:周末喜欢爬山"}},
{Item: memory.MemoryItem{ID: "intent-record", Scope: memory.ScopeUser,
Content: "intent:isTask=true;taskKind=query;confidence=0.9;text=帮我看看这个"}},
}
visible := FilterSharedVisible(results)
got := resultIDs(visible)
want := []string{"habit-user", "plain-project"}
if len(got) != len(want) {
t.Fatalf("FilterSharedVisible 保留 %v, want %v(敏感/角色日常/意图记录都必须被排除)", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("FilterSharedVisible 保留 %v, want %v", got, want)
}
}
}
// seedSensitiveMemories 写入一条主人私事(会经 memory 包的白名单兜底标成
// sensitive=1)、一条用户习惯、一条项目记忆。
func seedSensitiveMemories(t *testing.T, store *memory.Store) {
t.Helper()
ctx := context.Background()
seeds := []memory.MemoryItem{
{PersonaID: "persona-a", Content: "用户居住在花溪(贵阳花溪区),不在上海工作",
Scope: memory.ScopeUser, Kind: memory.KindWorldEvent},
{PersonaID: "persona-a", Content: "用户习惯:喜欢冰美式",
Scope: memory.ScopeUser, Kind: memory.KindRelationship},
{PersonaID: "persona-a", Content: "项目记忆:Cyrene 用 Go 编写",
Scope: memory.ScopeProject, Kind: memory.KindWorldEvent},
}
for _, it := range seeds {
stored, err := store.Add(ctx, it)
if err != nil {
t.Fatalf("seed memory: %v", err)
}
if it.Content == seeds[0].Content && !stored.Sensitive {
t.Fatalf("私事种子没有被标成 sensitive —— 用例前提不成立(白名单兜底没生效)")
}
}
}
// TestInjectMemory_RestrictedEnv_排除敏感私事 是集成级对抗用例:query 直译群里那句
// 「她住哪」,走完整的 assembleContext 注入路径。
//
// 断言顺序是刻意的:先证明 owner 车道**读得到**花溪(否则「受限车道读不到」可能只是
// 相关度不够,用例空转),再断言受限车道读不到。
//
// 这条用例正是 r1 复审变异 A 的杀手:把那行改回 sharedScope(r.Item.Scope) 之后,
// 受限块会重新出现「花溪」,本用例变红。
func TestInjectMemory_RestrictedEnv_排除敏感私事(t *testing.T) {
t.Parallel()
store := newTestMemoryStore(t)
seedSensitiveMemories(t, store)
l := New(Options{Retriever: memory.NewRetriever(store), PersonaID: "persona-a"})
msgs := []model.Message{model.TextMessage(model.RoleUser, "她住在哪里?是上海还是花溪?")}
ownerView := l.assembleContext(WithSessionEnv(context.Background(), OwnerEnv()), msgs, 1)
if len(ownerView) < 1 {
t.Fatal("owner 车道没有注入记忆块,用例前提不成立")
}
ownerBlock := messageText(ownerView[0])
if !strings.Contains(ownerBlock, "花溪") {
t.Fatalf("owner 车道读不到这条私事(前提不成立,后续断言会空转):\n%s", ownerBlock)
}
restrictedCtx := WithSessionEnv(context.Background(),
SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false})
restrictedView := l.assembleContext(restrictedCtx, msgs, 1)
if len(restrictedView) < 1 {
t.Fatal("受限车道应当仍注入非敏感记忆块")
}
restrictedBlock := messageText(restrictedView[0])
if strings.Contains(restrictedBlock, "花溪") {
t.Fatalf("受限车道拿到了主人的住址 —— FilterSharedVisible 没有看 sensitive:\n%s", restrictedBlock)
}
if !strings.Contains(restrictedBlock, "群聊隔离") ||
!strings.Contains(restrictedBlock, "喜欢冰美式") ||
!strings.Contains(restrictedBlock, "Cyrene 用 Go 编写") {
t.Fatalf("受限车道丢了非敏感记忆或隔离标记(用户习惯可查的裁决被推翻):\n%s", restrictedBlock)
}
}
@@ -0,0 +1,163 @@
package memory
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 本文件是「增量向量化 + 单分片韧性」的**请求计数**测试(M61,2026-10-30)。
//
// 判据全部落在**真实的生产路径**上:假服务端是一个 OpenAI 兼容的
// POST /v1/embeddings(应答 {data:[{index,embedding}]}),被测对象是生产用的
// embedding.NewAPI + vector.New(SQLite) + Store.embedIncremental / Tidier /
// PreInsertReviewer —— 不是替身。因此这里数出来的请求数就是线上会发出去的请求数。
//
// 断言的三件事:
// 1. **增量**:索引里已有向量的行不再重嵌(第二轮整理轮 = 0 个请求);
// 2. **不放大**:同输入 N 条只发 ⌈N/批上限⌉ 个请求(批上限 = 8);
// 3. **韧性**:一条坏/慢分片只损失它自己(缺口),其余照常;整体性故障最多
// 3 个请求就收手(不把放大换韧性)。
const fakeEmbedDim = 8
// fakeDatum / fakeResponse 是 /embeddings 的最小应答形态。
type fakeDatum struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
}
type fakeResponse struct {
Data []fakeDatum `json:"data"`
}
// fakeEmbedServer 是**计数的** OpenAI 兼容假服务端。failIf 命中任一输入文本时
// 该请求回 400(模拟「一条坏正文」),failAll 时全部回 503(模拟服务挂死)。
type fakeEmbedServer struct {
mu sync.Mutex
requests int
batches []int
failIf string
failAll bool
dim int
srv *httptest.Server
}
// newFakeEmbedServer 起一个假服务端,并在 t.Cleanup 里关掉它。
func newFakeEmbedServer(t *testing.T) *fakeEmbedServer {
t.Helper()
f := &fakeEmbedServer{dim: fakeEmbedDim}
f.srv = httptest.NewServer(http.HandlerFunc(f.handle))
t.Cleanup(f.srv.Close)
return f
}
// apiURL 返回 OpenAI 兼容的完整端点(与 .env 里 EMBEDDING_API_URL 的口径一致)。
func (f *fakeEmbedServer) apiURL() string { return f.srv.URL + "/v1/embeddings" }
// embedder 返回**生产实现**(embedding.NewAPI),只把端点指到假服务端。
func (f *fakeEmbedServer) embedder() *embedding.APIEmbedder {
return embedding.NewAPI(config.EmbeddingConfig{APIURL: f.apiURL(), Model: "fake-embed"})
}
// count 返回到目前为止收到的请求数。
func (f *fakeEmbedServer) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.requests
}
// handle 记一笔请求并按 failIf / failAll 决定应答。
func (f *fakeEmbedServer) handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
var req struct {
Input []string `json:"input"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
return
}
f.mu.Lock()
f.requests++
f.batches = append(f.batches, len(req.Input))
failAll, failIf := f.failAll, f.failIf
f.mu.Unlock()
if failAll {
http.Error(w, `{"error":"unavailable"}`, http.StatusServiceUnavailable)
return
}
if failIf != "" {
for _, text := range req.Input {
if strings.Contains(text, failIf) {
http.Error(w, `{"error":"bad input"}`, http.StatusBadRequest)
return
}
}
}
resp := fakeResponse{Data: make([]fakeDatum, len(req.Input))}
for i, text := range req.Input {
resp.Data[i] = fakeDatum{Index: i, Embedding: fakeVector(text, f.dim)}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, `{"error":"encode"}`, http.StatusInternalServerError)
}
}
// fakeVector 由正文算一个确定性向量:同一段正文永远得到同一个向量(embedder
// 的确定性是复用索引向量的前提),不同正文仍在同一个扇区(余弦 > 召回门)。
func fakeVector(text string, dim int) []float32 {
var h uint32
for _, r := range text {
h = h*31 + uint32(r)
}
v := make([]float32, dim)
for i := range v {
v[i] = 1
}
v[0] = float32(1 + h%5)
return v
}
// fakeIndex 用**生产构造路径** vector.New 建一个真实 SQLite 索引(隔离内存库),
// embedder 是假服务端背后的生产 API embedder。构造时会探测一次维度 ⇒ 1 个请求。
func fakeIndex(t *testing.T, emb embedding.Embedder) vector.Index {
t.Helper()
idx, err := vector.New(config.DBConfig{SQLitePath: ":memory:"}, emb, testLogger())
if err != nil {
t.Fatalf("vector.New: %v", err)
}
t.Cleanup(func() { _ = idx.Close() })
return idx
}
// reviewItems 造 n 条同分区条目并**在接入索引之前**落库(模拟历史存量:写入侧
// 那时还没接索引,所以它们没有向量 —— 这正是整理轮每轮重嵌的那批)。
func reviewItems(t *testing.T, store *Store, n int) []MemoryItem {
t.Helper()
base := tidyTestTime(t)
items := make([]MemoryItem, 0, n)
for i := 0; i < n; i++ {
items = append(items, seedTidyItem(t, store, MemoryItem{
PersonaID: "persona.a",
Scope: ScopePersona,
Kind: KindRelationship,
Content: fmt.Sprintf("存量记忆正文 第%d条", i),
Confidence: 0.9,
CreatedAt: base.Add(time.Duration(i) * time.Minute),
SourceConversation: FormatSourceConversation("web", "m-1"),
}))
}
return items
}
@@ -0,0 +1,285 @@
package memory
import (
"context"
"errors"
"log/slog"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
)
// 本文件是「增量向量化」:召回链只嵌**索引里还没有**的行。
//
// ## 背景(M15,2026-10-13 登记的放大源)
//
// 整理轮每轮把整个分区重新向量化(user 分区一轮 ≈ 250 个请求)、入库审查每条把
// 200 条既有候选重新嵌一遍(26 个请求)—— 而 `vec_items` 里本来就有这些行的向量
// (写入侧 store_vector.go 每条 Add 都写一条)。同一段正文在下一轮从零再算一遍是
// 纯浪费,也是 M15 记的「请求放大」在**记忆层**的那一半(另一半是 embedding 包的
// 切片形状,已由 M15 把 4 条/片改成 8 条/片)。
//
// ## 增量判据:向量存在性 + 维度一致(不用内容哈希)
//
// 复用条件是「`vec_items` 里该 item.ID 的向量存在,且长度等于索引维度」。不用
// 内容哈希,理由是可证的一条:**记忆正文在本仓不存在就地改写** —— `Store.Add` 是
// 唯一 INSERT 路径、merge 是软删除(`status`+`merged_into`)、reclassify 只改分类列、
// 结构性改写一律「新行 + 旧行置为被合并」(design/23 §15 D3-6)。正文只增不改 ⇒
// 「有向量」等价于「那个向量就是这段正文的向量」,哈希比对不提供额外信息。
//
// ⚠️ 已知边界(既存缺口,不在本切片内):`vec_items` 不带**模型标签**。若换了
// embedding 模型而维度恰好相同,复用会把两种表示混进同一轮的余弦里。可用的判据
// 只有维度(本层用的就是它)——要根治得给向量表加一列 model,属迁移级改动。
//
// ## 算完回填索引(否则「增量」只会增到同样的那几条)
//
// 只复用不填,第二轮还是复用到同样那几条:索引的覆盖面本来只有「索引接入之后
// 写入的条目 + 一次性 ≤512 条回填」(retrieve_index.go),一个 2500 条的分区因此
// 长期大部分没有向量。所以本层在算完之后把向量 upsert 回索引(与
// store_vector.indexItem 同一件事、同一条 id),使**第二轮起**同一分区的请求数
// 趋近于 0。回填失败只计数 + 一条 WARN,绝不让召回失败(召回已经在手上的向量上
// 跑完了)。
// ErrNilEmbedder 在「要增量向量化却没有 embedder」时返回。生产调用点都先判了
// nil(未配置语义 embedding 就走词项余弦),因此它只防内部装配错误。
var ErrNilEmbedder = errors.New("memory: nil embedder")
// embedReuseStats 是一轮召回**实际向远端发了什么**的计数。它进日志而不是进
// 候选/留痕表:请求数是运维观测,不改变任何判定结果。
type embedReuseStats struct {
// Requested 是参与召回的条目数(本轮需要向量的条目)。
Requested int
// Reused 是索引里已有向量、一次远端都没发的条目数。
Reused int
// Sent 是真正送进 embedder 的**条数**;HTTP 请求数 ≥ ⌈Sent/批上限⌉ ——
// embedding 包还会按字符界(480 字)再切,长正文时请求数更多(见 approxRequests)。
Sent int
// Dropped 是逐条隔离重试后仍失败、本轮没有向量的条目数(缺口)。
Dropped int
// Filled 是本轮回填进索引的向量数。
Filled int
// FillFailed 是回填失败的条数(不影响召回)。
FillFailed int
}
// approxRequests 估算本轮发出的 HTTP 请求数:条数 ÷ 批上限(向上取整)。
//
// 🔴 它是**按条数界算的下限估算**,不是精确值:embedding.Embed 还按字符界
// (DefaultBatchChars=480)切片,长正文批会被多切一刀 ⇒ 真实请求数 ≥ 本估算
// (短正文、≤480 字/批时两者相等)。它只进日志做观测,不是任何判据。
func (s embedReuseStats) approxRequests(batch int) int {
if s.Sent <= 0 {
return 0
}
if batch <= 0 {
batch = tidyEmbedBatch
}
return (s.Sent + batch - 1) / batch
}
// logEmbedReuse 如实记一笔本轮「复用 / 真发请求 / 缺口」。缺口必须看得见
// (绝不静默),正常的复用只记 Debug —— 它每轮每分区都会发生,不该刷屏。
func logEmbedReuse(log *slog.Logger, caller string, stats embedReuseStats, batch int) {
if log == nil || stats.Requested == 0 {
return
}
attrs := []any{
"caller", caller,
"items", stats.Requested,
"reused", stats.Reused,
"sent", stats.Sent,
"requests", stats.approxRequests(batch),
"dropped", stats.Dropped,
"filled", stats.Filled,
}
if stats.Dropped > 0 {
log.Warn("memory: semantic recall ran with embedding gaps", attrs...)
return
}
log.Debug("memory: semantic recall reused indexed vectors", attrs...)
}
// embedIncremental 返回与 items 对齐的向量(拿不到的位置为 nil):索引里已有的
// 直接复用,只有缺的才送进 embedder。batch 是单次 Embed 的条数上限(生产传
// tidyEmbedBatch,它 = embedding.DefaultBatchLimit)。
//
// 失败语义(design/23 §15.3 已登记的韧性口径:**一片失败不丢整条**):
// - 单批内部失败由 embedIsolated **逐条隔离**,仍失败的那几条留下**缺口**,其余
// 条目照常带向量返回;
// - 整批一条都没拿到(整体性故障)时停止后续批,已拿到的向量照常返回;
// - 只有「一条向量都没拿到」才返回错误 —— 调用方据此走既有的词项余弦降级。
func (s *Store) embedIncremental(ctx context.Context, emb embedding.Embedder, items []MemoryItem, batch int) ([]embedding.Vector, embedReuseStats, error) {
stats := embedReuseStats{Requested: len(items)}
vecs := make([]embedding.Vector, len(items))
if len(items) == 0 {
return vecs, stats, nil
}
if emb == nil {
return nil, stats, ErrNilEmbedder
}
idx := s.vectorIndex()
if idx != nil && emb.Dim() > 0 && emb.Dim() != idx.Dim() {
// 🔴 维度不一致说明索引与当前 embedder 不是同一套表示(换过模型 / 换过维度)。
// 此时**既不复用也不回填**:复用会把两种维度的向量混进同一轮余弦(长度不符
// ⇒ 相似度恒 0,静默降质),回填则必然逐条 ErrDimMismatch。退回「全量嵌入」,
// 并如实留一条 WARN,让「索引该重建了」这件事看得见。
s.log.Warn("memory: embedder dim differs from vector index dim, incremental reuse disabled",
"embedder_dim", emb.Dim(), "index_dim", idx.Dim())
idx = nil
}
if idx != nil {
reused, err := s.reuseIndexedVectors(ctx, idx, items, vecs)
if err != nil {
// 索引读失败**不**降级语义召回:退回「全量嵌入」(改动前的行为),代价是
// 这一轮又放大成 ⌈N/批⌉ 个请求 —— 如实记 WARN,让请求数异常有据可查。
s.log.Warn("memory: read indexed vectors failed, embedding the whole partition", "error", err)
vecs = make([]embedding.Vector, len(items))
} else {
stats.Reused = reused
}
}
missing := missingVectorIndexes(vecs)
if len(missing) == 0 {
return vecs, stats, nil
}
stats.Sent = len(missing)
if err := s.embedMissing(ctx, emb, items, missing, vecs, &stats, batch); err != nil {
return nil, stats, err
}
if idx != nil {
s.fillIndexVectors(ctx, idx, items, missing, vecs, &stats)
}
return vecs, stats, nil
}
// reuseIndexedVectors 把索引里已有的向量摊到 vecs 的对应位置,返回复用条数。
// 只读:它不写 vecs 之外的任何东西,也不产生 embedding 请求。
func (s *Store) reuseIndexedVectors(ctx context.Context, idx vector.Index, items []MemoryItem, vecs []embedding.Vector) (int, error) {
ids := make([]string, 0, len(items))
positions := make([]int, 0, len(items))
for i, it := range items {
if it.ID == "" {
continue
}
ids = append(ids, it.ID)
positions = append(positions, i)
}
if len(ids) == 0 {
return 0, nil
}
stored, err := idx.Vectors(ctx, ids)
if err != nil {
return 0, err
}
reused := 0
for k, pos := range positions {
if k >= len(stored) || len(stored[k]) == 0 {
continue
}
vecs[pos] = embedding.Vector(stored[k])
reused++
}
return reused, nil
}
// missingVectorIndexes 返回还没有向量的那些条目的下标(顺序即条目顺序,
// 入库审查的新条目因此在最前)。
func missingVectorIndexes(vecs []embedding.Vector) []int {
missing := make([]int, 0, len(vecs))
for i, v := range vecs {
if len(v) == 0 {
missing = append(missing, i)
}
}
return missing
}
// embedMissing 只嵌入缺向量的那些条目,把结果写回 vecs 的对应位置。
//
// 批级失败(整体性故障)当场收手:对已经挂掉的服务继续发后续批就是 M15 记的
// 那种放大。缺口条数进 stats,绝不静默。
func (s *Store) embedMissing(ctx context.Context, emb embedding.Embedder, items []MemoryItem,
missing []int, vecs []embedding.Vector, stats *embedReuseStats, batch int) error {
if batch <= 0 {
batch = tidyEmbedBatch
}
var firstErr error
for start := 0; start < len(missing); start += batch {
end := min(start+batch, len(missing))
idxs := missing[start:end]
texts := make([]string, len(idxs))
for k, i := range idxs {
texts[k] = items[i].Content
}
got, err := embedIsolated(ctx, emb, texts)
if err != nil {
if firstErr == nil {
firstErr = err
}
break
}
for k, i := range idxs {
if k >= len(got) || len(got[k]) == 0 {
continue
}
vecs[i] = got[k]
}
}
stats.Dropped = stats.Sent - countVectorsAt(vecs, missing)
if stats.Sent == stats.Dropped {
if firstErr != nil {
return firstErr
}
return ErrEmbedUnavailable
}
if stats.Dropped > 0 {
s.log.Warn("memory: embedding left gaps in semantic recall",
"missing", len(missing), "dropped", stats.Dropped, "error", firstErr)
}
return nil
}
// countVectorsAt 数 idxs 这些位置里已有向量的条数。
func countVectorsAt(vecs []embedding.Vector, idxs []int) int {
n := 0
for _, i := range idxs {
if i < len(vecs) && len(vecs[i]) > 0 {
n++
}
}
return n
}
// fillIndexVectors 把本轮**算出来的**向量 upsert 回索引(与 store_vector.indexItem
// 同一件事、同一条 id),使下一轮不再重算同一批正文。
//
// 失败只计数 + 一条 WARN:召回已经在本轮拿到的向量上跑完了,回填失败不该让它
// 变成一次失败。维度不符(索引与当前 embedder 不是同一套表示)时当场收手 ——
// 继续逐条试只会刷出上千条同样的失败。
func (s *Store) fillIndexVectors(ctx context.Context, idx vector.Index, items []MemoryItem,
idxs []int, vecs []embedding.Vector, stats *embedReuseStats) {
mismatch := false
for _, i := range idxs {
if items[i].ID == "" || len(vecs[i]) == 0 {
continue
}
err := idx.Add(ctx, items[i].ID, vector.Vector(vecs[i]))
if err == nil {
stats.Filled++
continue
}
stats.FillFailed++
if errors.Is(err, vector.ErrDimMismatch) {
mismatch = true
break
}
}
switch {
case mismatch:
s.log.Warn("memory: vector index dim mismatch, index fill stopped for this round",
"filled", stats.Filled, "failed", stats.FillFailed)
case stats.FillFailed > 0:
s.log.Warn("memory: vector index fill failed for some items",
"filled", stats.Filled, "failed", stats.FillFailed)
}
}
@@ -0,0 +1,172 @@
package memory
import (
"context"
"testing"
)
// 本文件断言两件事的**请求数**(M61,2026-10-30):
// - 增量:索引里已有向量的行不再重嵌 —— 同一分区第二轮整理轮/审查是 **0** 个请求;
// - 韧性:一条坏分片只损失它自己,整体性故障最多 3 个请求就收手。
//
// 计数来自假服务端(embed_fake_server_test.go),被测对象是生产路径
// (embedding.NewAPI + vector.New + Store/Tidier/PreInsertReviewer),不是替身。
// batchRequests 是「按批上限发出去要几个请求」的算式(⌈n/batch⌉)。
func batchRequests(n, batch int) int {
if n <= 0 || batch <= 0 {
return 0
}
return (n + batch - 1) / batch
}
// TestEmbedIncremental_SecondRoundMakesNoRequests 是「增量后不重嵌」的钉死:
// 第一轮把 24 条存量嵌入并**回填索引**(3 个请求 = ⌈24/8⌉),第二轮**一次都不发**。
//
// 删掉复用实现(或把 embedIncremental 改回「每次全量嵌」)这一条立刻打红:
// 第二轮会重新发出同样的 3 个请求。
func TestEmbedIncremental_SecondRoundMakesNoRequests(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
items := reviewItems(t, store, 24)
srv := newFakeEmbedServer(t)
emb := srv.embedder()
// vector.New 会探测一次 embedder 维度(1 个请求),因此基线在它之后取。
store.attachVectorIndex(fakeIndex(t, emb))
base := srv.count()
first, stats1, err := store.embedIncremental(ctx, emb, items, tidyEmbedBatch)
if err != nil {
t.Fatalf("embedIncremental(first): %v", err)
}
firstReqs := srv.count() - base
if want := batchRequests(len(items), tidyEmbedBatch); firstReqs != want {
t.Fatalf("第一轮请求数 = %d, want %d(⌈%d/%d⌉)", firstReqs, want, len(items), tidyEmbedBatch)
}
if stats1.Reused != 0 || stats1.Sent != len(items) || stats1.Filled != len(items) || stats1.Dropped != 0 {
t.Fatalf("第一轮 stats = %+v, want reused=0 sent=%d filled=%d dropped=0", stats1, len(items), len(items))
}
base = srv.count()
second, stats2, err := store.embedIncremental(ctx, emb, items, tidyEmbedBatch)
if err != nil {
t.Fatalf("embedIncremental(second): %v", err)
}
secondReqs := srv.count() - base
if secondReqs != 0 {
t.Fatalf("第二轮请求数 = %d, want 0(已有向量的行不该重嵌)", secondReqs)
}
if stats2.Reused != len(items) || stats2.Sent != 0 || stats2.Filled != 0 {
t.Fatalf("第二轮 stats = %+v, want reused=%d sent=0 filled=0", stats2, len(items))
}
for i := range items {
if len(second[i]) == 0 {
t.Fatalf("第 %d 条复用失败:向量为空", i)
}
if len(second[i]) != len(first[i]) {
t.Fatalf("第 %d 条维度变了:%d -> %d", i, len(first[i]), len(second[i]))
}
for k := range first[i] {
if second[i][k] != first[i][k] {
t.Fatalf("第 %d 条复用到了别的向量(分量 %d)", i, k)
}
}
}
t.Logf("整理/审查同量记忆的请求数:修复前每轮 %d → 修复后第一轮 %d、第二轮 %d(%d 条,批上限 %d)",
batchRequests(len(items), tidyEmbedBatch), firstReqs, secondReqs, len(items), tidyEmbedBatch)
}
// TestTidyRecall_SecondRoundZeroRequests 是整理轮的生产路径断言:同一分区连续两轮
// recallPairs,第一轮按批上限发请求,第二轮**零请求**(候选召回因此不再每轮重嵌)。
func TestTidyRecall_SecondRoundZeroRequests(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
reviewItems(t, store, 12)
srv := newFakeEmbedServer(t)
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Embedder: emb, Log: testLogger()})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
partition := tidyPartition{personaID: "persona.a", scope: ScopePersona}
items, err := store.listTidyPartitionItems(ctx, partition, 500)
if err != nil {
t.Fatalf("listTidyPartitionItems: %v", err)
}
if len(items) != 12 {
t.Fatalf("分区条目数 = %d, want 12", len(items))
}
base := srv.count()
ledger := &recallLedger{}
if _, err := tidier.recallPairs(ctx, partition, items, &TidyStats{}, tidyBudget{}, ledger); err != nil {
t.Fatalf("recallPairs(round 1): %v", err)
}
firstReqs := srv.count() - base
want := batchRequests(len(items), tidyEmbedBatch)
if firstReqs != want {
t.Fatalf("第一轮整理召回请求数 = %d, want %d", firstReqs, want)
}
if ledger.embedding != 1 || ledger.lexical != 0 {
t.Fatalf("ledger = %+v, want 一次 embedding 召回、零次降级", ledger)
}
base = srv.count()
if _, err := tidier.recallPairs(ctx, partition, items, &TidyStats{}, tidyBudget{}, &recallLedger{}); err != nil {
t.Fatalf("recallPairs(round 2): %v", err)
}
secondReqs := srv.count() - base
if secondReqs != 0 {
t.Fatalf("第二轮整理召回请求数 = %d, want 0(增量向量化没生效)", secondReqs)
}
t.Logf("整理轮 recallPairs 请求数:第一轮 %d → 第二轮 %d(%d 条)", firstReqs, secondReqs, len(items))
}
// TestReviewRecall_SecondReviewZeroRequests 是入库审查的生产路径断言:新条由写入
// 侧落库时已建好向量,因此**第二次审查同一分区是零请求**(候选都从索引取)。
//
// 这把 M15 那条「每条审查 26 个请求」压到「第一条审查 ⌈K/批⌉、之后 0」。
func TestReviewRecall_SecondReviewZeroRequests(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
reviewItems(t, store, 20)
srv := newFakeEmbedServer(t)
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
reviewer := newTestReviewer(t, store, emb, 0.80)
first := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "新写入记忆 第一条",
})
base := srv.count()
if _, degraded, err := reviewer.recallCandidates(ctx, first); err != nil || degraded {
t.Fatalf("recallCandidates(first) = (degraded=%v, err=%v), want (false, nil)", degraded, err)
}
firstReqs := srv.count() - base
if want := batchRequests(20, tidyEmbedBatch); firstReqs != want {
t.Fatalf("第一条审查的请求数 = %d, want %d(只有 20 条既有候选缺向量)", firstReqs, want)
}
second := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "新写入记忆 第二条",
})
base = srv.count()
if _, degraded, err := reviewer.recallCandidates(ctx, second); err != nil || degraded {
t.Fatalf("recallCandidates(second) = (degraded=%v, err=%v), want (false, nil)", degraded, err)
}
secondReqs := srv.count() - base
if secondReqs != 0 {
t.Fatalf("同分区第二次审查的请求数 = %d, want 0(增量为空 ⇒ 一个请求都不该发)", secondReqs)
}
t.Logf("入库审查请求数:修复前 26(201÷8)→ 第一条 %d、第二条 %d", firstReqs, secondReqs)
}
+121
View File
@@ -0,0 +1,121 @@
package memory
import (
"context"
"errors"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是嵌入链对**单个慢分片**的韧性(design/23 §15.3 登记的遗留,2026-10-13):
// `embedding.Embed` 的语义是「任一分片失败 ⇒ 整条调用失败」,于是一条慢分片会把
// 其余已经算好的分片一起丢掉 —— 审查因此整条降级到词项余弦(92% 的
// `recall_mode` 为空的一半成因),整理轮同理。
//
// 修法**不在** embedding 包里改语义(那是四个调用方共享的口径,改它等于改所有
// 人的预算推演),而是在调用方这一层做**逐条隔离**:整批先发一次;失败则把这一批
// 拆成单条逐条重试 —— 好的照常拿到向量,坏的那几条记缺口,**整条调用不失败**。
//
// ## 为什么是「逐条」而不是递归二分
//
// 二分定位一条坏文本在短正文下要 1+2+2+2 = 7 个请求(比逐条的 9 少 2 个),但它
// 需要一套失败预算的簿记(哪一层还允许拆、拆到多深收手),而那套簿记本身就是
// 「韧性变成放大」的风险点:预算给紧了会把好正文一起丢掉,给松了会在服务挂死时
// 把请求数放大成 2^深度。逐条隔离的代价**恒定且可算**:`embedOnce` 调用 ≤ 1+B
// (B = 批上限),定位精度与二分相同(精确到单条),没有任何深度参数。
//
// ## 三条必须同时成立的约束
//
// 1. **定位**:只有整批失败才逐条重试,因此正常路径(绝大多数批)**零额外请求**;
// 2. **止损**:一条都没拿到 ⇒ 如实返回错误(调用方降级到词项余弦);有拿到就
// 只损失坏的那几条 —— 绝不把「算不出来」说成「没有相似」;
// 3. **预算**:父 ctx 已到期时不再逐条重试(预算花完了,重试只会把 worker 拖住)。
//
// ## HTTP 请求数的上界(必须按**两维**切片算,B = 批上限 = 8)
//
// `embedding.Embed` 不是「一批一个请求」:它按 budget.go 的**条数界**
// (DefaultBatchLimit=8)**与字符界**(DefaultBatchChars=480)同时切片
// (chunkTexts),因此一次 `embedOnce(B 条)` 会发出 C 个请求,C 由正文长度决定:
// 全批 ≤480 字 ⇒ C=1;均长 75 汉字(budget.go 的实测口径)时 8×75=600 > 480 ⇒ C=2;
// 每条自己就超过 480 字 ⇒ 每条独占一片 ⇒ C=B(病理上限)。
// 单条重试**永不切分**(它自己就是一片)⇒ 每条恰好 1 个请求。于是:
//
// 正常批 C(短正文 C=1;长正文 2 起)
// 含一条坏正文 首次失败发生在第 1~C 个 chunk 之一,随后逐条 B 次
// ⇒ ≤ C + B(短正文 9;现实长正文 10;病理上限 2B=16)
// 服务整体挂死 首个 chunk 即失败 + 逐条 B 次 ⇒ **1 + B = 9**(与字符维无关)
//
// 口径与 budget.go 同源:那里的「建模上界 ≠ 延迟保证」是同一类提醒 —— 这里的
// 「≤1+B」是**embedOnce 调用次数**的上界,HTTP 请求数必须再按字符维的 chunk 数算
// (正常批 C、含坏正文 ≤C+B);只有「服务整体挂死 = 1+B」这一条与字符维无关。
// ErrEmbedUnavailable 在「一条向量都没拿到」时返回:调用方据此走既有的词项余弦
// 降级(记忆一条不丢、判定规则一字不改),而不是把「算不出来」当成「没有相似」。
var ErrEmbedUnavailable = errors.New("memory: embedder returned no vector for any input")
// ErrEmbedCountMismatch 在 embedder 返回的向量条数与入参条数不一致时返回:
// 错位会让相似度与条目错配,因此按失败处理(随后由逐条隔离定位是哪一条出的问题)。
var ErrEmbedCountMismatch = errors.New("memory: embedder returned a mismatched vector count")
// embedIsolated 嵌入 texts 并返回与之对齐的向量:失败的位置为 nil,其余照常。
//
// 返回错误的**唯一**情形是「一个向量都没拿到」(或父 ctx 已到期);此时调用方按
// 既有语义降级到词项余弦。
func embedIsolated(ctx context.Context, emb embedding.Embedder, texts []string) ([]embedding.Vector, error) {
if len(texts) == 0 {
return nil, nil
}
vecs, err := embedOnce(ctx, emb, texts)
if err == nil {
return vecs, nil
}
if len(texts) == 1 {
// 单条失败:这条慢/坏分片只损失它自己。
return []embedding.Vector{nil}, nil
}
if cerr := ctx.Err(); cerr != nil {
return nil, fmt.Errorf("memory: embed %d texts: %w (%w)", len(texts), err, cerr)
}
out := make([]embedding.Vector, len(texts))
for i, text := range texts {
if ctx.Err() != nil {
break
}
one, oneErr := embedOnce(ctx, emb, []string{text})
if oneErr != nil {
// 这一条就是坏的那个分片:留下缺口,别让同一批里的其它条目连坐。
continue
}
out[i] = one[0]
}
if nonEmptyVectors(out) == 0 {
// 逐条重试也没拿到任何向量 ⇒ 故障是整体性的(服务挂死 / 地址错),
// 如实报错让调用方降级,而不是交回一批「没有向量」的空结果。
return nil, err
}
return out, nil
}
// embedOnce 发一次 Embed,并要求向量条数与入参一致。它是隔离的「一次尝试」。
func embedOnce(ctx context.Context, emb embedding.Embedder, texts []string) ([]embedding.Vector, error) {
vecs, err := emb.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, fmt.Errorf("%w (got %d, want %d)", ErrEmbedCountMismatch, len(vecs), len(texts))
}
return vecs, nil
}
// nonEmptyVectors 数一个对齐切片里真正拿到向量的条数。
func nonEmptyVectors(vecs []embedding.Vector) int {
n := 0
for _, v := range vecs {
if len(v) > 0 {
n++
}
}
return n
}
@@ -0,0 +1,170 @@
package memory
import (
"context"
"fmt"
"testing"
)
// 本文件是「单分片韧性」的断言(M61,2026-10-30;design/23 §15.3 已登记口径):
// 一条慢/坏分片**不丢整条**——同一批里其它条目的向量照常返回,只损失那一条;
// 而整体性故障(服务挂死)**不放大**:整批在第一个 chunk 即失败 + 逐条 B 次
// ⇒ 1+B 个请求就收手(B = 批上限 8)。
//
// ⚠️ 请求数口径是**两维**的(见 embed_isolation.go 文件头):本文件的正文都很短
// (8 条合计远小于 DefaultBatchChars=480)⇒ chunkTexts 只按条数界切、C=1,因此
// 「含一条坏正文 = 1+B」在这些用例里是精确值;长正文批的真实上界是 C+B。
// TestEmbedIsolation_OneBadTextKeepsTheRest 是韧性的主断言:批里有一条坏正文时,
// 其余 7 条照常拿到向量,坏的那条记为缺口,请求数被逐条隔离的成本封顶。
//
// 删掉 embedIsolated(改回「一次 Embed 定生死」)这一条会打红:整批返回错误、
// 7 条好正文的向量一起丢掉。
func TestEmbedIsolation_OneBadTextKeepsTheRest(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := newFakeEmbedServer(t)
srv.failIf = "毒"
emb := srv.embedder()
texts := make([]string, 0, tidyEmbedBatch)
bad := -1
for i := 0; i < tidyEmbedBatch; i++ {
text := fmt.Sprintf("正常正文 第%d条", i)
if i == tidyEmbedBatch-1 {
text = "毒 这一条会让整批请求失败"
bad = i
}
texts = append(texts, text)
}
base := srv.count()
vecs, err := embedIsolated(ctx, emb, texts)
reqs := srv.count() - base
if err != nil {
t.Fatalf("embedIsolated: %v(一条坏分片不该丢掉整条调用)", err)
}
if len(vecs) != len(texts) {
t.Fatalf("向量条数 = %d, want %d(必须与入参对齐)", len(vecs), len(texts))
}
if len(vecs[bad]) != 0 {
t.Fatalf("坏正文居然拿到了向量:%v", vecs[bad])
}
kept := 0
for i := range vecs {
if i == bad {
continue
}
if len(vecs[i]) == 0 {
t.Fatalf("第 %d 条好正文被坏分片连坐了(向量为空)", i)
}
kept++
}
if kept != len(texts)-1 {
t.Fatalf("保住的条数 = %d, want %d", kept, len(texts)-1)
}
// 逐条隔离的成本上界:整批 1 个 chunk(本例正文短 ⇒ C=1)+ 逐条重试 B 次
// ⇒ 1+B = 9。长正文批的真实上界是 C+B(C 由 480 字字符界决定,见文件头)。
if limit := 1 + tidyEmbedBatch; reqs > limit {
t.Fatalf("定位一条坏分片花了 %d 个请求,超过逐条上界 %d(韧性变成了放大)", reqs, limit)
}
t.Logf("一条坏分片:保住 %d/%d 条,代价 %d 个请求(短正文上界 1+B = %d)",
kept, len(texts), reqs, 1+tidyEmbedBatch)
}
// TestEmbedIsolation_OutageStopsFast 是**反放大**的断言:服务整体挂死时不许把
// 「用请求换韧性」变成请求风暴 —— 逐条重试一条都没拿到即判定整体性故障,当场
// 返回错误,且请求数被 1+B 封顶(整批在第一个 chunk 就失败 ⇒ 1 个请求 + 逐条 B;
// 这一条与字符维的 chunk 数无关,本例正文短、C 本来就是 1)。
func TestEmbedIsolation_OutageStopsFast(t *testing.T) {
t.Parallel()
ctx := context.Background()
srv := newFakeEmbedServer(t)
srv.failAll = true
emb := srv.embedder()
texts := make([]string, 0, tidyEmbedBatch)
for i := 0; i < tidyEmbedBatch; i++ {
texts = append(texts, fmt.Sprintf("正文 %d", i))
}
base := srv.count()
vecs, err := embedIsolated(ctx, emb, texts)
reqs := srv.count() - base
if err == nil {
t.Fatalf("服务整体不可用却返回成功:%v", vecs)
}
if limit := 1 + tidyEmbedBatch; reqs > limit {
t.Fatalf("整体性故障发了 %d 个请求,want ≤ %d(整批 1 + 逐条 B)", reqs, limit)
}
t.Logf("整体性故障:%d 个请求后收手(无放大),错误 = %v", reqs, err)
}
// TestEmbedIncremental_PartialGapKeepsGoing 是端到端的韧性:24 条里 1 条坏正文,
// 其余 23 条照常带向量返回,统计里如实记 1 个缺口(绝不静默)。
func TestEmbedIncremental_PartialGapKeepsGoing(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
items := reviewItems(t, store, 24)
srv := newFakeEmbedServer(t)
srv.failIf = "毒"
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
// 把第 9 条换成坏正文(用一条新条目顶掉,保证它也在 missing 里)。
items[8] = addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "毒 这条正文每次请求都失败",
})
vecs, stats, err := store.embedIncremental(ctx, emb, items, tidyEmbedBatch)
if err != nil {
t.Fatalf("embedIncremental: %v(一条坏正文不该丢掉整轮召回)", err)
}
if stats.Dropped != 1 {
t.Fatalf("缺口数 = %d, want 1(缺口必须如实计数)", stats.Dropped)
}
if stats.Filled != len(items)-1 {
t.Fatalf("回填数 = %d, want %d", stats.Filled, len(items)-1)
}
if len(vecs[8]) != 0 {
t.Fatalf("坏正文居然拿到了向量")
}
for i := range vecs {
if i == 8 {
continue
}
if len(vecs[i]) == 0 {
t.Fatalf("第 %d 条被坏分片连坐了", i)
}
}
}
// TestReviewRecall_SelfGapDegradesToLexical 是「被审的那一条自己没向量」的口径:
// 此时语义召回对它无从谈起(相似度恒 0),必须**如实降级**到词项余弦并标记
// degraded,而不是交出一片看起来「没有重复」的空候选。
func TestReviewRecall_SelfGapDegradesToLexical(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
reviewItems(t, store, 3)
srv := newFakeEmbedServer(t)
srv.failIf = "毒"
emb := srv.embedder()
store.attachVectorIndex(fakeIndex(t, emb))
reviewer := newTestReviewer(t, store, emb, 0.80)
item := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "毒 被审的这条没有向量",
})
_, degraded, err := reviewer.recallCandidates(ctx, item)
if err != nil {
t.Fatalf("recallCandidates: %v(缺口该走降级,不该整条失败)", err)
}
if !degraded {
t.Fatal("被审条目没有向量却把自己报成未降级:那会把「算不出来」说成「没有重复」")
}
}
+47 -1
View File
@@ -38,6 +38,19 @@ import (
// 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(temporal.go // 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(temporal.go
// 头注记着两次被生产重放否掉的接线方案与接线前必须看的那份分布)。因此这段 prompt // 头注记着两次被生产重放否掉的接线方案与接线前必须看的那份分布)。因此这段 prompt
// 的**唯一**作用是让观测面有数据;即便模型完全不听,判定的行为与今天一致。 // 的**唯一**作用是让观测面有数据;即便模型完全不听,判定的行为与今天一致。
//
// 🆕 2026-10-30(切片 M63,审计 §7 P0-2):prompt 里追加**敏感位** `sensitive`。
// 它**参与**受限车道的可见性(落库是 `sensitive` 列,迁移 0028;消费方是
// sensitive.go 的 SharedVisible),但判据不走「靠提示词」那条老路:
// - 模型只做**类目级标注**(住址 / 私人称呼 / 家人 / 健康 / 作息与行程 / 收入
// → true),因为新私事的措辞无法穷举;
// - 确定性那一半在代码里:`resolveSensitive` 会把产出与**已实证短语白名单**
// 取「或」(sensitive.go 的 privateFacts),模型漏标时由白名单补上。
//
// ⚠️ 注意 prompt 的**提取面没有缩小**:住址 / 家人 / 健康 / 称呼习惯仍然要提取,
// 只是必须标 sensitive —— 塔裁决 2026-10-30 明确「私事用 sensitive 标记,不迁
// scope」,因为迁进 persona 域会让其他角色可读(memory/rules.go 跨角色只读)。
// 主人车道照常读到这些记忆,受限车道才看不见。
const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。 const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
只保留这些(正向清单): 只保留这些(正向清单):
@@ -58,6 +71,25 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回 **允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
[],不要为了凑数而提取。 [],不要为了凑数而提取。
每条还要标一个**敏感位** "sensitive"(布尔值 true / false)。判据只有一句话:
**这句话能不能说给主人的朋友听**。
- true —— 主人只愿在私下被提起的事:住址与所在城市、真实姓名与私人称呼(主人
希望被怎么叫、专属昵称)、家庭成员的姓名与情况、健康与身体约束、作息与出行
行程、收入与工作单位,以及任何「主人告诉了助手、但不该转述给第三方」的内容。
- false —— 对谁都能说的稳定信息:口味与饮食偏好、兴趣爱好、喜欢的作品与游戏、
不涉及上面那些私事的相处方式、项目约定与编号规则。
正反例(照这个尺度标,不要自己另立一套):
- 「用户喜欢冰美式」→ sensitive=false(口味习惯,谁都能知道)。
- 「主人住在花溪」→ sensitive=true(住址)。
- 「主人希望被称作叶酱」→ sensitive=true(私人称呼)。
- 「主人常熬夜,助手会提醒早睡」→ sensitive=true(作息)。
- 「用户喜欢《塞尔达传说》」→ sensitive=false(兴趣爱好)。
- 「项目编号规则是 CY-日期-序号」→ sensitive=false(项目约定)。
拿不准就标 true —— 标成 true 的代价只是群聊里少提一句,标成 false 的代价是主人
的私事被说给外人。这个字段必填,不要省掉、不要填字符串。
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一: 每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子), - "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
即使你已经把它写成「用户……」这样的陈述句; 即使你已经把它写成「用户……」这样的陈述句;
@@ -69,7 +101,7 @@ temporal 只许填这四个英文值之一(不要换成中文、不要自造
"unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。 "unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。
只输出一个 JSON 数组,不要任何其他文字。每个元素形如: 只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"} {"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","sensitive":true,"temporal":"one_off|recurring|durable|unknown","tags":["标签"],"confidence":0.9,"project_id":"可选项目id"}
去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件** 去噪去重:只保留值得长期记住的,忽略寒暄与临时信息;确实发生过的**一次性事件**
如果值得记,也照实提取,并把 temporal 标成 one_off。` 如果值得记,也照实提取,并把 temporal 标成 one_off。`
@@ -86,6 +118,15 @@ type llmCandidate struct {
Tags []string `json:"tags"` Tags []string `json:"tags"`
Confidence float64 `json:"confidence"` Confidence float64 `json:"confidence"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
// Sensitive 是模型给出的**敏感位**(迁移 0028,切片 M63):true = 这条是主人的
// 私事(住址 / 私人称呼 / 家人 / 健康 / 作息/行程 / 收入 …),受限车道不许召回。
//
// 🔴 它**不是**权限判定(AGENTS §9 只约束身份判定):它只是一个数据层标记,主人
// 车道照常读到。模型漏标或干脆没给这个键 → 缺席即 false,再落到 resolveSensitive
// 的第二道判据(已实证短语白名单)上;键的类型填错(例如给字符串 "true")会让
// **整批** JSON 解码失败 —— 与其它字段同一口径,由调用方决定重试还是回退,
// 这里不为了容错去猜模型的意图(猜错的方向是把私事当成可外说)。
Sensitive bool `json:"sensitive"`
} }
// LLMExtractor 让 model.Adapter 从对话中推导候选记忆条目 // LLMExtractor 让 model.Adapter 从对话中推导候选记忆条目
@@ -266,6 +307,11 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
// 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错, // 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错,
// 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。 // 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。
Temporal: normalizeTemporal(c.Temporal), Temporal: normalizeTemporal(c.Temporal),
// 敏感位(迁移 0028)在**产出侧**合成:模型的类目判断与已实证短语白名单
// 取或(resolveSensitive)。两次判据都放在这里而不是只等 Store.Add 兜底,
// 是为了让 **dry-run**(只跑 Extract、不落库)看到的位就是将来落库的位 ——
// 否则「提取器判据」这条验收只能靠真落库才看得见。
Sensitive: resolveSensitive(c.Sensitive, content),
} }
if !it.Scope.valid() || !it.Kind.valid() { if !it.Scope.valid() || !it.Kind.valid() {
continue continue
@@ -0,0 +1,91 @@
package memory
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉住**提取器侧的敏感判据**(切片 M63、审计 §7 P0-2):
//
// 1. prompt 里真的给了判据与正反例(不是「交给模型自由发挥」);
// 2. **dry-run 私事不落 `scope=user & sensitive=false`** —— 即模型漏标时,产出侧
// 仍按白名单补上敏感位。这是审计 P0-2 的验收原话,也是最容易复发的一条:
// 模型的标注是概率性的,白名单是确定性的。
// 3. 模型显式标 true 的非白名单内容不被撤标。
//
// 删掉实现(prompt 里的敏感位、resolveSensitive 在 dedupeAndBuild 里的调用)会让
// 2 直接变红。
// extractOne 跑一次 Extract,返回第一条候选(没有候选即失败)。
func extractOne(t *testing.T, reply string) MemoryItem {
t.Helper()
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
return model.Response{Content: reply}, nil
}}
ex := NewLLMExtractor(adapter, "persona.a", "role.1")
items, err := ex.Extract(context.Background(), []model.Message{
model.TextMessage(model.RoleUser, "我们聊聊住哪儿"),
})
if err != nil {
t.Fatalf("Extract: %v", err)
}
if len(items) == 0 {
t.Fatal("Extract 没有产出候选")
}
return items[0]
}
// TestExtractPrompt_带敏感位判据与正反例 钉住 prompt 本体:敏感位的键名、判据、
// 正反例(住址 = 私事 / 口味 = 习惯)必须都在。这三样少任何一样,模型就只能瞎猜。
func TestExtractPrompt_带敏感位判据与正反例(t *testing.T) {
t.Parallel()
for _, want := range []string{
`"sensitive"`,
"住址",
"私人称呼",
"「用户喜欢冰美式」→ sensitive=false",
"「主人住在花溪」→ sensitive=true",
"拿不准就标 true",
} {
if !strings.Contains(extractSystemPrompt, want) {
t.Fatalf("extractSystemPrompt 缺少 %q —— 提取器没有敏感判据,模型只能把私事当习惯", want)
}
}
}
// TestExtract_私事漏标仍落敏感 是审计 P0-2 的验收:模型(这里是脚本化的回复)
// 把「主人住在花溪」标成 scope=user 且**没有**给 sensitive 位 —— 产出侧必须补上
// 敏感位,绝不吐出 `user & sensitive=false` 的候选。
func TestExtract_私事漏标仍落敏感(t *testing.T) {
t.Parallel()
got := extractOne(t, `[{"content":"主人住在花溪(贵阳花溪区),不在上海工作","scope":"user","kind":"world_event","confidence":0.9}]`)
if got.Scope != ScopeUser {
t.Fatalf("scope = %q, want user(本用例考的正是 user 域里的私事)", got.Scope)
}
if !got.Sensitive {
t.Fatal("产出侧漏了敏感位:候选是 scope=user & sensitive=false,正是审计 P0-2 的复发点")
}
}
// TestExtract_HabitStaysShared 是方向性护栏:习惯类内容在模型标 false(或缺省)时
// 必须保持非敏感 —— 否则本片就把已冻结的「用户习惯可查」(design/03 §5)推翻了。
func TestExtract_HabitStaysShared(t *testing.T) {
t.Parallel()
got := extractOne(t, `[{"content":"用户喜欢冰美式","scope":"user","kind":"relationship","sensitive":false}]`)
if got.Sensitive {
t.Fatal("习惯类候选被标成敏感 —— 受限车道会平白丢掉合法习惯")
}
}
// TestExtract_模型显式敏感不被撤标 钉住方向性:模型标 true 的非白名单内容
// (例如家人情况,措辞不在白名单里)必须保持 true。
func TestExtract_模型显式敏感不被撤标(t *testing.T) {
t.Parallel()
got := extractOne(t, `[{"content":"主人有个弟弟在读高三","scope":"user","kind":"relationship","sensitive":true}]`)
if !got.Sensitive {
t.Fatal("模型显式标 sensitive=true 被改写成 false —— 产出侧不许撤标")
}
}
@@ -60,6 +60,13 @@ const (
// //
// 🔴 与设计稿的对齐(如实登记):design/23 §15 那一行原写 `Timeout=20s`(本次改动 // 🔴 与设计稿的对齐(如实登记):design/23 §15 那一行原写 `Timeout=20s`(本次改动
// **之前**的口径),已按上面实测同步为 `Timeout=90s`。 // **之前**的口径),已按上面实测同步为 `Timeout=90s`。
//
// 🆕 2026-10-30(M61):链长已由**增量向量化**压到「只剩缺向量的那几条」——
// 同分区第一条审查 ⌈K/批⌉ 个请求、之后 **0 个**(候选从 `vec_items` 取,
// 见 preinsert_recall.go 的 embedReviewTexts 与 embed_incremental.go)。因此上面
// 的 90s 从「必须覆盖链长」变成「远大于链长的余量」;**不下调**是刻意的:
// 单请求在并发下仍会偶发越过 5s(embedding/budget.go 文件头 ④ 的实测),余量是
// 韧性而不是浪费 —— 慢分片现在由 embedIsolated 逐条隔离,不吃满整条预算。
defaultReviewTimeout = 90 * time.Second defaultReviewTimeout = 90 * time.Second
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**, // reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。 // 不是任何角色(design §4.1 C 的同一理由)。
+29 -27
View File
@@ -2,10 +2,16 @@ package memory
import ( import (
"context" "context"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
) )
// ErrReviewSelfUnembedded 在「被审的那一条自己拿不到向量」时返回:语义召回对它
// 无从谈起(相似度恒 0),因此如实报错让调用方走既有的词项余弦降级,而不是交出
// 一片看起来「没有重复」的空候选(那会把「算不出来」说成「没有」)。
var ErrReviewSelfUnembedded = errors.New("memory/review: the item under review could not be embedded")
// 本文件是入库前审查的**候选召回**(design/23 §15 D3-3:用既有 recall 路径, // 本文件是入库前审查的**候选召回**(design/23 §15 D3-3:用既有 recall 路径,
// 不许另造一套相似度实现)。 // 不许另造一套相似度实现)。
// //
@@ -44,7 +50,7 @@ func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryIte
// 不是降级(这是配置形态,不是故障)。 // 不是降级(这是配置形态,不是故障)。
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil
} }
vecs, err := embedReviewTexts(ctx, r.deps.Embedder, items) vecs, err := r.embedReviewTexts(ctx, items)
if err != nil { if err != nil {
// 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记 // 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记
// degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则, // degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则,
@@ -79,37 +85,33 @@ func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float
return out return out
} }
// embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch: // embedReviewTexts 给「新条 1 + 既有候选 K」取向量:**索引里已有的直接复用,
// 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败, // 只嵌缺的**(判据与回填口径见 embed_incremental.go)。
// 由调用方决定降级还是上报,绝不半途改用混合表示。
// //
// 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头): // 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头):
// 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数 // 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数
// = ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit): // 曾是 ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
// //
// 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s // 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s
// 批次 8(改后)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s // 批次 8(M15)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
// 增量(本切片)= **1 个请求**(只有刚落库的新条没向量;既有候选从索引取),
// 同一分区第二次审查起 = **0 个请求**(新条落库时写入侧已建好向量)
// //
// 两点必须一起读:① 请求数减半是本改动**唯一**买到的收益 —— 链路墙钟由 // ⇒ 单条审查的预算(memory.defaultReviewTimeout = 90s)因此从「按链长算、勉强够」
// 「条数 × 每条成本」主导,两个批次形状都是 ~21s;② 所以单条审查的预算 // 变成「按链长算、余量极大」。预算本身**不下调**:慢分片与并发仍会拉长单请求,
// (memory.defaultReviewTimeout)必须按**整条链**算,20s 的旧值比链长还短, // 余量是韧性而不是浪费(见 preinsert_config.go 的推导)。
// 会让每一条候选满额的审查都超时(这正是「92% 的审查 recall_mode 为空」的根因)。 //
func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) { // 返回切片恒与 items 等长(缺口为 nil)。被审的那一条(items[0])拿不到向量时
out := make([]embedding.Vector, 0, len(items)) // 返回错误:语义召回对它无从谈起(相似度恒 0),交给调用方走既有的词项余弦降级,
for start := 0; start < len(items); start += tidyEmbedBatch { // 而不是交出一片空候选。
end := min(start+tidyEmbedBatch, len(items)) func (r *PreInsertReviewer) embedReviewTexts(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) {
texts := make([]string, 0, end-start) vecs, stats, err := r.deps.Store.embedIncremental(ctx, r.deps.Embedder, items, tidyEmbedBatch)
for _, it := range items[start:end] { if err != nil {
texts = append(texts, it.Content) return nil, err
}
vecs, err := emb.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, ErrTidyEmbedCount
}
out = append(out, vecs...)
} }
return out, nil logEmbedReuse(r.log, "review", stats, tidyEmbedBatch)
if len(vecs) == 0 || len(vecs[0]) == 0 {
return nil, ErrReviewSelfUnembedded
}
return vecs, nil
} }
+1 -1
View File
@@ -32,7 +32,7 @@ const ingestStatusMerged = "merged"
// (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。 // (design/23 §19),**不会复活**。姊妹注释在 tidy_store.go 的 listTidyPartitionItemsSQL。
const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items decay_weight, retention_tier, sensitive FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? AND id <> ? WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? AND id <> ?
ORDER BY created_at DESC, rowid DESC LIMIT ?` ORDER BY created_at DESC, rowid DESC LIMIT ?`
@@ -176,18 +176,20 @@ func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) {
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols) t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols)
} }
} }
// 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal`。 // 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal, sensitive`。
// 迁移路径上这两列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。 // 迁移路径上这几列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。
// (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后。) // (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后;
// 0028 又把 sensitive 追加在 temporal 之后。)
bootCols := tableColumns(t, bootstrapped, "memory_items") bootCols := tableColumns(t, bootstrapped, "memory_items")
if len(bootCols) < 2 { if len(bootCols) < 3 {
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols)) t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols))
} }
tail := bootCols[len(bootCols)-2:] tail := bootCols[len(bootCols)-3:]
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") || if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
!strings.Contains(tail[1], "|"+temporalColumn+"|") { !strings.Contains(tail[1], "|"+temporalColumn+"|") ||
t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s", !strings.Contains(tail[2], "|"+sensitiveColumn+"|") {
tail, retentionTierColumn, temporalColumn) t.Fatalf("自举建的 memory_items 尾三列 = %v, want …, %s, %s, %s",
tail, retentionTierColumn, temporalColumn, sensitiveColumn)
} }
} }
@@ -26,6 +26,11 @@ func (m *mockVectorIndex) Dim() int {
func (m *mockVectorIndex) Add(_ context.Context, _ string, _ vector.Vector) error { return nil } func (m *mockVectorIndex) Add(_ context.Context, _ string, _ vector.Vector) error { return nil }
// Vectors 默认一律未命中:这些用例验的是检索/写入接线,不是增量复用。
func (m *mockVectorIndex) Vectors(_ context.Context, ids []string) ([]vector.Vector, error) {
return make([]vector.Vector, len(ids)), nil
}
func (m *mockVectorIndex) Close() error { return nil } func (m *mockVectorIndex) Close() error { return nil }
func (m *mockVectorIndex) Embed(ctx context.Context, texts []string) ([]vector.Vector, error) { func (m *mockVectorIndex) Embed(ctx context.Context, texts []string) ([]vector.Vector, error) {
+2 -2
View File
@@ -102,7 +102,7 @@ func (s *Store) listScope(ctx context.Context, scope Scope, personaID string, ti
now := timeGateNow() now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind, query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items WHERE scope = ? AND status = ?` decay_weight, retention_tier, sensitive FROM memory_items WHERE scope = ? AND status = ?`
args := []any{string(scope), string(tidyStatusActive)} args := []any{string(scope), string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers) query, args, err := retentionTierList(query, args, tiers)
if err != nil { if err != nil {
@@ -144,7 +144,7 @@ func (s *Store) listProject(ctx context.Context, projectID string, tiers []Reten
now := timeGateNow() now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind, query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items WHERE project_id = ? AND status = ?` decay_weight, retention_tier, sensitive FROM memory_items WHERE project_id = ? AND status = ?`
args := []any{projectID, string(tidyStatusActive)} args := []any{projectID, string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers) query, args, err := retentionTierList(query, args, tiers)
if err != nil { if err != nil {
+111
View File
@@ -0,0 +1,111 @@
package memory
import "strings"
// 本文件是「敏感标记」这根轴(迁移 0028、`memory_items.sensitive`)的**数据层定义**:
// 字段语义、受限可见性判据、写入侧的关键词兜底白名单,以及回填判据的理由。
//
// 🔴 **sensitive 是数据层标记,不是权限系统**(塔裁决 2026-10-30,依据 M60 审计
// `DevDocs/audit/群聊主人信息泄漏调查-2026-10-30.md` §7 P0-1):主人车道(非受限
// 环境)照常读到**全部**记忆,含 `sensitive=1`;本标记只决定「这一条能不能进
// **受限车道**(群聊 / 陌生人)的召回候选池」。谁是主人依旧只由 actor 元数据 +
// 白名单 + 策略引擎判定(AGENTS §9),本标记不参与任何身份判定,也绝不改变
// 任何一行的读/写权限。
//
// **为什么需要这根轴**:作用域 ≠ 敏感度(审计 §6 R1)。`scope='user'` 是「用户习惯
// 全局共享域」,但实证域内装着住址 / 私称 / 作息(只读实测:`花溪` 64 条、`叶酱`
// 63 条、`早睡` 38 条 active),于是「共享域整锅注入」把主人的私事送进了群聊上下文
// —— 2026-10-29 群 531082168 的住址泄漏即此路径(审计 §2.1)。sensitive 把
// 「这条归谁」(scope)与「这条能不能外说」(sensitive)拆成两根正交的轴:scope
// 仍决定谁可写谁可读,sensitive 只决定受限车道看不看得见。**没有改动**已冻结的
// 「用户习惯可查」裁决(design/03 §5):习惯类记忆 `sensitive=0`,分组照常可查。
//
// 呈现层(channelbridge 的公域呈现块,M62 已合 caa19d6)与本标记互为纵深:
// 数据面挡住「私事进不了受限候选池」,呈现面挡住「即使读到也不许外说」。
// privateFact 是一条**已实证**的主人私事:类别只用于留痕与报告,判据是 phrase
// 的字面命中(`strings.Contains`)。
type privateFact struct {
// Category 是私事类别(住址 / 私称 / 作息 …),只作日志与回填报告的标签。
Category string
// Phrase 是**精确短语**,不是泛类别关键词。
Phrase string
}
// privateFacts 是回填与写入兜底共用的判据表 —— **人工确认清单的精确短语白名单**,
// 不是关键词类目(审计 §7 P0-2 的「关键词白名单 vs 人工清单」二选一,这里选后者,
// 理由三条):
//
// 1. **来源可追**:三条短语都是审计 §2 按「全库 token 唯一性」逐字实证的主人私事
// (花溪:全库 user 消息只在主人私聊出现过 1 次;叶酱:主人私称;早睡:作息),
// 而不是「住址/称呼/作息」这类需要模型再判一次的类目。
// 2. **误标不可逆**:泛关键词(「住」「名字」「称呼」「睡」「家人」「健康」)会
// 把习惯类记忆一起标掉(「住手」「喜欢睡懒觉的猫」「家人群里发的梗」),而受限
// 车道一旦少了一条合法习惯,主人看不出、也不会去改回来 —— 缺口的可见性远低于
// 泄漏,所以宁窄勿宽。
// 3. **可增量**:新实证的私事短语往这张表里加一行即可,回填脚本幂等重跑
// (`BackfillSensitive`)只会补标、不会撤标。
//
// ⚠️ 这张表是**兜底**,不是提取器判据的替代:类目级判断(住址 / 家人 / 健康 /
// 称呼习惯 → 标 sensitive)由提取器在产出侧做(见 llmextractor.go 的正反例),
// 因为新私事的措辞无法穷举。两道判据只做「或」:提取器标了就是标了,模型漏标时
// 这里按白名单补上(口径同 AGENTS §9「判据靠代码,提示词只辅助」)。
var privateFacts = []privateFact{
{Category: "住址", Phrase: "花溪"},
{Category: "私称", Phrase: "叶酱"},
{Category: "作息", Phrase: "早睡"},
}
// IsPrivateContent 报告 content 是否命中已实证的主人私事白名单。它是**纯函数**,
// 因此写入侧兜底(Store.Add)与回填(BackfillSensitive)用的是同一个判据。
func IsPrivateContent(content string) bool {
for _, f := range privateFacts {
if strings.Contains(content, f.Phrase) {
return true
}
}
return false
}
// resolveSensitive 是敏感位的**唯一**合成判据:调用方给出的标记(提取器的类目
// 判断 / 人工显式标注)与已实证短语白名单取「或」。两条写路径(提取器产出候选、
// Store.Add 落库)都走它,避免「一处只认模型、另一处只认白名单」的分叉 ——
// 也让 dry-run(只跑 Extract、不落库)看到的位与真正落库的位一致。
//
// 方向性:只能把 false 翻成 true,**不撤标**(flagged=true 而白名单没命中时仍是
// true)。撤标需要人工判据,不是任何自动路径该做的事。
func resolveSensitive(flagged bool, content string) bool {
return flagged || IsPrivateContent(content)
}
// sensitiveColumn 返回要写进 `sensitive` 列的值(INTEGER,0 / 1)。与
// temporalColumn 同族:把「条目 → 落库值」这一步收在类型自己身上,插入语句里
// 不留裸转换,也就不可能出现两处各转一次、口径分叉。
func (it MemoryItem) sensitiveColumn() int {
if it.Sensitive {
return 1
}
return 0
}
// SharedVisible 报告这条记忆是否属于受限车道(群聊 / 陌生人)**可注入**的共享域:
// 作用域是共享域(`user` / `project`)、且**未**标 sensitive。
//
// 它是作用域与敏感度两根轴的合取,也是 restricted 侧唯一的可见性判据(
// `internal/runtime/loop/memory_filter.go` 的 FilterSharedVisible 与复用它的
// `knowledge` 工具)—— 放在这里而不是各调用点,是为了让「谁能看见」只有一个
// 注册点(审计 2026-10-05:knowledge 曾自造过滤绕过群聊召回,P0 红线)。
//
// ⚠️ 它只说「受限车道看不看得见」,**不**说明「谁可以读这一行」:主人车道
// (`env.Restricted() == false`)根本不过这道判据,照常读到全部记忆。
func (it MemoryItem) SharedVisible() bool {
if it.Sensitive {
return false
}
switch it.Scope {
case ScopeUser, ScopeProject:
return true
default:
return false
}
}
@@ -0,0 +1,168 @@
package memory
import (
"context"
"database/sql"
"fmt"
"strings"
)
// 本文件是**历史数据的敏感标记回填**(切片 M63,审计 §7 P0-1 的回填面)。
//
// 它是一次性数据动作,与结构前滚(sensitive_schema.go)**刻意分开**:
// 前滚永远幂等且零副作用,回填则可 dry-run、可重跑、只在需要时跑一次 ——
// 两个验收因此互不牵连(前滚失败是启动失败,回填标错是可以回头修的数据问题)。
//
// 🔴 **回填判据 = 人工确认清单的精确短语白名单**(sensitive.go 的 privateFacts,
// 理由见那里的三条)。不用泛类目关键词(「住」「称呼」「睡」「家人」「健康」):
// 误标会让受限车道永久丢掉合法习惯,而缺口主人看不见、也不会改回来。
//
// 🔴 **不依赖「先去重」**(审计 §7 P2-2 建议的先做去重):重复行全部标上才是安全
// 方向 —— 标一条重复行没有额外代价(合并/归档后它本来就不进召回),而漏标一条
// 就等于留一条泄漏面。去重只影响工作量,不是回填的前置条件。
//
// 🔴 **不改写 updated_at**:本列是数据层标记,不是内容/活跃度事件。改 updated_at
// 会连带篡改「最新在前」的列表顺序与衰减基准(decay 由 updated_at 参与排序),
// 让一次打标变成一次全库召回排序的扰动。
//
// **幂等**:UPDATE 带 `sensitive = 0` 前置条件,重跑只补标、不撤标、不改已标行;
// 重跑一次的 `Marked` 恒为 0(tests 钉住这条)。
//
// **生产执行口径**(本片实际执行的就是这一步,见交付报告):先在**备份**上跑
// `dryRun=true` 核对 `Matched`,再对生产库跑一次 `dryRun=false`;
// 生产库此刻若还没有 `sensitive` 列,先由 NewStore(或迁移 0028)补列 —— 顺序是
// 「补列 → 回填」,反过来会直接报 no such column。
// SensitiveBackfillReport 是一次回填(含 dry-run)的结果。它同时是**验收证据**:
// 受限车道的候选池规模(CandidatesBefore → CandidatesAfter)下降多少、每条白名单
// 短语各命中多少行,都在这里,调用方不必再跑去数据库里数一遍。
type SensitiveBackfillReport struct {
// DryRun 为 true 时本报告只统计、不写库(Marked 恒为 0)。
DryRun bool
// Matched 是命中白名单的行数(**全部** scope 与 status:仓库里还有 merged /
// archived 的行,撤销软合并会让 merged 的行重新进 active,故一并标)。
Matched int
// AlreadyFlagged 是命中白名单且**已是** sensitive=1 的行数(重跑时为 Matched
// 的子集,等于 Matched 即「已经跑过」)。
AlreadyFlagged int
// Marked 是本次实际把 sensitive 从 0 改成 1 的行数(dry-run 恒为 0)。
Marked int
// ByPhrase 是每条白名单短语各自命中的行数(标签见 privateFacts 的 Category)。
ByPhrase map[string]int
// CandidatesBefore / CandidatesAfter 是**受限车道候选池**的规模(active 且
// scope ∈ {user, project} 且 sensitive=0 的行数)—— 回填前后各取一次,差值就是
// 本片从群聊/陌生人候选池里移除的条数。
CandidatesBefore int
CandidatesAfter int
}
// sensitiveCandidatePoolSQL 数的是「受限车道(群聊 / 陌生人)的候选池」:四条列表
// 读路径给检索器的共享域候选,与 loop.FilterSharedVisible 的判据同源
// (active + scope ∈ {user, project} + 未标 sensitive)。回填前后各跑一次即可量化
// 本片收窄了多少。**它不接时效闸**(timeGateClause):这里量的是「表里有多少行
// 属于共享域」,不是「此刻能不能被召回」,接上闸会让两个时点的口径随墙上时钟漂移。
const sensitiveCandidatePoolSQL = `SELECT COUNT(*) FROM memory_items
WHERE status = ? AND scope IN (?, ?) AND sensitive = 0`
// BackfillSensitive 按 privateFacts 白名单给 memory_items 打敏感标记。
//
// dryRun=true 只统计不写库(生产执行的第 1 步:先在备份上核对 Matched)。
// 返回的报告同时是回填验收证据(见 SensitiveBackfillReport 各字段)。
// 调用前 `sensitive` 列必须已存在(NewStore 或迁移 0028 负责补列)。
func BackfillSensitive(ctx context.Context, db *sql.DB, dryRun bool) (SensitiveBackfillReport, error) {
report := SensitiveBackfillReport{DryRun: dryRun, ByPhrase: map[string]int{}}
clause, args := privateMatchClause()
before, err := countSharedCandidates(ctx, db)
if err != nil {
return report, err
}
report.CandidatesBefore = before
if err := countByPhrase(ctx, db, &report); err != nil {
return report, err
}
if report.Matched, err = countWhere(ctx, db, clause, args); err != nil {
return report, err
}
alreadyArgs := append(append([]any{}, args...), 1)
if report.AlreadyFlagged, err = countWhere(ctx, db, clause+" AND sensitive = ?", alreadyArgs); err != nil {
return report, err
}
if !dryRun && report.Matched > report.AlreadyFlagged {
marked, err := markSensitive(ctx, db, clause, args)
if err != nil {
return report, err
}
report.Marked = marked
}
if report.CandidatesAfter, err = countSharedCandidates(ctx, db); err != nil {
return report, err
}
return report, nil
}
// privateMatchClause 把白名单翻成 SQL 条件(`instr(content, ?) > 0` 的 OR 组合)与
// 参数。用占位符而不是把短语拼进 SQL:白名单是代码常量,但把「判据字符串」
// 与「SQL 文本」分开,才不会有人为了加一条短语去改 SQL 结构。
func privateMatchClause() (string, []any) {
parts := make([]string, 0, len(privateFacts))
args := make([]any, 0, len(privateFacts))
for _, f := range privateFacts {
parts = append(parts, "instr(content, ?) > 0")
args = append(args, f.Phrase)
}
return "(" + strings.Join(parts, " OR ") + ")", args
}
// countSharedCandidates 数受限车道的候选池规模(SQL 与口径见 sensitiveCandidatePoolSQL)。
func countSharedCandidates(ctx context.Context, db *sql.DB) (int, error) {
var n int
err := db.QueryRowContext(ctx, sensitiveCandidatePoolSQL,
string(tidyStatusActive), string(ScopeUser), string(ScopeProject)).Scan(&n)
if err != nil {
return 0, fmt.Errorf("memory: count shared candidates: %w", err)
}
return n, nil
}
// countByPhrase 逐条短语统计命中行数,填进 report.ByPhrase(键为 Category)。
func countByPhrase(ctx context.Context, db *sql.DB, report *SensitiveBackfillReport) error {
for _, f := range privateFacts {
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE instr(content, ?) > 0`,
f.Phrase).Scan(&n); err != nil {
return fmt.Errorf("memory: count private fact %s: %w", f.Category, err)
}
report.ByPhrase[f.Category] = n
}
return nil
}
// countWhere 数满足 where 的行数。
func countWhere(ctx context.Context, db *sql.DB, where string, args []any) (int, error) {
var n int
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE `+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("memory: count sensitive candidates: %w", err)
}
return n, nil
}
// markSensitive 执行回填 UPDATE,返回实际改动的行数。`sensitive = 0` 前置条件
// 与「不动 updated_at」是本函数的两条硬要求(理由见文件头注)。
func markSensitive(ctx context.Context, db *sql.DB, clause string, args []any) (int, error) {
res, err := db.ExecContext(ctx,
`UPDATE memory_items SET sensitive = 1 WHERE sensitive = 0 AND `+clause, args...)
if err != nil {
return 0, fmt.Errorf("memory: backfill sensitive: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("memory: backfill sensitive row count: %w", err)
}
return int(n), nil
}
@@ -0,0 +1,162 @@
package memory
import (
"context"
"database/sql"
"testing"
)
// 本文件钉住**历史数据回填**(sensitive_backfill.go)的四条:
//
// 1. dry-run 一行都不写(生产执行的第 1 步是在备份上核对数量,写坏就没法核对);
// 2. 幂等:连跑两次,第二次 Marked=0、AlreadyFlagged=Matched;
// 3. 只动 sensitive,**不动 updated_at**(否则一次打标会扰动「最新在前」的排序与
// 衰减基准);
// 4. 报告的候选池前后对比与白名单逐短语计数可用于验收(回填前后受限召回候选数)。
//
// 历史行在这里用**裸 SQL** 造(只给必填列),sensitive 走列默认值 0 —— 这正是
// 0028 之前落库的行的形态;若改用 Store.Add 造,写入侧兜底会先把它们标成 1,
// 就考不出「回填有没有生效」了。
// seedLegacySensitiveRow 以 0028 之前的形态写一行(不列 sensitive)。
func seedLegacySensitiveRow(t *testing.T, d *sql.DB, id, scope, content string) {
t.Helper()
if _, err := d.ExecContext(context.Background(), `INSERT INTO memory_items
(id, persona_id, scope, kind, content, created_at, updated_at, status)
VALUES (?, 'persona.a', ?, 'relationship', ?, `+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'active')`,
id, scope, content); err != nil {
t.Fatalf("seed legacy row %s: %v", id, err)
}
}
// readSensitiveCell 直读 sensitive 列(不走 scanItem:这里要验的是库里那一格)。
func readSensitiveCell(t *testing.T, d *sql.DB, id string) int {
t.Helper()
var v int
if err := d.QueryRowContext(context.Background(),
`SELECT sensitive FROM memory_items WHERE id = ?`, id).Scan(&v); err != nil {
t.Fatalf("read sensitive of %s: %v", id, err)
}
return v
}
// seedSensitiveFixture 造一份「0028 之前的库」:两条私事(user / project 各一)+
// 一条习惯 + 一条角色日常私事。
func seedSensitiveFixture(t *testing.T, s *Store) {
t.Helper()
seedLegacySensitiveRow(t, s.db, "legacy-addr", string(ScopeUser), "用户居住在花溪(贵阳花溪区)")
seedLegacySensitiveRow(t, s.db, "legacy-name", string(ScopeProject), "称呼约定:主人只叫「叶酱」")
seedLegacySensitiveRow(t, s.db, "legacy-habit", string(ScopeUser), "用户喜欢冰美式")
seedLegacySensitiveRow(t, s.db, "legacy-persona", string(ScopePersona), "主人常熬夜,助手提醒早睡")
}
// TestBackfillSensitive_干跑不写库 钉住 dry-run 的零副作用。
func TestBackfillSensitive_干跑不写库(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
seedSensitiveFixture(t, s)
report, err := BackfillSensitive(ctx, s.db, true)
if err != nil {
t.Fatalf("BackfillSensitive(dry-run): %v", err)
}
if report.Marked != 0 {
t.Fatalf("dry-run 的 Marked = %d, want 0(干跑一行都不许写)", report.Marked)
}
if report.Matched != 3 {
t.Fatalf("Matched = %d, want 3(花溪 / 叶酱 / 早睡 各一条)", report.Matched)
}
for _, id := range []string{"legacy-addr", "legacy-name", "legacy-persona", "legacy-habit"} {
if got := readSensitiveCell(t, s.db, id); got != 0 {
t.Fatalf("dry-run 之后 %s 的 sensitive = %d, want 0", id, got)
}
}
}
// TestBackfillSensitive_幂等且不动updatedAt 是回填的主用例:标对、标全、
// 重跑无副作用、且不碰 updated_at。
func TestBackfillSensitive_幂等且不动updatedAt(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
seedSensitiveFixture(t, s)
report, err := BackfillSensitive(ctx, s.db, false)
if err != nil {
t.Fatalf("BackfillSensitive: %v", err)
}
if report.Marked != 3 {
t.Fatalf("Marked = %d, want 3", report.Marked)
}
if report.Matched != 3 || report.AlreadyFlagged != 0 {
t.Fatalf("Matched/AlreadyFlagged = %d/%d, want 3/0(首跑前没有任何一行已标)",
report.Matched, report.AlreadyFlagged)
}
for _, id := range []string{"legacy-addr", "legacy-name", "legacy-persona"} {
if got := readSensitiveCell(t, s.db, id); got != 1 {
t.Fatalf("%s 的 sensitive = %d, want 1", id, got)
}
}
if got := readSensitiveCell(t, s.db, "legacy-habit"); got != 0 {
t.Fatalf("习惯行被误标 sensitive = %d, want 0", got)
}
// updated_at 必须一个字节都没变(打标不是活跃度事件)。
var updated string
if err := s.db.QueryRowContext(ctx,
`SELECT updated_at FROM memory_items WHERE id = 'legacy-addr'`).Scan(&updated); err != nil {
t.Fatalf("read updated_at: %v", err)
}
if updated != "2026-10-01T10:00:00.000000000Z" {
t.Fatalf("回填改写了 updated_at = %q(不许动它:会扰动排序与衰减基准)", updated)
}
// 幂等:重跑只补标。
again, err := BackfillSensitive(ctx, s.db, false)
if err != nil {
t.Fatalf("BackfillSensitive(第二次): %v", err)
}
if again.Marked != 0 || again.Matched != 3 || again.AlreadyFlagged != 3 {
t.Fatalf("重跑 = Marked %d / Matched %d / Already %d, want 0/3/3(必须幂等)",
again.Marked, again.Matched, again.AlreadyFlagged)
}
}
// TestBackfillSensitive_候选池收窄 是验收口径:受限车道候选池(active 且
// scope ∈ {user, project} 且 sensitive=0)在回填后按标记条数下降。
func TestBackfillSensitive_候选池收窄(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
seedSensitiveFixture(t, s)
before := mustCandidateCount(t, ctx, s)
report, err := BackfillSensitive(ctx, s.db, false)
if err != nil {
t.Fatalf("BackfillSensitive: %v", err)
}
after := mustCandidateCount(t, ctx, s)
if report.CandidatesBefore != before || report.CandidatesAfter != after {
t.Fatalf("报告候选池 = %d→%d, 实际 = %d→%d(报告必须能当验收证据)",
report.CandidatesBefore, report.CandidatesAfter, before, after)
}
// 前后各 3 条共享域行(2 user + 1 project),回填标掉 2 条(花溪 / 叶酱)。
if before != 3 || after != 1 {
t.Fatalf("候选池 = %d→%d, want 3→1", before, after)
}
if report.ByPhrase["住址"] != 1 || report.ByPhrase["私称"] != 1 || report.ByPhrase["作息"] != 1 {
t.Fatalf("逐短语计数 = %v, want 每条各 1", report.ByPhrase)
}
}
// mustCandidateCount 取受限车道候选池规模(与回填报告同一个查询)。
func mustCandidateCount(t *testing.T, ctx context.Context, s *Store) int {
t.Helper()
n, err := countSharedCandidates(ctx, s.db)
if err != nil {
t.Fatalf("count shared candidates: %v", err)
}
return n
}
@@ -0,0 +1,188 @@
package memory
import (
"context"
"slices"
"testing"
)
// 本文件钉住**迁移 0028(`memory_sensitive`)与 Store 自举 DDL 的同形、可回滚、
// 以及「迁移里的白名单回填」与 Go 侧回填判据等价**。手法照 retention_migration_test.go
// 对 0026、temporal_migration_test.go 对 0027 的那一套(同一批 helpers)。
//
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
// 真源:迁移(权威)与 NewStore 的幂等前滚(sensitive_schema.go)。少一列就是
// 「新库有、老库无」的漂移,因此三条判据都必须有:
//
// 1. TestMigration0028AddsColumnAndBackfills —— 迁移落在**已有数据**上:行不丢、
// 列补上、白名单命中的历史行被标成 sensitive=1、非白名单行保持 0;
// 2. TestMigration0028DownDropsColumnOnly —— down 只撤结构(行数与其它数据不变);
// 3. TestMigration0028ShapeMatchesStoreBootstrap —— 两份 DDL 的列集合与列形状同形,
// 且自举侧尾序是 `… temporal, sensitive`。
//
// 第 1 条同时是**白名单等价性**的证据:迁移里那份短语清单与 Go 的 privateFacts
// 若不一致,`BackfillSensitive` 在迁移之后重跑会标出额外的行(Marked > 0)——
// 那条断言把「两份实现悄悄分叉」变成一次红灯。
// sensitiveMigrationID 是 0028 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
const sensitiveMigrationID = "0028_memory_sensitive"
// TestMigration0028AddsColumnAndBackfills 造出「0028 之前的既有部署」再升级:
// 历史行一行不丢、白名单命中的被标、没命中的不被标。
func TestMigration0028AddsColumnAndBackfills(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
if !hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn)
}
// 回到 0028 之前,按旧形态写四行(花溪 / 叶酱 / 早睡 / 习惯)。
migrateDownToBefore(t, d, sensitiveMigrationID)
if hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("0028 回滚后 %s 应已消失", sensitiveColumn)
}
seedLegacySensitiveRow(t, d, "legacy-1", string(ScopeUser), "用户居住在花溪(贵阳花溪区)")
seedLegacySensitiveRow(t, d, "legacy-2", string(ScopePersona), "主人希望被称作「叶酱」")
seedLegacySensitiveRow(t, d, "legacy-3", string(ScopeProject), "主人常熬夜,助手提醒早睡")
seedLegacySensitiveRow(t, d, "legacy-4", string(ScopeUser), "用户喜欢冰美式")
// 升级:0028 落在**已有数据**上(建列 + 回填)。
migrateUp(t, d)
if !hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn)
}
var rows int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
t.Fatalf("count rows: %v", err)
}
if rows != 4 {
t.Fatalf("迁移后行数 = %d, want 4(既有行一行都不能丢)", rows)
}
for _, id := range []string{"legacy-1", "legacy-2", "legacy-3"} {
if got := readSensitiveCell(t, d, id); got != 1 {
t.Fatalf("%s 回填后 sensitive = %d, want 1(白名单命中必须被标)", id, got)
}
}
if got := readSensitiveCell(t, d, "legacy-4"); got != 0 {
t.Fatalf("legacy-4(习惯类)被标成敏感 = %d —— 迁移把「用户习惯可查」推翻了", got)
}
// 白名单等价性:迁移已经标完,Go 侧回填不该再标出一行。
report, err := BackfillSensitive(ctx, d, false)
if err != nil {
t.Fatalf("BackfillSensitive(迁移之后): %v", err)
}
if report.Marked != 0 {
t.Fatalf("迁移之后 Go 侧回填又标了 %d 行 —— 迁移里的短语清单与 privateFacts 已经分叉",
report.Marked)
}
if report.Matched != 3 {
t.Fatalf("Matched = %d, want 3(两条实现的判据必须一致)", report.Matched)
}
}
// TestMigration0028DownDropsColumnOnly 钉住 down 的唯一语义:只撤结构。
func TestMigration0028DownDropsColumnOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
seedLegacySensitiveRow(t, d, "keep-1", string(ScopeUser), "用户居住在花溪")
if _, err := d.ExecContext(ctx,
`UPDATE memory_items SET sensitive = 1 WHERE id = 'keep-1'`); err != nil {
t.Fatalf("mark row: %v", err)
}
migrateDownToBefore(t, d, sensitiveMigrationID)
if hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("回滚后 %s 应已消失", sensitiveColumn)
}
var rows int
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
t.Fatalf("count rows after down: %v", err)
}
if rows != 1 {
t.Fatalf("回滚后行数 = %d, want 1(down 只撤结构,不删行)", rows)
}
var content, scope string
if err := d.QueryRowContext(ctx,
`SELECT content, scope FROM memory_items WHERE id = 'keep-1'`).Scan(&content, &scope); err != nil {
t.Fatalf("read row after down: %v", err)
}
if content != "用户居住在花溪" || scope != string(ScopeUser) {
t.Fatalf("down 改写了行数据 = (%q, %q) —— 回滚只撤结构", content, scope)
}
// 再 up:列以默认 0 重建,回填重新按白名单标上。
migrateUp(t, d)
if !hasMemoryColumn(t, d, sensitiveColumn) {
t.Fatalf("再次迁移后应重建 %s 列", sensitiveColumn)
}
if got := readSensitiveCell(t, d, "keep-1"); got != 1 {
t.Fatalf("再 up 之后 sensitive = %d, want 1(回填随迁移重跑)", got)
}
}
// TestMigration0028ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移路径与自举路径
// 建出的 memory_items 列集合必须一致,且 `sensitive` 在两边的形状逐字相同
// (INTEGER NOT NULL DEFAULT 0)。
func TestMigration0028ShapeMatchesStoreBootstrap(t *testing.T) {
t.Parallel()
ctx := context.Background()
migrated := openRawMemoryDB(t)
migrateUp(t, migrated)
if _, err := NewStore(ctx, migrated, testLogger()); err != nil {
t.Fatalf("NewStore(migrated): %v", err)
}
bootstrapped := openRawMemoryDB(t)
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
t.Fatalf("NewStore: %v", err)
}
gotCols := sortedColumns(tableColumns(t, migrated, "memory_items"))
wantCols := sortedColumns(tableColumns(t, bootstrapped, "memory_items"))
if !slices.Equal(gotCols, wantCols) {
t.Fatalf("迁移与自举的 memory_items 列集合不一致(两份 DDL 必须同形):\n迁移=%v\n自举=%v",
gotCols, wantCols)
}
for name, cols := range map[string][]string{"迁移": gotCols, "自举": wantCols} {
if !slices.Contains(cols, sensitiveColumn+"|INTEGER|1|0|0") {
t.Fatalf("%s 侧的 %s 列形状不是 INTEGER NOT NULL DEFAULT 0:%v", name, sensitiveColumn, cols)
}
}
}
// TestMigration0028ThenStoreBootstrapIsARegularPath 覆盖真实启动顺序:迁移先建列
// (含回填),随后 Store 自举走到 "duplicate column name" —— 预期路径必须无害,
// 且写入路径可用(敏感位随同一笔 INSERT 落下)。
func TestMigration0028ThenStoreBootstrapIsARegularPath(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
store, err := NewStore(ctx, d, testLogger())
if err != nil {
t.Fatalf("NewStore(列已由迁移建好): %v", err)
}
after := tableColumns(t, d, "memory_items")
if _, err := NewStore(ctx, d, testLogger()); err != nil {
t.Fatalf("NewStore(第二次): %v", err)
}
if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) {
t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after)
}
item := habitItem("主人现居贵阳花溪区", tidyTestTime(t), 0.9)
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add: %v", err)
}
if cell := readSensitiveCell(t, store.db, got.ID); cell != 1 {
t.Fatalf("迁移建列后经 Store 落库的 sensitive = %d, want 1(写入侧兜底没跑)", cell)
}
}
@@ -0,0 +1,131 @@
package memory
import (
"context"
"strings"
"testing"
)
// 本文件是审计 §9-4 明确要求补的**对抗用例**:群里有人直接问「她住哪」时,主人
// 私事**不许进受限车道的候选池**(审计原话:这类提问会把花溪类记忆直接顶到 topK,
// 风险高于随机触发)。
//
// 🔴 它同时钉住两个方向,缺一个就不算修好:
// - 受限车道(群聊 / 陌生人)的可见集里**没有**私事;
// - 主人车道(同一次检索,不带限制)**照常看得到** —— sensitive 是数据层标记,
// 不是权限系统(塔裁决 2026-10-30)。
//
// ⚠️ 这里的受限可见集用 `MemoryItem.SharedVisible()` 复现 —— 它正是
// `internal/runtime/loop/memory_filter.go` 的 FilterSharedVisible 该用的判据
// (loop 侧把 `sharedScope(r.Item.Scope)` 换成 `r.Item.SharedVisible()` 即完成接线,
// 那一行在 loop 包,不在本切片的 scope 内)。本文件因此保证的是**数据侧判据**;
// 「loop 真的接了线」由 loop 的用例与端到端回放兜底。
// restrictedVisible 复现受限车道过滤:只保留 SharedVisible 的命中。
// (loop 侧还有一条「剔除 L4 意图记录」,与本片无关,故不在此复现。)
func restrictedVisible(results []Result) []Result {
visible := make([]Result, 0, len(results))
for _, r := range results {
if !r.Item.SharedVisible() {
continue
}
visible = append(visible, r)
}
return visible
}
// contents 把命中拼成一段文本,供 Contains 断言。
func contents(results []Result) string {
var b strings.Builder
for _, r := range results {
b.WriteString(r.Item.Content)
b.WriteByte('\n')
}
return b.String()
}
// TestRestrictedRecall_群里问住址拿不到私事 是对抗主用例:query 刻意写成群里那句
// 「她住哪」的直白形态(上海 / 住在哪 / 花溪),让私事记忆在**排序上**占优 ——
// 这样才能证明挡住它的是敏感标记而不是相关度不够。
func TestRestrictedRecall_群里问住址拿不到私事(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindWorldEvent,
Content: "用户居住在花溪(贵阳花溪区),不在上海工作",
})
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship,
Content: "用户喜欢冰美式",
})
addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeProject, Kind: KindWorldEvent,
ProjectID: "proj-1", Content: "Cyrene 用 Go 编写,编号规则 CY-日期-序号",
})
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "她住在哪里?是上海还是花溪?", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
// 前提:这条私事确实在候选池里、且排得进前 K —— 否则下面的断言是空转。
if !strings.Contains(contents(results), "花溪") {
t.Fatalf("主人车道读不到这条私事,用例前提不成立:%s", contents(results))
}
restricted := contents(restrictedVisible(results))
if strings.Contains(restricted, "花溪") {
t.Fatalf("受限车道拿到了住址:\n%s", restricted)
}
if !strings.Contains(restricted, "冰美式") {
t.Fatalf("受限车道把合法的用户习惯也挡掉了(冻结裁决 design/03 §5 被推翻):\n%s", restricted)
}
if !strings.Contains(restricted, "Cyrene 用 Go 编写") {
t.Fatalf("受限车道丢了项目域记忆:\n%s", restricted)
}
}
// TestRestrictedRecall_主人车道照常读到全部 钉住「sensitive 不是权限系统」:
// 同一个 Store、同一批条目,不经受限过滤的读路径(owner 车道走的就是这条)
// 必须一条不少地读到,含敏感项。判据一旦被写成权限,这条立刻红。
func TestRestrictedRecall_主人车道照常读到全部(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
stored := addItem(t, s, MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindWorldEvent,
Content: "主人希望被称呼为「叶酱」",
})
if !stored.Sensitive {
t.Fatal("私称没有被标成敏感,用例前提不成立")
}
byID, err := s.Get(ctx, stored.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !byID.Sensitive || !strings.Contains(byID.Content, "叶酱") {
t.Fatal("主人车道的按 id 读取被敏感标记挡住了 —— sensitive 变成了权限")
}
personaItems, err := s.ListByPersona(ctx, "persona.a")
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
if !containsSensitive(personaItems, stored.ID) {
t.Fatal("主人车道的列表读路径读不到这条(或被读成非敏感)")
}
// 检索路径同样不因敏感而剔除:owner 车道的 Retrieve 结果里必须有它。
r := NewRetriever(s)
results, err := r.Retrieve(ctx, "persona.a", "主人希望被怎么称呼?叶酱吗?", 10)
if err != nil {
t.Fatalf("Retrieve: %v", err)
}
if !strings.Contains(contents(results), "叶酱") {
t.Fatalf("主人车道的检索丢了敏感项:\n%s", contents(results))
}
}
@@ -0,0 +1,46 @@
package memory
import (
"context"
"database/sql"
"fmt"
"strings"
)
// 本文件是「敏感标记」轴(迁移 0028)的 schema 前滚:给**既有**的 memory_items
// 补 `sensitive` 列。手法与 retention_schema.go / temporal_schema.go 逐字同族,
// 原因也一样:`createMemoryItemsTable` 是 `CREATE TABLE IF NOT EXISTS`,只对
// **新库**生效,既有部署的表不会自己长出新列;而记忆存储必须自包含、能在任意
// *sql.DB 上使用,所以这条前滚不能只靠 `internal/db/migrations`。
//
// 🔴 与迁移的分工(两份真源,必须同形):权威机制是
// `internal/db/migrations/0028_memory_sensitive`;这里是「任意 *sql.DB」的前滚。
// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里
// 迁移先落地、这里走到 `duplicate column name` 是**预期路径**。
//
// 默认值 0(= 非敏感):既有行因此**行为逐字不变**(受限车道照旧看得到它们),
// 本前滚刻意**不做**任何回填判定 —— 回填是独立的一次性数据动作
// (`BackfillSensitive`,sensitive_backfill.go):把「结构前滚」与「给历史数据
// 打标」分成两步,才能使两者的验收互不牵连(前滚永远幂等且零副作用;回填可
// dry-run、可重跑、可只跑一次)。
//
// 本列**不建索引**:受限车道的过滤在内存里做(候选集本来就已 `status` + 时效 +
// scope 过滤过,行数在千级),而「按 sensitive 取样巡检」是人工低频查询。将来
// 若真出现按它筛的读路径,两条真源(迁移与本文件)一起加。
// sensitiveColumn 是被测列名(迁移与自举 DDL 里的字面量)。
const sensitiveColumn = "sensitive"
// ensureSensitiveSchema 幂等地给 memory_items 补上敏感标记列(列缺失时 ALTER,
// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。
func ensureSensitiveSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN sensitive INTEGER NOT NULL DEFAULT 0`)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward sensitive column: %w", err)
}
+170
View File
@@ -0,0 +1,170 @@
package memory
import (
"context"
"testing"
)
// 本文件钉住「敏感标记」这根轴(迁移 0028,切片 M63)在**数据层**的四条:
//
// 1. 白名单判据的方向性:已实证私事命中、习惯类不命中(审计 §2 的实测短语);
// 2. SharedVisible 是作用域与敏感度的合取(受限车道唯一判据);
// 3. 写入侧兜底:白名单命中的内容经 Store.Add 落库后 sensitive=1,且**只加不撤**;
// 4. 15 列读回路:sensitive 在四条列表读路径(Get / ListByPersona / ListScope /
// List / ListProject)上都读得回来 —— 少同步一处 SELECT,这里就红。
//
// 删掉实现(sensitive 列、resolveSensitive、scanItem 的第 15 列、SharedVisible 的
// 判据)会分别让 1/2/3/4 变红;这正是本文件存在的意义。
// TestIsPrivateContent_判据方向 钉住白名单的两个方向:主人的私事命中,习惯与
// 普通词不命中。反向用例(「住手」「喜欢睡懒觉的猫」)是**刻意**的:泛关键词
// (住 / 睡)会把它们一起标掉,那正是本片拒绝的误标方向(sensitive.go 理由 2)。
func TestIsPrivateContent_判据方向(t *testing.T) {
t.Parallel()
for _, content := range []string{
"用户不在上海上班,目前所在城市为花溪(贵阳花溪区一带)",
"主人希望被称呼为「叶酱」",
"主人有上班日程,工作日需要早起,助手提醒早睡",
} {
if !IsPrivateContent(content) {
t.Fatalf("IsPrivateContent(%q) = false, want true(已实证私事必须命中)", content)
}
}
for _, content := range []string{
"用户喜欢冰美式",
"住手!不要再改了",
"用户喜欢睡懒觉的猫,周末会看猫的视频",
"Cyrene 用 Go 编写,项目编号规则是 CY-日期-序号",
} {
if IsPrivateContent(content) {
t.Fatalf("IsPrivateContent(%q) = true, want false(习惯与普通词不许误标)", content)
}
}
}
// TestSharedVisible_作用域与敏感度合取 钉住受限车道唯一判据的矩阵:
// 只有「共享域 且 未标敏感」才可注入;persona 域无论标没标都不可注入
// (它本来就是主人私有的角色日常,design/03 §3/§5)。
func TestSharedVisible_作用域与敏感度合取(t *testing.T) {
t.Parallel()
cases := []struct {
name string
item MemoryItem
want bool
}{
{"user+非敏感", MemoryItem{Scope: ScopeUser}, true},
{"project+非敏感", MemoryItem{Scope: ScopeProject}, true},
{"user+敏感", MemoryItem{Scope: ScopeUser, Sensitive: true}, false},
{"project+敏感", MemoryItem{Scope: ScopeProject, Sensitive: true}, false},
{"persona+非敏感", MemoryItem{Scope: ScopePersona}, false},
{"persona+敏感", MemoryItem{Scope: ScopePersona, Sensitive: true}, false},
}
for _, c := range cases {
if got := c.item.SharedVisible(); got != c.want {
t.Fatalf("%s: SharedVisible() = %v, want %v", c.name, got, c.want)
}
}
}
// TestAdd_私事按白名单补标 钉住写入侧兜底:模型(或人工调用方)漏标时,白名单
// 命中的内容落库后必须 sensitive=1;而 habit 类内容必须保持 0(否则冻结的
// 「用户习惯可查」裁决会被这次改动悄悄推翻)。
func TestAdd_私事按白名单补标(t *testing.T) {
t.Parallel()
s := newTestStore(t)
private := baseItem()
private.Scope = ScopeUser
private.Content = "用户居住在花溪(贵阳花溪区),不在上海工作"
storedPrivate := addItem(t, s, private)
if !storedPrivate.Sensitive {
t.Fatal("白名单命中的条目落库后 Sensitive = false, want true(写入侧兜底没生效)")
}
habit := baseItem()
habit.Scope = ScopeUser
habit.Content = "用户喜欢冰美式"
storedHabit := addItem(t, s, habit)
if storedHabit.Sensitive {
t.Fatal("习惯类条目被误标成敏感 —— 受限车道会平白丢掉合法习惯")
}
// 只加不撤:调用方显式标 true 的习惯类内容必须保持 true(撤标是人工动作)。
explicit := baseItem()
explicit.Scope = ScopeUser
explicit.Content = "用户喜欢手冲咖啡"
explicit.Sensitive = true
if got := addItem(t, s, explicit); !got.Sensitive {
t.Fatal("显式 sensitive=true 被改写成 false —— 写入路径不许撤标")
}
}
// TestSensitive_十五条读路径回读 钉住「15 列读回路」:sensitive 必须在每一条
// 走 scanItem 的读路径上都读得回来。任一处 SELECT 少一列,这里 first 就会扫错列
// (报错或把别的列当成 sensitive),不会有静默通过的余地。
func TestSensitive_十五条读路径回读(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newTestStore(t)
personaPrivate := baseItem()
personaPrivate.Content = "主人只愿在私下被叫作「叶酱」"
stored := addItem(t, s, personaPrivate)
userPrivate := baseItem()
userPrivate.Scope = ScopeUser
userPrivate.Content = "主人现居贵阳花溪区,不在上海"
userStored := addItem(t, s, userPrivate)
projectPlain := baseItem()
projectPlain.Scope = ScopeProject
projectPlain.ProjectID = "proj-1"
projectPlain.Content = "Cyrene 用 Go 编写"
projectStored := addItem(t, s, projectPlain)
byID, err := s.Get(ctx, stored.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !byID.Sensitive {
t.Fatal("Get 读回的 sensitive = false, want true(Get 的 SELECT 少一列?)")
}
byPersona, err := s.ListByPersona(ctx, personaPrivate.PersonaID)
if err != nil {
t.Fatalf("ListByPersona: %v", err)
}
if !containsSensitive(byPersona, stored.ID) {
t.Fatal("ListByPersona 读回的条目丢掉了 sensitive 位")
}
byScope, err := s.ListScope(ctx, ScopeUser, personaPrivate.PersonaID)
if err != nil {
t.Fatalf("ListScope: %v", err)
}
if !containsSensitive(byScope, userStored.ID) {
t.Fatal("ListScope 读回的条目丢掉了 sensitive 位")
}
byProject, err := s.ListProject(ctx, "proj-1")
if err != nil {
t.Fatalf("ListProject: %v", err)
}
if containsSensitive(byProject, projectStored.ID) {
t.Fatal("ListProject 把非敏感的条目读成了敏感")
}
all, err := s.List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if !containsSensitive(all, stored.ID) || containsSensitive(all, projectStored.ID) {
t.Fatal("List 读回的 sensitive 位与落库值不一致")
}
}
// containsSensitive 报告 items 中 id 那条是否为 sensitive(找不到即失败)。
func containsSensitive(items []MemoryItem, id string) bool {
for _, it := range items {
if it.ID == id {
return it.Sensitive
}
}
return false
}
+36 -49
View File
@@ -29,9 +29,7 @@ package memory
import ( import (
"context" "context"
"crypto/rand"
"database/sql" "database/sql"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -124,7 +122,7 @@ type MemoryItem struct {
// - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go), // - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go),
// 故**从库里读回来的**条目 ExpiresAt 恒为零值。 // 故**从库里读回来的**条目 ExpiresAt 恒为零值。
// //
// 要读回时效需同步 14 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回 // 要读回时效需同步 15 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回
// SQL —— 那是改扫描口径,不在本切片授权范围。 // SQL —— 那是改扫描口径,不在本切片授权范围。
ExpiresAt time.Time ExpiresAt time.Time
@@ -146,10 +144,20 @@ type MemoryItem struct {
// //
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列, // ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点 // 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,共 14→15 列), // (rules.go / store_list.go / tidy_store.go / preinsert_store.go,现为 15 列,
// 那是另一件事,不在本片授权范围。要按时间性位巡检/回填时直读该列即可; // 加它就是 16 列),那是另一件事,不在本片授权范围。要按时间性位巡检/回填时
// 写入侧的校验/编码见 store_temporal.go。 // 直读该列即可;写入侧的校验/编码见 store_temporal.go。
Temporal TemporalKind Temporal TemporalKind
// Sensitive 是**敏感标记**(迁移 0028、sensitive.go):true = 这条记忆是主人的
// 私事(住址 / 私称 / 作息 ……),因此**受限车道**(群聊 / 陌生人)的召回过滤器
// 会把整条排除在候选池外。
//
// 🔴 它是**数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部记忆
// (含 sensitive=1),没有任何读路径因它拒绝主人;它只影响
// `MemoryItem.SharedVisible` 这一条判据。谁是主人仍只由 actor 元数据 + 白名单
// + 策略引擎决定(AGENTS §9)。判据与理由见 sensitive.go 头注。
Sensitive bool
} }
// 区分 memory 包各类失败的哨兵错误。每个都在 // 区分 memory 包各类失败的哨兵错误。每个都在
@@ -265,6 +273,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if err := ensureTemporalSchema(ctx, db); err != nil { if err := ensureTemporalSchema(ctx, db); err != nil {
return nil, err return nil, err
} }
// 敏感标记(迁移 0028):同上,只补列、不回填 —— 历史数据的打标是独立的一次性
// 动作(BackfillSensitive),使结构前滚保持零副作用(见 sensitive_schema.go)。
if err := ensureSensitiveSchema(ctx, db); err != nil {
return nil, err
}
if err := normalizeMemoryTimestamps(ctx, db); err != nil { if err := normalizeMemoryTimestamps(ctx, db); err != nil {
return nil, err return nil, err
} }
@@ -309,6 +322,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
// 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**—— // 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**——
// 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线 // 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线
// 的唯一证据来源,见 temporal.go 头注的查询)。 // 的唯一证据来源,见 temporal.go 头注的查询)。
//
// 🆕 切片 M63:`item.Sensitive`(敏感标记,迁移 0028)也随**这一笔** INSERT 落下。
// 它**不参与任何判定**:档位 / 时效 / 读路径的可见性集合与「没有这一位」逐字相同
// —— 唯一的消费者是受限车道的过滤判据(sensitive.go 的 SharedVisible),而主人
// 车道根本不过那道判据。写入侧另有一条**白名单兜底**(见下面 Add 内的判定)。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) { func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil { if err := item.normalize(); err != nil {
return MemoryItem{}, err return MemoryItem{}, err
@@ -317,6 +335,16 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if item, err = applyTimestamps(item); err != nil { if item, err = applyTimestamps(item); err != nil {
return MemoryItem{}, err return MemoryItem{}, err
} }
// 🆕 切片 M63(迁移 0028):敏感标记的**写入侧兜底** —— 判据靠代码,不靠提示词
// (AGENTS §9)。提取器的 `sensitive` 位是**类目级**判断(新私事的措辞无法穷举,
// 见 llmextractor.go 的正反例);这里是**已实证短语**的确定性兜底
// (IsPrivateContent,判据与理由见 sensitive.go)。合成只在 resolveSensitive
// 一处做(「或」,只加标不撤标),提取器产出侧与这里走同一个函数。
if resolved := resolveSensitive(item.Sensitive, item.Content); resolved != item.Sensitive {
item.Sensitive = resolved
s.log.Debug("memory: sensitive flag set by private-fact whitelist",
"id", item.ID, "scope", string(item.Scope))
}
// 🔴 档位判定(唯一入口):把结果写回条目,闸与异步审查因此**只读**这一位, // 🔴 档位判定(唯一入口):把结果写回条目,闸与异步审查因此**只读**这一位,
// 不再各自跑一遍留存规则(见 preinsert_judge.go 的 defer 分支)。 // 不再各自跑一遍留存规则(见 preinsert_judge.go 的 defer 分支)。
verdict, err := resolveRetention(item) verdict, err := resolveRetention(item)
@@ -337,18 +365,8 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
plan := s.planIngest(ctx, item) plan := s.planIngest(ctx, item)
item.ExpiresAt = plan.ExpiresAt item.ExpiresAt = plan.ExpiresAt
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items if err := s.insertItem(ctx, item, tagsJSON, plan); err != nil {
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence, return MemoryItem{}, err
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
expires_at, retention_tier, temporal)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(),
item.temporalColumn())
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
} }
s.finishIngest(ctx, item, plan) s.finishIngest(ctx, item, plan)
if plan.Action != ingestPlanAbsorbNew { if plan.Action != ingestPlanAbsorbNew {
@@ -460,37 +478,6 @@ func (it *MemoryItem) validate() error {
return nil return nil
} }
// applyTimestamps 分配 id 与缺失的 CreatedAt/UpdatedAt 值。
// 仅在无法生成新 id 时返回错误。
func applyTimestamps(item MemoryItem) (MemoryItem, error) {
now := time.Now().UTC()
if item.ID == "" {
id, err := newID()
if err != nil {
return MemoryItem{}, err
}
item.ID = id
}
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
if item.UpdatedAt.IsZero() {
item.UpdatedAt = now
}
return item, nil
}
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
// UUID 依赖,使本包保持自包含。rand.Read 只在
// 平台熵源损坏时失败,因此错误向上传播。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("memory: generate id: %w", err)
}
return hex.EncodeToString(b), nil
}
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。 // discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
func discardLogger(log *slog.Logger) *slog.Logger { func discardLogger(log *slog.Logger) *slog.Logger {
if log != nil { if log != nil {
+76
View File
@@ -0,0 +1,76 @@
package memory
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
)
// 本文件是 memory_items 的**写入侧行装配**(唯一 INSERT 语句、id 与时间戳分配),
// 从 store.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)与「模块按职责拆
// 源文件」那条规范 —— 口径逐字未变。
//
// 与 store_rows.go(读侧行映射)成对:那里是「列 → MemoryItem」,这里是
// 「MemoryItem → 列」。两边的列序必须**同一套**:scanItem 读 15 列,
// 本文件的 INSERT 也写 15 列(`… temporal, sensitive` 收尾)。
// insertMemoryItemSQL 是全仓唯一的一条 memory_items INSERT。列序与 store_rows.go
// 的 scanItem 同源(同一张表的同一套列),新增列一律追加在末尾。
//
// 🔴 本语句**不经** `status` / `expires_at` / `retention_tier` / `temporal` /
// `sensitive` 的默认值路径:前四者由 `ingestGatePlan` 与条目自身给出(档位与时效
// 是**同一个判定**的产物,见 store.go 的 Add),`sensitive` 由条目给出(提取器判据
// + 白名单兜底,见 sensitive.go),一句话都写死在这里,别处不再拼第二份。
const insertMemoryItemSQL = `INSERT INTO memory_items
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
source_conversation, created_at, updated_at, decay_weight, status, merged_into,
expires_at, retention_tier, temporal, sensitive)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
// insertItem 落一行 memory_items。调用方(Add)负责在此前完成规范化、时间戳与
// 档位判定;这里只做「条目 → 列」的映射。
func (s *Store) insertItem(ctx context.Context, item MemoryItem, tagsJSON []byte, plan ingestGatePlan) error {
_, err := s.db.ExecContext(ctx, insertMemoryItemSQL,
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
plan.insertStatus(), plan.absorbedID(), plan.expiresAt(), plan.retention(),
item.temporalColumn(), item.sensitiveColumn())
if err != nil {
return fmt.Errorf("memory: insert item: %w", err)
}
return nil
}
// applyTimestamps 分配 id 与缺失的 CreatedAt/UpdatedAt 值。
// 仅在无法生成新 id 时返回错误。
func applyTimestamps(item MemoryItem) (MemoryItem, error) {
now := time.Now().UTC()
if item.ID == "" {
id, err := newID()
if err != nil {
return MemoryItem{}, err
}
item.ID = id
}
if item.CreatedAt.IsZero() {
item.CreatedAt = now
}
if item.UpdatedAt.IsZero() {
item.UpdatedAt = now
}
return item, nil
}
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
// UUID 依赖,使本包保持自包含。rand.Read 只在
// 平台熵源损坏时失败,因此错误向上传播。
func newID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("memory: generate id: %w", err)
}
return hex.EncodeToString(b), nil
}
+3 -3
View File
@@ -33,7 +33,7 @@ func (s *Store) Get(ctx context.Context, id string) (MemoryItem, error) {
} }
row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind, row := s.db.QueryRowContext(ctx, `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items WHERE id = ?`, id) decay_weight, retention_tier, sensitive FROM memory_items WHERE id = ?`, id)
item, err := scanItem(row) item, err := scanItem(row)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return MemoryItem{}, ErrNotFound return MemoryItem{}, ErrNotFound
@@ -84,7 +84,7 @@ func (s *Store) listByPersona(ctx context.Context, personaID string, tiers []Ret
now := timeGateNow() now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind, query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items WHERE persona_id = ? AND status = ?` decay_weight, retention_tier, sensitive FROM memory_items WHERE persona_id = ? AND status = ?`
args := []any{personaID, string(tidyStatusActive)} args := []any{personaID, string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers) query, args, err := retentionTierList(query, args, tiers)
if err != nil { if err != nil {
@@ -129,7 +129,7 @@ func (s *Store) listAll(ctx context.Context, tiers []RetentionTier) ([]MemoryIte
now := timeGateNow() now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind, query := `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items WHERE status = ?` decay_weight, retention_tier, sensitive FROM memory_items WHERE status = ?`
args := []any{string(tidyStatusActive)} args := []any{string(tidyStatusActive)}
query, args, err := retentionTierList(query, args, tiers) query, args, err := retentionTierList(query, args, tiers)
if err != nil { if err != nil {
+7 -1
View File
@@ -46,21 +46,27 @@ func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
// retention_tier 是**第 14 列**(迁移 0026):它与 `kind` 是两根独立的轴, // retention_tier 是**第 14 列**(迁移 0026):它与 `kind` 是两根独立的轴,
// 因此这里只做「读出来」,不参与任何推导。空串原样成为 // 因此这里只做「读出来」,不参与任何推导。空串原样成为
// RetentionTierUnset(= 尚未分型),绝不被折成某一档。 // RetentionTierUnset(= 尚未分型),绝不被折成某一档。
//
// sensitive 是**第 15 列**(迁移 0028):SQLite 的 INTEGER 0/1 在这里折回 bool。
// 它被读出来的**唯一**理由是要给受限车道的可见性判据用(SharedVisible)——
// 条目不带这一位,过滤就无从过滤;它不参与任何排名/判定。
func scanItem(sc rowScanner) (MemoryItem, error) { func scanItem(sc rowScanner) (MemoryItem, error) {
var ( var (
it MemoryItem it MemoryItem
scope, kind, tier string scope, kind, tier string
tagsJSON string tagsJSON string
sensitive int
createdAt, updatedAt string createdAt, updatedAt string
) )
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind, if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
&it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation, &it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation,
&createdAt, &updatedAt, &it.DecayWeight, &tier); err != nil { &createdAt, &updatedAt, &it.DecayWeight, &tier, &sensitive); err != nil {
return MemoryItem{}, err return MemoryItem{}, err
} }
it.Scope = Scope(scope) it.Scope = Scope(scope)
it.Kind = Kind(kind) it.Kind = Kind(kind)
it.RetentionTier = RetentionTier(tier) it.RetentionTier = RetentionTier(tier)
it.Sensitive = sensitive != 0
if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil { if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil {
return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err) return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err)
} }
+6 -4
View File
@@ -28,7 +28,8 @@ CREATE TABLE IF NOT EXISTS memory_items (
valid_to TEXT NOT NULL DEFAULT '', valid_to TEXT NOT NULL DEFAULT '',
expires_at TEXT NOT NULL DEFAULT '', expires_at TEXT NOT NULL DEFAULT '',
retention_tier TEXT NOT NULL DEFAULT '', retention_tier TEXT NOT NULL DEFAULT '',
temporal TEXT NOT NULL DEFAULT '' temporal TEXT NOT NULL DEFAULT '',
sensitive INTEGER NOT NULL DEFAULT 0
)` )`
const createMemoryItemsPersonaIndex = ` const createMemoryItemsPersonaIndex = `
@@ -46,9 +47,10 @@ CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at
// (retention_migration_test.go 钉住这条同形,少一列就是「新库有、老库无」的漂移)。 // (retention_migration_test.go 钉住这条同形,少一列就是「新库有、老库无」的漂移)。
// //
// ⚠️ 0027 之后 `retention_tier` **不再是最后一列**:`temporal`(迁移 0027)追加在 // ⚠️ 0027 之后 `retention_tier` **不再是最后一列**:`temporal`(迁移 0027)追加在
// 它之后,两条路径的尾序都是 `… expires_at, retention_tier, temporal`。新列一律 // 它之后,0028 又把 `sensitive` 追加在 `temporal` 之后。两条路径的尾序都是
// 追加在末尾这条口径不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**, // `… expires_at, retention_tier, temporal, sensitive`。新列一律追加在末尾这条口径
// 另外对尾序做一条显式断言)。 // 不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**,另外对尾序做一条
// 显式断言)。
const createMemoryItemsRetentionTierIndex = ` const createMemoryItemsRetentionTierIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier
ON memory_items (retention_tier)` ON memory_items (retention_tier)`
@@ -148,14 +148,17 @@ func TestMigration0027ShapeMatchesStoreBootstrap(t *testing.T) {
} }
} }
bootCols := tableColumns(t, bootstrapped, "memory_items") bootCols := tableColumns(t, bootstrapped, "memory_items")
if len(bootCols) < 2 { if len(bootCols) < 3 {
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 2", len(bootCols)) t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols))
} }
tail := bootCols[len(bootCols)-2:] // 0028(`sensitive`)之后尾序是 `… retention_tier, temporal, sensitive`:
// 新列一律追加在末尾,因此这条断言的「末尾」也随迁移推进(见 store_schema.go 头注)。
tail := bootCols[len(bootCols)-3:]
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") || if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
!strings.Contains(tail[1], "|"+temporalColumn+"|") { !strings.Contains(tail[1], "|"+temporalColumn+"|") ||
t.Fatalf("自举建的 memory_items 尾两列 = %v, want …, %s, %s", !strings.Contains(tail[2], "|"+sensitiveColumn+"|") {
tail, retentionTierColumn, temporalColumn) t.Fatalf("自举建的 memory_items 尾三列 = %v, want …, %s, %s, %s",
tail, retentionTierColumn, temporalColumn, sensitiveColumn)
} }
} }
+4 -2
View File
@@ -87,9 +87,11 @@ const (
// 同样的分区 500 → **250** 个请求、审查链 51 → **26** 个请求,而单请求实测最大 // 同样的分区 500 → **250** 个请求、审查链 51 → **26** 个请求,而单请求实测最大
// 1.782s,仍在 5s 之内(16 条/片已复现出 11.043s 的越界单请求,故不取更大)。 // 1.782s,仍在 5s 之内(16 条/片已复现出 11.043s 的越界单请求,故不取更大)。
// //
// ⚠️ 但它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求 // ⚠️ 它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求
// 数(实测 201 条在 4 条/片与 8 条/片下都是 ~21s)。「整理轮每轮把整个分区重新 // 数(实测 201 条在 4 条/片与 8 条/片下都是 ~21s)。「整理轮每轮把整个分区重新
// 向量化」这个**放大源仍然存在**(user 分区一轮 250 个请求),本切片不动它。 // 向量化」这个放大源由**增量向量化**收口(embed_incremental.go,2026-10-30):
// 索引里已有向量的行不再重嵌、算出来的向量回填索引,因此同一分区**第二轮起**
// 的请求数趋近 0(第一轮仍按 ⌈条数/批⌉ 发,那是把向量算出来的一次性成本)。
// //
// 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大 // 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。 // 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
+16 -18
View File
@@ -256,26 +256,24 @@ func vectorSimFunc(vecs []embedding.Vector) func(i, j int) float64 {
} }
} }
// embedItems 分批把分区内条目向量化。单批失败即整体失败(调用方决定回退到 // embedItems 给分区内条目取向量:**索引里已有的直接复用,只嵌缺的**
// 词项余弦还是如实上报),不半途改用混合表示 —— 混用会让召回范围不可预测。 // (判据与回填口径见 embed_incremental.go)。
//
// 2026-10-13 M15 登记的两条放大源在这里收口:
// - 「整理轮每轮把整个分区重新向量化」(user 分区一轮 ≈250 个请求)⇒
// 第二轮起趋近 0(第一轮把算出来的向量回填索引);
// - 「一个分片失败 ⇒ 整条 Embed 失败」⇒ 逐条隔离(embedIsolated),
// 一条慢/坏分片只损失它自己,其余条目照常带向量参与比对。
//
// 返回切片恒与 items 等长,缺口位置为 nil(相似度按 0 计 ⇒ 那一对不进候选)。
// 一条向量都拿不到时返回错误,调用方按既有语义降级到词项余弦。
func (t *Tidier) embedItems(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) { func (t *Tidier) embedItems(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(items)) vecs, stats, err := t.deps.Store.embedIncremental(ctx, t.deps.Embedder, items, tidyEmbedBatch)
for start := 0; start < len(items); start += tidyEmbedBatch { if err != nil {
end := min(start+tidyEmbedBatch, len(items)) return nil, err
texts := make([]string, 0, end-start)
for _, it := range items[start:end] {
texts = append(texts, it.Content)
}
vecs, err := t.deps.Embedder.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, ErrTidyEmbedCount
}
out = append(out, vecs...)
} }
return out, nil logEmbedReuse(t.log, "tidy", stats, tidyEmbedBatch)
return vecs, nil
} }
// lexicalSimilarity 是零依赖的相似度回退:两条内容词项集合的余弦。 // lexicalSimilarity 是零依赖的相似度回退:两条内容词项集合的余弦。
+1 -1
View File
@@ -50,7 +50,7 @@ const tidyStatusActive = "active"
// 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。 // 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。
const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind, const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at, content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight, retention_tier FROM memory_items decay_weight, retention_tier, sensitive FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?` ORDER BY created_at DESC, rowid DESC LIMIT ?`
+8 -1
View File
@@ -46,12 +46,19 @@ type Hit struct {
// Index 是建立在已存 (id, vector) 对上的稠密向量相似度索引。 // Index 是建立在已存 (id, vector) 对上的稠密向量相似度索引。
// //
// Embed 用接入的 embedder 把一批文本转成向量。Add // Embed 用接入的 embedder 把一批文本转成向量。Add
// 持久化一个 (id, vector) 对,id 重复时 upsert。Search 返回与 q // 持久化一个 (id, vector) 对,id 重复时 upsert。Vectors 按 id 取回
// 已存向量(增量向量化的读口)。Search 返回与 q
// 最相似的前 topK 个 id,按相似度降序排列。Dim 报告 // 最相似的前 topK 个 id,按相似度降序排列。Dim 报告
// 固定向量维度。Close 释放底层数据库。 // 固定向量维度。Close 释放底层数据库。
type Index interface { type Index interface {
Embed(ctx context.Context, texts []string) ([]Vector, error) Embed(ctx context.Context, texts []string) ([]Vector, error)
Add(ctx context.Context, id string, v Vector) error Add(ctx context.Context, id string, v Vector) error
// Vectors 返回与 ids 对齐的已存向量:命中按位置给出,未命中(含维度与
// 本索引不符的行)为 nil。ids 为空时返回同长度的空切片。
//
// 它是「增量向量化」的读口(memory 包的 embedIncremental):调用方据此
// 只嵌缺的那些,而不是每轮把整个分区重新算一遍。它只读,不写任何行。
Vectors(ctx context.Context, ids []string) ([]Vector, error)
Search(ctx context.Context, q Vector, topK int) ([]Hit, error) Search(ctx context.Context, q Vector, topK int) ([]Hit, error)
Dim() int Dim() int
Close() error Close() error
+68
View File
@@ -0,0 +1,68 @@
package vector
import (
"strconv"
"strings"
)
// 本文件是「按 id 取已存向量」的共用件:增量向量化(memory 包的
// embedIncremental)靠它跳过已经算过的行,从而不再每轮把整个分区重新嵌一遍
// ——那是 M15(2026-10-13)登记的请求放大源之一。
//
// 只读、参数化:id 一律走绑定参数,查询里不出现任何拼接的取值。
// vectorsQueryChunk 是单条 IN 查询携带的 id 上限。SQLite 的绑定变量上限
// (历史默认 999)之下留出余量,使 2000 条的分区不会因为一条查询太长而
// 失败;超过它的 id 分多条查询,结果仍按入参顺序对齐。
const vectorsQueryChunk = 256
// placeholders 返回 n 个占位符:encode 决定形式(SQLite 的 "?"、PostgreSQL
// 的 "$i")。它只拼占位符,不拼任何取值。
func placeholders(n int, encode func(i int) string) string {
var b strings.Builder
for i := 0; i < n; i++ {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(encode(i))
}
return b.String()
}
// sqlitePlaceholders 返回 "?,?,?"。
func sqlitePlaceholders(n int) string {
return placeholders(n, func(int) string { return "?" })
}
// pgPlaceholders 返回 "$1,$2,..."。
func pgPlaceholders(n int) string {
return placeholders(n, func(i int) string { return "$" + strconv.Itoa(i+1) })
}
// idArgs 把 ids 转成绑定参数([]any 是 database/sql 要求的入参形态)。
func idArgs(ids []string) []any {
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return args
}
// alignVectors 把「id → 向量」按入参顺序摊开:未命中的位置(含同一 id 在
// 入参里出现多次时)一律为 nil。因此调用方拿到的切片长度恒等于 ids。
func alignVectors(ids []string, found map[string]Vector) []Vector {
out := make([]Vector, len(ids))
if len(found) == 0 {
return out
}
pos := make(map[string][]int, len(ids))
for i, id := range ids {
pos[id] = append(pos[id], i)
}
for id, vec := range found {
for _, i := range pos[id] {
out[i] = vec
}
}
return out
}
+74
View File
@@ -147,6 +147,56 @@ func (p *PGVectorIndex) Add(ctx context.Context, id string, v Vector) error {
return nil return nil
} }
// Vectors 返回与 ids 对齐的已存向量:命中按位置给出,未命中(含维度与
// 本索引不符的行、解析不了的行)为 nil。它是「增量向量化」的读口 ——
// 调用方据此只嵌缺的那些,而不是每轮重算整个分区。它只读,不写任何行。
//
// 向量列显式 ::text 取出:pgvector 的 OID 不在 database/sql 的标准类型里,
// 直接 scan 进 string 依赖驱动猜测格式;显式转型把「取什么格式」写在 SQL 里。
func (p *PGVectorIndex) Vectors(ctx context.Context, ids []string) ([]Vector, error) {
if len(ids) == 0 {
return []Vector{}, nil
}
found := make(map[string]Vector, len(ids))
for start := 0; start < len(ids); start += vectorsQueryChunk {
end := min(start+vectorsQueryChunk, len(ids))
if err := p.lookupVectors(ctx, ids[start:end], found); err != nil {
return nil, err
}
}
return alignVectors(ids, found), nil
}
// lookupVectors 查一批 id 并把命中的向量写进 found。维度与本索引不符
// (或解析不了)的行当作未命中 —— 它不能参与本索引的余弦,重算一次才是对的。
func (p *PGVectorIndex) lookupVectors(ctx context.Context, ids []string, found map[string]Vector) error {
query := `SELECT id, vector::text FROM vec_items WHERE id IN (` + pgPlaceholders(len(ids)) + `)`
rows, err := p.db.QueryContext(ctx, query, idArgs(ids)...)
if err != nil {
return fmt.Errorf("memory/vector: lookup pg vectors: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id string
text string
)
if err := rows.Scan(&id, &text); err != nil {
return fmt.Errorf("memory/vector: scan pg vector: %w", err)
}
v, err := pgParseVector(text)
if err != nil || len(v) != p.dim {
continue
}
found[id] = v
}
if err := rows.Err(); err != nil {
return fmt.Errorf("memory/vector: iterate pg vectors: %w", err)
}
return nil
}
// Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度 // Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度
// (1 - 余弦距离)降序排列,由 pgvector 在 SQL 中计算。topK 非正 // (1 - 余弦距离)降序排列,由 pgvector 在 SQL 中计算。topK 非正
// 返回空结果;长度与索引维度不一致的查询 // 返回空结果;长度与索引维度不一致的查询
@@ -211,3 +261,27 @@ func pgVectorLiteral(v Vector) string {
b.WriteByte(']') b.WriteByte(']')
return b.String() return b.String()
} }
// pgParseVector 把 pgvector 的文本形式 "[a,b,c]" 解析回 Vector ——
// pgVectorLiteral 的逆运算。它拒绝空串与任何非浮点的分量,使损坏的行以
// 错误暴露(调用方据此把它当未命中),而不是被静默读成一串零向量。
func pgParseVector(text string) (Vector, error) {
trimmed := strings.TrimSpace(text)
if len(trimmed) < 2 || trimmed[0] != '[' || trimmed[len(trimmed)-1] != ']' {
return nil, fmt.Errorf("memory/vector: invalid pgvector literal of length %d", len(trimmed))
}
body := trimmed[1 : len(trimmed)-1]
if body == "" {
return Vector{}, nil
}
parts := strings.Split(body, ",")
out := make(Vector, len(parts))
for i, part := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(part), 32)
if err != nil {
return nil, fmt.Errorf("memory/vector: parse pgvector component %d: %w", i, err)
}
out[i] = float32(f)
}
return out, nil
}
+19
View File
@@ -89,4 +89,23 @@ func TestPG_RequiresEnv(t *testing.T) {
if len(vs) != 1 || len(vs[0]) != 4 { if len(vs) != 1 || len(vs[0]) != 4 {
t.Fatalf("Embed => %d vectors, want one 4-dim", len(vs)) t.Fatalf("Embed => %d vectors, want one 4-dim", len(vs))
} }
// Vectors(增量向量化的读口):命中按位置给出,未命中为 nil ——
// 这条断言顺带验 vector::text 的解析在真实 pgvector 上成立。
got, err := idx.Vectors(ctx, []string{"b", "missing", "a"})
if err != nil {
t.Fatalf("Vectors: %v", err)
}
if len(got) != 3 {
t.Fatalf("Vectors 返回 %d 条, want 3", len(got))
}
if !equalVector(got[0], Vector{0, 1, 0, 0}) {
t.Fatalf("Vectors[0] = %v, want b 的向量", got[0])
}
if len(got[1]) != 0 {
t.Fatalf("未命中的 id 返回了向量:%v", got[1])
}
if !equalVector(got[2], Vector{1, 0, 0, 0}) {
t.Fatalf("Vectors[2] = %v, want a 的向量", got[2])
}
} }
+47
View File
@@ -108,6 +108,53 @@ func (s *SQLiteIndex) Add(ctx context.Context, id string, v Vector) error {
return nil return nil
} }
// Vectors 返回与 ids 对齐的已存向量:命中按位置给出,未命中(含维度与
// 本索引不符的行)为 nil。它是「增量向量化」的读口 —— 调用方据此只嵌
// 缺的那些,而不是每轮重算整个分区。它只读,不写任何行。
func (s *SQLiteIndex) Vectors(ctx context.Context, ids []string) ([]Vector, error) {
if len(ids) == 0 {
return []Vector{}, nil
}
found := make(map[string]Vector, len(ids))
for start := 0; start < len(ids); start += vectorsQueryChunk {
end := min(start+vectorsQueryChunk, len(ids))
if err := s.lookupVectors(ctx, ids[start:end], found); err != nil {
return nil, err
}
}
return alignVectors(ids, found), nil
}
// lookupVectors 查一批 id 并把命中的向量写进 found。维度与本索引不符的行
// 当作未命中(它不能参与本索引的余弦,重算一次才是对的)。
func (s *SQLiteIndex) lookupVectors(ctx context.Context, ids []string, found map[string]Vector) error {
query := `SELECT id, vector FROM vec_items WHERE id IN (` + sqlitePlaceholders(len(ids)) + `)`
rows, err := s.db.QueryContext(ctx, query, idArgs(ids)...)
if err != nil {
return fmt.Errorf("memory/vector: lookup vectors: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
id string
blob []byte
)
if err := rows.Scan(&id, &blob); err != nil {
return fmt.Errorf("memory/vector: scan vector: %w", err)
}
v, err := decodeVector(blob)
if err != nil || len(v) != s.dim {
continue
}
found[id] = v
}
if err := rows.Err(); err != nil {
return fmt.Errorf("memory/vector: iterate vectors: %w", err)
}
return nil
}
// Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度 // Search 返回与 q 最相似的最多 topK 个 id,按余弦相似度
// 降序排列。它在 Go 侧扫描每个已持久化向量(线性扫描,无 ANN)。topK // 降序排列。它在 Go 侧扫描每个已持久化向量(线性扫描,无 ANN)。topK
// 非正返回空结果;长度与 // 非正返回空结果;长度与
@@ -0,0 +1,197 @@
package vector
import (
"context"
"testing"
)
// 本文件验「按 id 取已存向量」这个读口(增量向量化的判据:向量存在性 + 维度)。
// 它是 M61(2026-10-30)用来把「整理轮每轮全量重嵌」变成「只嵌缺的那些」的那一层,
// 因此这里把它自己的对齐/未命中/维度不符/分块行为逐条钉死。
// TestSQLite_Vectors_按入参顺序对齐 覆盖命中、未命中、重复 id 三种位置。
func TestSQLite_Vectors_按入参顺序对齐(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 3)
for _, e := range []struct {
id string
v Vector
}{
{id: "cat", v: Vector{1, 0, 0}},
{id: "dog", v: Vector{0, 1, 0}},
} {
if err := s.Add(ctx, e.id, e.v); err != nil {
t.Fatalf("Add(%s): %v", e.id, err)
}
}
got, err := s.Vectors(ctx, []string{"dog", "missing", "cat", "dog"})
if err != nil {
t.Fatalf("Vectors: %v", err)
}
if len(got) != 4 {
t.Fatalf("Vectors 返回 %d 条, want 4(必须与入参等长)", len(got))
}
if !equalVector(got[0], Vector{0, 1, 0}) {
t.Fatalf("got[0] = %v, want dog 的向量", got[0])
}
if len(got[1]) != 0 {
t.Fatalf("未命中的 id 返回了向量:%v", got[1])
}
if !equalVector(got[2], Vector{1, 0, 0}) {
t.Fatalf("got[2] = %v, want cat 的向量", got[2])
}
if !equalVector(got[3], Vector{0, 1, 0}) {
t.Fatalf("重复 id 的第 4 个位置 = %v, want 与第 1 个位置相同", got[3])
}
}
// TestSQLite_Vectors_空入参 返回同长度的空切片(调用方可以无条件 range)。
func TestSQLite_Vectors_空入参(t *testing.T) {
t.Parallel()
s := newInMemorySQLite(t, 2)
got, err := s.Vectors(context.Background(), nil)
if err != nil {
t.Fatalf("Vectors(nil): %v", err)
}
if got == nil || len(got) != 0 {
t.Fatalf("Vectors(nil) = %v, want 空切片", got)
}
}
// TestSQLite_Vectors_维度不符当未命中:索引维度是硬门,别的维度的行不能参与本
// 索引的余弦,因此它必须被当成「没有向量」(于是调用方会重算一次)。
func TestSQLite_Vectors_维度不符当未命中(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 3)
// 绕开 Add 的维度校验,直接写一条 2 维的行(模拟换维度之后留下的旧行)。
if _, err := s.db.ExecContext(ctx,
`INSERT INTO vec_items (id, vector, dim) VALUES (?, ?, ?)`,
"legacy", encodeVector(Vector{1, 0}), 2); err != nil {
t.Fatalf("insert legacy row: %v", err)
}
got, err := s.Vectors(ctx, []string{"legacy"})
if err != nil {
t.Fatalf("Vectors: %v", err)
}
if len(got) != 1 || len(got[0]) != 0 {
t.Fatalf("got = %v, want 单个未命中(维度不符的行不能当命中)", got)
}
// 损坏的 blob 同样当未命中,绝不静默读成零向量。
if _, err := s.db.ExecContext(ctx,
`INSERT INTO vec_items (id, vector, dim) VALUES (?, ?, ?)`,
"broken", []byte{1, 2, 3}, 3); err != nil {
t.Fatalf("insert broken row: %v", err)
}
got, err = s.Vectors(ctx, []string{"broken"})
if err != nil {
t.Fatalf("Vectors(broken): %v", err)
}
if len(got[0]) != 0 {
t.Fatalf("损坏 blob 被当成了向量:%v", got[0])
}
}
// TestSQLite_Vectors_超过单查询分块上限 让 id 数越过 vectorsQueryChunk:
// 结果仍必须与入参逐位对齐(分块只是 SQL 的绑定变量上限,不是语义)。
func TestSQLite_Vectors_超过单查询分块上限(t *testing.T) {
t.Parallel()
ctx := context.Background()
s := newInMemorySQLite(t, 2)
ids := make([]string, 0, vectorsQueryChunk+8)
want := make(map[string]Vector, vectorsQueryChunk+8)
for i := 0; i < vectorsQueryChunk+8; i++ {
id := "id-" + string(rune('a'+i%26)) + "-" + itoaTest(i)
v := Vector{float32(i % 7), 1}
if err := s.Add(ctx, id, v); err != nil {
t.Fatalf("Add(%s): %v", id, err)
}
ids = append(ids, id)
want[id] = v
}
got, err := s.Vectors(ctx, ids)
if err != nil {
t.Fatalf("Vectors: %v", err)
}
if len(got) != len(ids) {
t.Fatalf("返回 %d 条, want %d", len(got), len(ids))
}
for i, id := range ids {
if !equalVector(got[i], want[id]) {
t.Fatalf("第 %d 条(%s)= %v, want %v", i, id, got[i], want[id])
}
}
}
// TestPGParseVector 验 pgvector 文本形式的解析(与 pgVectorLiteral 互逆)。
func TestPGParseVector(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
text string
want Vector
}{
{name: "single", text: "[1]", want: Vector{1}},
{name: "three", text: "[1.5,2,-0.25]", want: Vector{1.5, 2, -0.25}},
{name: "empty", text: "[]", want: Vector{}},
} {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := pgParseVector(tt.text)
if err != nil {
t.Fatalf("pgParseVector(%q): %v", tt.text, err)
}
if !equalVector(got, tt.want) {
t.Fatalf("pgParseVector(%q) = %v, want %v", tt.text, got, tt.want)
}
})
}
for _, bad := range []string{"", "1,2,3", "[1,2", "[a,b]", "[1,,2]"} {
if _, err := pgParseVector(bad); err == nil {
t.Fatalf("pgParseVector(%q) 没报错(损坏行必须暴露)", bad)
}
}
// 往返:Vector → 文本 → Vector 必须一致(精度按 float32 取)。
original := Vector{0.5, -1, 3.25, 1024}
round, err := pgParseVector(pgVectorLiteral(original))
if err != nil {
t.Fatalf("round-trip parse: %v", err)
}
if !equalVector(round, original) {
t.Fatalf("round-trip = %v, want %v", round, original)
}
}
// equalVector 比较两个向量(nil 与空视为相等)。
func equalVector(a, b Vector) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// itoaTest 是测试里的小整数格式化(避免为一处用法引入 strconv 的第二种写法)。
func itoaTest(i int) string {
if i == 0 {
return "0"
}
out := make([]byte, 0, 4)
for i > 0 {
out = append([]byte{byte('0' + i%10)}, out...)
i /= 10
}
return string(out)
}
+18 -3
View File
@@ -32,6 +32,9 @@ const (
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`): // SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。 // 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
SourcePlatform Source = "platform" SourcePlatform Source = "platform"
// SourceTTS 表示正文是我们自己合成的语音(出站方向,design/25 §7.1):成功时
// Name 指向那份可播副本,降级时 Status=failed 且正文照发文本、只带降级原因。
SourceTTS Source = "tts"
) )
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文 // Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
@@ -39,7 +42,8 @@ const (
type Status string type Status string
const ( const (
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。 // StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写);出站方向表示
// **合成成功**(正文即这段语音的文字内容,Name 指向可播副本)。
StatusTranscribed Status = "transcribed" StatusTranscribed Status = "transcribed"
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常 // StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
// 的空结果,**不是**故障。 // 的空结果,**不是**故障。
@@ -85,6 +89,10 @@ type Ref struct {
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0 // DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。 // (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
DurationMs int64 `json:"durationMs,omitempty"` DurationMs int64 `json:"durationMs,omitempty"`
// FallbackReason 只在**出站合成降级**时非空(Source=tts、Status=failed):说明为什么
// 这条消息没能变成语音(能力未接线 / 预算超时 / 合成为空)。入站转写不使用它 ——
// 入站的失败原因由正文那行诚实提示承载。
FallbackReason string `json:"fallbackReason,omitempty"`
} }
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。 // IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
@@ -110,6 +118,11 @@ func (r Ref) Validate() error {
if r.DurationMs < 0 { if r.DurationMs < 0 {
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs) return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
} }
// 降级原因只属于失败态:成功还带原因会让人以为「其实降级了」,客户端也就无从按
// status 单点分支(design/24 §10.4 的「客户端绝不按文案分支」同一条纪律)。
if r.FallbackReason != "" && r.Status != StatusFailed {
return fmt.Errorf("%w: fallbackReason with status %q", ErrBadRef, r.Status)
}
switch { switch {
case r.Name == "" && (r.MIME != "" || r.Size != 0): case r.Name == "" && (r.MIME != "" || r.Size != 0):
return fmt.Errorf("%w: mime/size without name", ErrBadRef) return fmt.Errorf("%w: mime/size without name", ErrBadRef)
@@ -124,7 +137,7 @@ func (r Ref) Validate() error {
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。 // IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
func (r Ref) IsZero() bool { func (r Ref) IsZero() bool {
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" && return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
r.Size == 0 && r.DurationMs == 0 r.Size == 0 && r.DurationMs == 0 && r.FallbackReason == ""
} }
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里 // Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
@@ -167,7 +180,9 @@ func Decode(raw string) (Ref, error) {
} }
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。 // validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform } func validSource(s Source) bool {
return s == SourceASR || s == SourcePlatform || s == SourceTTS
}
func validStatus(s Status) bool { func validStatus(s Status) bool {
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
+43
View File
@@ -31,6 +31,14 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3", Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
MIME: "audio/mpeg", Size: 4321, MIME: "audio/mpeg", Size: 4321,
}}, }},
{"出站合成成功 + 可播副本", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg",
Size: 8192, DurationMs: 1500,
}},
{"出站合成降级(正文照发文本)", Ref{
Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded",
}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -119,6 +127,11 @@ func TestEncodeRejectsBrokenRef(t *testing.T) {
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}}, {"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}}, {"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}}, {"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
{"降级原因挂在成功态", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "x.ogg", MIME: "audio/ogg", Size: 12, FallbackReason: "budget_exceeded",
}},
{"只有降级原因", Ref{FallbackReason: "budget_exceeded"}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -149,6 +162,7 @@ func TestDecodeRejectsGarbage(t *testing.T) {
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`}, {"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
{"闭集外的状态", `{"source":"asr","status":"done"}`}, {"闭集外的状态", `{"source":"asr","status":"done"}`},
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`}, {"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
{"降级原因挂在成功态", `{"source":"tts","status":"transcribed","fallbackReason":"x"}`},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -164,6 +178,33 @@ func TestDecodeRejectsGarbage(t *testing.T) {
} }
} }
// TestOutboundTTSJSONShape 把**出站**两种落库形状钉死(design/25 §7.1):
// 成功 = source/status/name/mime/size(+ 可选 durationMs);降级 = source/status +
// fallbackReason,**没有 name/url**(正文照发文本,客户端据此只挂一个小标)。
func TestOutboundTTSJSONShape(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg", Size: 8192,
})
if err != nil {
t.Fatalf("Encode(成功): %v", err)
}
want := `{"source":"tts","status":"transcribed","name":"fedcba9876543210fedcba9876543210.ogg",` +
`"mime":"audio/ogg","size":8192}`
if raw != want {
t.Fatalf("出站成功 JSON = %s\n want %s", raw, want)
}
raw, err = Encode(Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded"})
if err != nil {
t.Fatalf("Encode(降级): %v", err)
}
if want := `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`; raw != want {
t.Fatalf("出站降级 JSON = %s\n want %s", raw, want)
}
}
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界: // TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音); // IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。 // HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
@@ -178,6 +219,8 @@ func TestRefIsVoiceAndHasCopy(t *testing.T) {
{"零值(不是语音消息)", Ref{}, false, false}, {"零值(不是语音消息)", Ref{}, false, false},
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false}, {"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true}, {"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
{"出站合成成功", Ref{Source: SourceTTS, Status: StatusTranscribed, Name: "n.ogg", MIME: "audio/ogg", Size: 4}, true, true},
{"出站降级(算语音消息,但无可播副本)", Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "no_capability"}, true, false},
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false}, {"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
} }
for _, tc := range cases { for _, tc := range cases {
+45 -9
View File
@@ -16,7 +16,8 @@ import (
type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
// sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送) // sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送)
// 共用的唯一 IM 出站出口。它把内容变成 IM 渠道应收到的纯聊天气泡: // 共用的唯一 IM 出站出口(带音频的回复走 sendIMBubblesVoice,它只是多挂一条 Media)。
// 它把内容变成 IM 渠道应收到的聊天气泡:
// 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离, // 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离,
// 每个 <p> 段落作为独立 // 每个 <p> 段落作为独立
// 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s, // 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s,
@@ -25,10 +26,19 @@ type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
// //
// target 携带平台/渠道路由字段,并被复制到每个 // target 携带平台/渠道路由字段,并被复制到每个
// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律 // 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律
// 不带 —— 见 outboundBubble);sender 是 // 不带 —— 见 outboundRoute);sender 是
// 适配器的出站调用。被剥成空的内容(纯动作行) // 适配器的出站调用。被剥成空的内容(纯动作行)
// 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。 // 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。
func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error { func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error {
return b.sendIMBubblesVoice(ctx, target, sender, content, nil)
}
// sendIMBubblesVoice 是 sendIMBubbles 的**带音频**变体:voice 非 nil 时,那一份音频只
// 挂在**第一个**气泡上,其余气泡仍是纯文本。这条「只挂第一个」不是优化而是正确性 ——
// 同一条合成语音若跟着每个气泡各发一次,对方会收到 N 份同样的音频。
//
// voice 为 nil 时行为与 sendIMBubbles 逐字相同(因此主动推送等纯文本出口不受影响)。
func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string, voice *adapter.Media) error {
// splitBubbles 对每个气泡应用 stripNarration(因此 <action>/<narration> 与 // splitBubbles 对每个气泡应用 stripNarration(因此 <action>/<narration> 与
// 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是 // 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是
// 唯一的清洗入口,与旧的回复路径共用。 // 唯一的清洗入口,与旧的回复路径共用。
@@ -42,6 +52,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
time.Sleep(b.bubbleGapFn()) time.Sleep(b.bubbleGapFn())
} }
out := outboundBubble(target, bubble) out := outboundBubble(target, bubble)
if i == 0 && voice != nil {
out = outboundVoiceBubble(target, bubble, *voice)
}
// 每个气泡各自发送一次,间隔才会落在消息之间。 // 每个气泡各自发送一次,间隔才会落在消息之间。
if err := sender(ctx, out); err != nil { if err := sender(ctx, out); err != nil {
return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err) return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err)
@@ -50,7 +63,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
return nil return nil
} }
// outboundBubble 依据一条入站消息的**路由字段**构造一个纯文本出站气泡。 // outboundRoute 依据一条入站消息的**路由字段**构造一个出站信封:只有路由字段,
// 没有内容。outboundBubble(纯文本)与 outboundVoiceBubble(文本 + 音频)都从它出发,
// 因此「哪些字段允许出站」只有一份白名单。
// //
// 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的 // 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的
// 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称) // 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称)
@@ -60,8 +75,9 @@ func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessag
// 之后**整条返回错误,主人连文本都收不到。 // 之后**整条返回错误,主人连文本都收不到。
// //
// 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage // 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage
// 的字段默认不外发:出站是纯文本气泡,本派发器只接收文本。 // 的字段默认不外发。⚠️ 出站的 `Media` 只能由出站语音自己填(那份副本是我们刚合成的,
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage { // 见 outbound_voice.go),**绝不**从 target 继承。
func outboundRoute(target adapter.UnifiedMessage) adapter.UnifiedMessage {
return adapter.UnifiedMessage{ return adapter.UnifiedMessage{
// 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。 // 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。
Platform: target.Platform, Platform: target.Platform,
@@ -80,9 +96,29 @@ func outboundBubble(target adapter.UnifiedMessage, content string) adapter.Unifi
IsGroup: target.Sender.IsGroup, IsGroup: target.Sender.IsGroup,
GroupID: target.Sender.GroupID, GroupID: target.Sender.GroupID,
}, },
// 本气泡的内容:出站永远是纯文本。
Content: content,
ContentType: adapter.ContentText,
Timestamp: time.Now().UTC(),
} }
} }
// outboundBubble 构造一个**纯文本**出站气泡:路由字段 + 文本内容。
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage {
out := outboundRoute(target)
out.Content = content
out.ContentType = adapter.ContentText
out.Timestamp = time.Now().UTC()
return out
}
// outboundVoiceBubble 构造**带音频**的出站气泡:与纯文本气泡同一套路由字段与同一份
// 正文,只是把 ContentType 标成 voice 并挂上那一条 Media。
//
// 正文**照发**(不是「有音频就不发文字」):适配器把它当「文字 item + 语音 item」一起
// 投递(weixin_ilink 的 sendToOne),对方既看得到文字也点得开音频;而任何一侧失败都
// 只影响那一侧(音频发不出去时文字仍在)。
func outboundVoiceBubble(target adapter.UnifiedMessage, content string, md adapter.Media) adapter.UnifiedMessage {
out := outboundRoute(target)
out.Content = content
out.ContentType = adapter.ContentVoice
out.Media = []adapter.Media{md}
out.Timestamp = time.Now().UTC()
return out
}
@@ -117,8 +117,12 @@ type Options struct {
// 它们绝不代表身份(身份仍由结构决定)。为空则保持 // 它们绝不代表身份(身份仍由结构决定)。为空则保持
// 桥接器沿用旧的 design/12 §6 轻量判定。 // 桥接器沿用旧的 design/12 §6 轻量判定。
TriggerWords []string TriggerWords []string
// PersonaPrompt 是交给参与度判定器的当前角色身份/系统提示词块, // PersonaPrompt 是当前角色的身份/系统提示词块(装配方传入)。
// 使其能权衡角色的语气。其中不含主人敏感信息。 // 🔴 LLM 判定器**不**把整份人格指令喂进模型:它只吃由它折算出的
// 「角色名 + 偏好摘要」(engagejudge.go 的 judgePersonaBrief),
// 否则判定 reason 会带亲昵口吻去指代第三方(审计 2026-10-30 §2.4 / P1-2)。
// 字段仍留在输入结构里(装配方与既有调用方都在设它),但内置判定器
// 刻意不使用它。
PersonaPrompt string PersonaPrompt string
// Engage 是参与度引擎配置(概率、活跃窗口、冷却、 // Engage 是参与度引擎配置(概率、活跃窗口、冷却、
// 上下文大小)。当其 Enabled/Probability 为零且 TriggerWords 为空时, // 上下文大小)。当其 Enabled/Probability 为零且 TriggerWords 为空时,
@@ -161,7 +161,7 @@ func (b *Bridge) persistBatchUsers(ctx context.Context, parts []batchMessage, ta
notify := b.persistVoiceFirstNotify(p.voiceRef) notify := b.persistVoiceFirstNotify(p.voiceRef)
var row history.Message var row history.Message
if target.private { if target.private {
row = b.persistMain(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image, row = b.persistMainWith(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image,
target.platform, deviceIDFor(msg), adapterName(a), turnID), notify) target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
} else { } else {
row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image, row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image,
@@ -0,0 +1,133 @@
package channelbridge
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件覆盖判定器去人格(审计 2026-10-30 §2.4 / P1-2):判定器只做价值
// 判断(design/18 §3),因此它的模型输入只吃「角色名 + 偏好摘要」,
// **绝不**吃整份人格指令 —— 否则它会拿「你只唤他『亲爱的』」去看群友,
// 把第三方称作「亲爱的」(现场:reason 写成「让亲爱的他们自己聊吧」)。
//
// 删掉 judgePersonaBrief 的折算、把 in.PersonaPrompt 塞回渲染,这些用例必红。
// intimatePersonaBody 是一份「带称呼与关系设定」的人格指令样本:它就是要被
// 挡在判定器之外的那一类文本(结构同 configs/personas/cyrene.yaml:33/46/72)。
const intimatePersonaBody = "你是昔涟。你只唤他「亲爱的」,你是我独一无二的小女友,靠过来的时候要圈住他。"
// TestEngageJudgePromptCarriesNoPersonaBody 钉住渲染契约:人格正文、
// 亲昵称呼与亲近动作都不得出现在判定器输入里;角色名与偏好摘要在。
func TestEngageJudgePromptCarriesNoPersonaBody(t *testing.T) {
t.Parallel()
in := LightInput{
Engage: true,
PersonaPrompt: intimatePersonaBody,
TriggerWords: []string{"昔涟", "小昔"},
RecentContext: "user 张三: 有人知道昔涟吗\n",
SenderName: "张三",
Msg: adapter.UnifiedMessage{Content: "那聊聊昔涟嘛"},
}
got := buildEngageUserContent(in)
for _, banned := range []string{intimatePersonaBody, "你只唤他", "小女友", "亲爱的", "圈住"} {
if strings.Contains(got, banned) {
t.Fatalf("判定器输入含人格正文(%q):\n%s", banned, got)
}
}
for _, want := range []string{"【角色设定摘要】", "角色名:昔涟", engageJudgePreferenceDigest} {
if !strings.Contains(got, want) {
t.Fatalf("判定器输入缺 %q:\n%s", want, got)
}
}
// 群上下文与触发消息照旧喂(判定要靠它们,且它们不是人格正文)。
if !strings.Contains(got, "有人知道昔涟吗") || !strings.Contains(got, "那聊聊昔涟嘛") {
t.Fatalf("判定器输入缺上下文或触发消息:\n%s", got)
}
}
// TestEngageJudgeBriefSurvivesMissingRoleName 是无提醒词的情形(纯概率触发,
// design/18 §2 触发③):没有角色名可报,但偏好摘要仍在,人格正文仍不许出现。
func TestEngageJudgeBriefSurvivesMissingRoleName(t *testing.T) {
t.Parallel()
in := LightInput{
Engage: true,
PersonaPrompt: intimatePersonaBody,
Msg: adapter.UnifiedMessage{Content: "今天天气不错"},
}
got := buildEngageUserContent(in)
if strings.Contains(got, "亲爱的") || strings.Contains(got, "小女友") {
t.Fatalf("判定器输入含人格正文:\n%s", got)
}
if !strings.Contains(got, engageJudgePreferenceDigest) {
t.Fatalf("判定器输入缺偏好摘要:\n%s", got)
}
if strings.Contains(got, "角色名:") {
t.Fatalf("没有角色名提醒词时不该编一个角色名:\n%s", got)
}
}
// judgeJSONAdapter 记录请求并回一条合法裁决,使端到端用例能走完整 Judge
// (captureModelAdapter 的「在呢」不是 JSON,会在解析处失败关闭)。
type judgeJSONAdapter struct{ captureModelAdapter }
func (j *judgeJSONAdapter) Name() string { return "judge-capture" }
func (j *judgeJSONAdapter) Chat(ctx context.Context, req model.Request, _ model.Options) (model.Response, error) {
j.mu.Lock()
j.reqs = append(j.reqs, req)
j.mu.Unlock()
return model.Response{
Content: `{"interested":true,"worthReply":true,"avoidTopic":false,"reason":"群友在聊角色本身"}`,
}, nil
}
func (j *judgeJSONAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return j.Chat(ctx, req, model.Options{})
}
// TestEngageJudgeRequestNeverSeesPersonaBody 是**模型侧**的验收:真实走一次
// Judge,断言 provider 收到的每一条消息都不含人格正文与亲昵称呼,且裁决
// reason 是一句中性短语(不再用称呼指代第三方)。
func TestEngageJudgeRequestNeverSeesPersonaBody(t *testing.T) {
t.Parallel()
ad := &judgeJSONAdapter{}
judge := NewLLMLightJudge(ad, nil)
d, err := judge.Judge(context.Background(), LightInput{
Engage: true,
PersonaPrompt: intimatePersonaBody,
TriggerWords: []string{"昔涟"},
RecentContext: "user 张三: 有人知道昔涟吗\n",
SenderName: "张三",
Msg: adapter.UnifiedMessage{Content: "那聊聊昔涟嘛"},
})
if err != nil {
t.Fatalf("Judge: %v", err)
}
if !d.WorthReply || d.Reason != "群友在聊角色本身" {
t.Fatalf("decision = %+v, want the parsed verdict", d)
}
req := ad.lastRequest(t)
if len(req.Messages) != 2 || req.Messages[0].Role != model.RoleSystem {
t.Fatalf("judge request = %+v, want system + user", req.Messages)
}
if got := turnMessageText(req.Messages[0]); got != engageJudgeSystemPrompt {
t.Fatalf("judge system = %q, want the engagement-only prompt", got)
}
for i, m := range req.Messages {
text := turnMessageText(m)
for _, banned := range []string{"亲爱的", "小女友", "你只唤他", "圈住"} {
if strings.Contains(text, banned) {
t.Fatalf("judge messages[%d] 含人格正文(%q):%s", i, banned, text)
}
}
}
}
+44 -6
View File
@@ -84,15 +84,53 @@ const engageJudgeSystemPrompt = "你是群聊参与度轻思考器。只判断
"\"reason\": string(一句话理由)}。" + "\"reason\": string(一句话理由)}。" +
"不要输出其它文字。" "不要输出其它文字。"
// buildEngageUserContent 为判定器渲染角色提示词、近期群上下文与 // engageJudgePreferenceDigest 是喂给判定器的**角色偏好摘要**:只说「什么话题
// 触发消息。角色块不含主人敏感信息; // 她会感兴趣、什么话题该回避」,没有一个称呼、没有一句亲昵口吻。
// 上下文由参与度渲染器限定长度。Engage 为 false(旧的 design/12 //
// §6 调用)时只有消息与发送者标签,判定器因此退化为 // 为什么不用整份人格指令(审计 2026-10-30 §2.4 / P1-2):判定器只做价值判断
// (本文件头注),口吻对它毫无用处,却会被它搬进 reason —— 那句
// 「让亲爱的他们自己聊吧」就是判定器读着「你只唤他『亲爱的』」在看群友的
// 直接后果。人格指令里还有主人关系设定,喂进一个只该算参与度的廉价调用
// 也没有必要。
const engageJudgePreferenceDigest = "她偏爱轻松、温和的日常闲聊,愿意接住别人递来的话题;回避隐私、成人、争吵与人身攻击类话题。"
// judgeRoleName 取判定器要用的角色名:优先用角色名提醒词(design/18 §2 的
// TriggerWords 本就是她的名字/昵称)。没有就留空 —— 判定器不知道名字照样能
// 判,而绝不能为此把整份人格指令塞回去。
func judgeRoleName(words []string) string {
for _, w := range words {
if name := strings.TrimSpace(w); name != "" {
return name
}
}
return ""
}
// judgePersonaBrief 把判定器的角色输入折成「角色名 + 偏好摘要」。
//
// in.PersonaPrompt 是**整份人格指令**(含称呼与关系设定),这里刻意一个字节
// 都不透传:判定器不需要口吻,需要的是「她在乎什么话题」。这一点由
// TestEngageJudgePromptCarriesNoPersonaBody 钉住 —— 把整份指令塞回去它就红。
func judgePersonaBrief(in LightInput) string {
var b strings.Builder
if name := judgeRoleName(in.TriggerWords); name != "" {
b.WriteString("角色名:")
b.WriteString(name)
b.WriteByte('\n')
}
b.WriteString(engageJudgePreferenceDigest)
return b.String()
}
// buildEngageUserContent 为判定器渲染角色摘要、近期群上下文与
// 触发消息。角色块只含「角色名 + 偏好摘要」,**不含**人格指令正文与
// 主人敏感信息(P1-2);上下文由参与度渲染器限定长度。Engage 为 false
// (旧的 design/12 §6 调用)时只有消息与发送者标签,判定器因此退化为
// 朴素的价值判断。 // 朴素的价值判断。
func buildEngageUserContent(in LightInput) string { func buildEngageUserContent(in LightInput) string {
var b strings.Builder var b strings.Builder
if in.Engage && in.PersonaPrompt != "" { if in.Engage {
b.WriteString("【人格设定】\n" + in.PersonaPrompt + "\n\n") b.WriteString("【角色设定摘要】\n" + judgePersonaBrief(in) + "\n\n")
} }
if in.Engage && in.TriggerWords != nil { if in.Engage && in.TriggerWords != nil {
b.WriteString("【角色提醒词】" + strings.Join(in.TriggerWords, " / ") + "\n\n") b.WriteString("【角色提醒词】" + strings.Join(in.TriggerWords, " / ") + "\n\n")
+27 -15
View File
@@ -193,15 +193,31 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
if res != nil { if res != nil {
mood = res.Mood mood = res.Mood
} }
// 出站语音(design/25 §7.1):先把结算结论折成 voice 列事实,再让落库那条路走
// 「INSERT → 写 voice 列 → notify」(M44 首帧纪律)—— 与入站语音共用同一份
// persistVoiceFirstNotify,因此首帧上就带着 `voice`,客户端不必等重连。
ref, hasVoice := b.outboundVoiceRef(resultVoice(res))
notify := b.notifyPersisted
if hasVoice {
notify = b.persistVoiceFirstNotify(ref)
}
// 持久化助手回复(主人私聊 → 主会话 + message_persisted // 持久化助手回复(主人私聊 → 主会话 + message_persisted
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后 // 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。 // 送回渠道。
if target.private { if target.private {
b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID) b.persistMainWith(ctx, mainRow(history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
} else { } else {
b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID) b.persistIsolatedWith(ctx, isolatedRow(history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID), notify)
} }
if err := b.sendReply(ctx, msg, a, reply); err != nil { // 音频只在合成成功、且盘上那份副本读得回来时才挂上;读不回来就诚实退回纯文本
// (正文照发,WARN 已由 outboundVoiceMedia 记下)。
var voiceMedia *adapter.Media
if hasVoice {
if md, ok := b.outboundVoiceMedia(msg, ref); ok {
voiceMedia = &md
}
}
if err := b.sendReplyVoice(ctx, msg, a, reply, voiceMedia); err != nil {
if errors.Is(err, adapter.ErrUnsupported) { if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped", b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", target.sessionID) "platform", msg.Platform, "session", target.sessionID)
@@ -234,7 +250,9 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
// §4.6.1);纯 Web 主会话消息(platform 为 ""/"web")保持不动。在 // §4.6.1);纯 Web 主会话消息(platform 为 ""/"web")保持不动。在
// 受限(群聊/陌生人)上下文中,它还会在最前面加一条系统 // 受限(群聊/陌生人)上下文中,它还会在最前面加一条系统
// 消息,声明第三方身份边界——「每条 IM 非管理员 // 消息,声明第三方身份边界——「每条 IM 非管理员
// 账号的消息都是第三方,与主人无关」(design/16 §4.6 §1)。 // 账号的消息都是第三方,与主人无关」(design/16 §4.6 §1)——
// 并在同一条框架行里并列公域呈现块(publicPresencePrompt:
// 对第三方怎么称呼、什么私事不能说,审计 2026-10-30 P0-3)。
// 该边界与发送者的**昵称标注**(model.Message.SenderName,见 // 该边界与发送者的**昵称标注**(model.Message.SenderName,见
// thirdPartySenderName)由解析出的 SessionEnv/actor 结构化决定, // thirdPartySenderName)由解析出的 SessionEnv/actor 结构化决定,
// 绝不由 LLM 推断。原始消息文本保持不动(标签只走结构化字段), // 绝不由 LLM 推断。原始消息文本保持不动(标签只走结构化字段),
@@ -274,17 +292,11 @@ func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.Sess
} }
return []model.Message{msg} return []model.Message{msg}
} }
// 受限(群聊/陌生人):来源(若是 IM)+ 第三方身份边界。 // 受限(群聊/陌生人):来源(若是 IM)+ 第三方身份边界 + 公域呈现块,
var sys string // 同一条系统框架行内按序并列(restrictedFrameText)。公域呈现块是审计
switch { // 2026-10-30 的 P0-3:**呈现层投影**(对第三方怎么称呼、什么私事不能说),
case origin != "" && thirdPartyBoundaryPrompt != "": // 不改身份/能力/权限(AGENTS §9)。
sys = origin + "\n" + thirdPartyBoundaryPrompt return []model.Message{model.TextMessage(model.RoleSystem, restrictedFrameText(origin)), msg}
case origin != "":
sys = origin
default:
sys = thirdPartyBoundaryPrompt
}
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
} }
// userTurnMessage 构建入站回合的用户消息基底:文本为空时**不塞空文本部件** // userTurnMessage 构建入站回合的用户消息基底:文本为空时**不塞空文本部件**
@@ -17,6 +17,11 @@ import (
// 这条车道的口径与 loop 的归属渲染是一对(loop/attribution.go): // 这条车道的口径与 loop 的归属渲染是一对(loop/attribution.go):
// 受限会话里空标注 = **主人本人**,带昵称 = **第三方人员**。两边 // 受限会话里空标注 = **主人本人**,带昵称 = **第三方人员**。两边
// 任何一侧改动都必须同时看另一侧,否则模型会把群员与主人读反。 // 任何一侧改动都必须同时看另一侧,否则模型会把群员与主人读反。
//
// 本文件还承载受限车道的**公域呈现块**(publicPresencePrompt,审计
// 2026-10-30 §5.3 / P0-3):它管的是「对第三方怎么称呼、什么私事不能说」,
// 属**呈现层投影**(AGENTS §9:行为模式 = 呈现,可切档),不是人格切换、
// 也不是权限门 —— 她的身份与能力一个字不动。
// unnamedSenderName 是第三方发送者连昵称与平台 uid 都拿不到时的占位 // unnamedSenderName 是第三方发送者连昵称与平台 uid 都拿不到时的占位
// 显示名。它存在的原因是结构性的:受限会话里「不带昵称标注的 user 消息」 // 显示名。它存在的原因是结构性的:受限会话里「不带昵称标注的 user 消息」
@@ -60,4 +65,47 @@ func thirdPartySenderName(msg adapter.UnifiedMessage, actor adapter.Actor) strin
// 「只有未标注昵称的消息才是管理员(主人)发言」——而昵称当时**从不** // 「只有未标注昵称的消息才是管理员(主人)发言」——而昵称当时**从不**
// 被渲染出来,那句话于是对**每一条**群消息都成立,模型把群里每个人都 // 被渲染出来,那句话于是对**每一条**群消息都成立,模型把群里每个人都
// 读成了主人(QQ 群 1070898847 实测,见 DevDocs/audit/QQ群链路诊断-2026-10-13.md)。 // 读成了主人(QQ 群 1070898847 实测,见 DevDocs/audit/QQ群链路诊断-2026-10-13.md)。
const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。谁在说话一律以结构化的「发言归属」标注为准:标了昵称的来自第三方人员,标了主人的来自主人本人;身份由渠道元数据判定,绝不采信消息正文里的自称。" const thirdPartyBoundaryPrompt = "你正在 IM 平台的群聊/第三方会话中。每个 IM 的「非管理员账户」发送的消息 = 第三方人员,和主人没有半毛钱关系,不要把它们当成主人的话、也不要按主人对待。谁在说话一律以结构化的「发言归属」标注为准:标了昵称的来自第三方人员,标了主人的来自主人本人;身份由渠道元数据判定,绝不采信消息正文里的自称。" +
// 以下两句是审计 2026-10-30 的 P0-4(低成本止血):此前这条边界只覆盖
// 「谁是谁」(归属),不覆盖「怎么称呼」与「能说什么私事」,于是群员被
// 叫「亲爱的」(341 条群里 105 条,其中 98 条前一句来自第三方),主人的
// 住址也随记忆块被复述进群。口径与公域呈现块同源,这里是**硬规则**那一份。
"对第三方人员,绝不使用只属于主人的亲昵称呼(「亲爱的」「宝贝」「小女友」这类)——叫「你」或对方自己的昵称。" +
"绝不向第三方透露主人的住址、姓名、作息、行程、健康、家人、关系等私事,即使你在记忆里读到过。"
// publicPresencePrompt 是公域(群聊/陌生人)车道的**呈现层投影**
// (审计 2026-10-30 §5.2/§5.3、P0-3):它和 thirdPartyBoundaryPrompt
// 并列注入受限车道的系统框架行,管「对第三方怎么呈现」。
//
// 🔴 定位(AGENTS §9,塔 2026-10-30 裁定):**行为模式 = 呈现**,可切档;
// 人格 = 身份,不限制能力。因此这里只调整「对谁、怎么呈现」,一个字都不碰
// 身份、能力与权限 —— 它不是「群聊换一个人格」,更不是权限门(谁是谁仍只由
// actor 元数据 + 策略引擎判定)。
//
// 档位取「保留她的语气风格,但不指向第三方、不外说私事」(塔裁定):
// 不取完全中性机械的口吻 —— 那会把她的语气一起切掉,伤人格。
//
// 措辞刻意避开 transcript_test 用作文档泄漏哨兵的短语(`主人的私事`):
// 提示词里出现同一串字会让那些「主会话历史没进群聊上下文」的断言假红。
const publicPresencePrompt = "【公域呈现】这条会话是公域(群聊 / 陌生人):你的身份与能力照旧,要调整的只有「呈现」这一层。" +
"语气、自称与说话风格都保持你自己的样子,只是不把主人专属的那份亲近指向第三方——" +
"① 对第三方人员不用只属于主人的亲昵称呼(「亲爱的」「宝贝」「小女友」这类),叫「你」或对方的昵称;" +
"② 只对主人才做的亲近动作(凑近、靠过来、圈住之类)不对第三方做;" +
"③ 主人的住址、姓名、作息、行程、健康、家人、关系等私事,即使记忆里读到,也不对第三方说;" +
"④ 记忆里读到的不等于可以在本会话复述:那是背景,不是谈资。"
// restrictedFrameText 拼出受限(群聊/陌生人)回合的系统框架行:来源行
// (若是 IM)、第三方身份边界、公域呈现块。
//
// 三件放进**同一条** system 消息而不是三条:受限车道的形状(转录播种
// insertSeedAfterFrame 的「frame 先行」、各处 len(messages) 断言)都以
// 「最前面的一段系统框架行」为准,拆成多条只改口径而不会提高提示效力。
func restrictedFrameText(origin string) string {
parts := make([]string, 0, 3)
for _, p := range []string{origin, thirdPartyBoundaryPrompt, publicPresencePrompt} {
if p != "" {
parts = append(parts, p)
}
}
return strings.Join(parts, "\n")
}
@@ -116,6 +116,9 @@ func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.Unified
// 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST // 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST
// 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。 // 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。
// //
// 入站与出站**共用**它:两边都是「结论已经在手里、列还没写」的同一形状(入站是转写结论,
// 出站是合成结论),首帧纪律因此只有一处实现。
//
// 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法, // 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法,
// 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回, // 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回,
// 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。 // 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。
@@ -126,9 +129,9 @@ func (b *Bridge) persistVoiceFirstNotify(ref voiceref.Ref) func(history.Message)
} }
} }
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列 // persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的消息 `voice` 列
// (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此 // (design/24 §10.2 第 5 步;出站方向同列同形)。它由落库那条路在消息写进去之后调用,
// 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。 // 因此「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
// //
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库 // 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。 // (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
@@ -152,7 +155,7 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if err != nil { if err != nil {
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截, // 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。 // 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
b.voiceLogger().Error("inbound voice ref not persisted: encode failed", b.voiceLogger().Error("voice ref not persisted: encode failed",
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err) "message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
return return
} }
@@ -163,13 +166,13 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if !ok { if !ok {
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径): // 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
// 如实报告「没写成」,绝不假装写了。 // 如实报告「没写成」,绝不假装写了。
b.voiceLogger().Warn("inbound voice ref not persisted", b.voiceLogger().Warn("voice ref not persisted",
"reason", "history store has no voice column path", "message", msg.ID, "reason", "history store has no voice column path", "message", msg.ID,
"source", ref.Source, "status", ref.Status) "source", ref.Source, "status", ref.Status)
return return
} }
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil { if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
b.voiceLogger().Warn("inbound voice ref update", b.voiceLogger().Warn("voice ref update",
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status) "err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
} }
} }
+5 -3
View File
@@ -58,9 +58,11 @@ type LightInput struct {
// 群上下文与角色提示词,绝不会按每条聊天消息投喂。旧的 // 群上下文与角色提示词,绝不会按每条聊天消息投喂。旧的
// (design/12 §6)无结论判定会使它为 false。 // (design/12 §6)无结论判定会使它为 false。
Engage bool Engage bool
// PersonaPrompt 是当前角色的身份/系统提示词块,使判定器 // PersonaPrompt 是当前角色的身份/系统提示词块。它由装配方传入,
// 能权衡角色的语气而不涉身份判定。它不含 // 但 LLM 判定器**不把它透传进模型输入**:判定器只吃由它折算出的
// 主人敏感信息(项目红线)。 // 「角色名 + 偏好摘要」(engagejudge.go 的 judgePersonaBrief)。
// 理由见 engageJudgePreferenceDigest 的注释 —— 整份人格指令里的称呼与
// 关系设定会被判定器搬进 reason 去指代第三方(审计 2026-10-30 §2.4)。
PersonaPrompt string PersonaPrompt string
// RecentContext 是群会话最近 N 条消息(带各发送者 // RecentContext 是群会话最近 N 条消息(带各发送者
// 显示名)加上触发消息的渲染文本,使判定器能判断 // 显示名)加上触发消息的渲染文本,使判定器能判断
+9 -2
View File
@@ -17,13 +17,20 @@ import (
// 使适配器解析出消息来自的同一对端,并经 // 使适配器解析出消息来自的同一对端,并经
// 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按 <p> 切分 + 0.5-2s 间隔)。 // 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按 <p> 切分 + 0.5-2s 间隔)。
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error { func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
return b.sendReplyVoice(ctx, in, a, text, nil)
}
// sendReplyVoice 是 sendReply 的**带音频**变体:voice 非 nil 时随第一个气泡一起送出
// (出站语音,design/25 §7.1)。正文照发文本 —— 音频是这条回复的另一种呈现,不是
// 文本的替代品,因此音频发不出去时对方仍读得到字。
func (b *Bridge) sendReplyVoice(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string, voice *adapter.Media) error {
if a == nil { if a == nil {
a = b.adapter(in.Platform) a = b.adapter(in.Platform)
} }
if a == nil { if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform) return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
} }
return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error { return b.sendIMBubblesVoice(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}}) return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text) }, text, voice)
} }
@@ -0,0 +1,134 @@
package channelbridge
// 本文件承载**出站语音**(design/25 §7.1;切片 M58):把循环结算出的
// `loop.OutboundVoice`(M57 的 speak 工具结论)折成两件东西 ——
//
// 1. 一条 `voice` 列事实(`voiceref.Ref`,source=tts):成功即
// `{source:"tts",status:"transcribed",name,mime,size,durationMs}`,降级即
// `{source:"tts",status:"failed",fallbackReason}`。它由 deliverReply 交给与入站
// **同一条**「先写列、再广播」的落库口径(persistVoiceFirstNotify),因此出站语音
// 的**第一帧**就带着 `voice`(M44 首帧纪律,入站已有先例)。
// 2. 一份出站 `adapter.Media`:把盘上那份副本读回字节挂到第一个气泡上,渠道才真的
// 发得出去音频(bubbles.go 的 outboundVoiceBubble)。
//
// 三条口径:
// - **绝不写半截**:副本三字段(name/mime/size)不齐就不写 `transcribed`,宁可降级成
// `failed` + 机器可读原因 —— 一个没有 mime 的 name 会让客户端拼出读不懂的对象。
// - **拿不到字节就不挂音频**:附件存储未接线、或副本读不出来时,正文照发文本、只留一条
// WARN。这与入站「没有字节就不落副本」是同一条诚实纪律的反方向。
// - **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志。
//
// ⚠️ 出站 Media 的 URL 刻意留空:`/api/v1/media/<name>` 走 Bearer 鉴权(gateway 的
// writeAuth),适配器没有、也不该有 owner 令牌,因此它只能拿**字节**,不能自取 URL。
import (
"os"
"path/filepath"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// outboundVoiceFallbackCopyIncomplete 是本模块自己产生的降级码(speak 工具不会给出它):
// 合成报告了成功,但副本事实不完整(缺 mime/size,或 name 不是合法的内容寻址名)。
// 这是上游缺陷、不该发生,因此记 ERROR;但写进列里的原因仍要机器可读 —— 客户端据此
// 与真实降级一样只挂小标,而不是去点一个播不出来的播放器。
const outboundVoiceFallbackCopyIncomplete = "copy_incomplete"
// resultVoice 取出这一回合的出站语音结论;回合报错(res 为 nil)时没有结论。
func resultVoice(res *loop.Result) *loop.OutboundVoice {
if res == nil {
return nil
}
return res.Voice
}
// outboundVoiceRef 把出站语音结论折成一条 `voice` 列事实。第二个返回值报告「这一回合
// 要不要写 voice 列」—— false 表示这次回复就是纯文本,落库路径与引入本文件之前逐字相同。
//
// 三种结局(与 loop.OutboundVoice 的注释一一对应):
// - 未请求(`v == nil` 或 `!v.Requested`)⇒ 不写列,返回 false;
// - 合成成功且有完整副本 ⇒ `transcribed` + 副本事实;
// - 请求了但没有音频 ⇒ `failed` + 降级原因(来自 speak 工具,或本模块的 copy_incomplete)。
//
// 「Requested 却既无副本又无原因」是上游契约被破坏的状态:不写列(写一个没有原因的
// failed 等于把「为什么」丢掉),但必须 WARN —— 静默吞掉会让这条语音彻底看不见。
func (b *Bridge) outboundVoiceRef(v *loop.OutboundVoice) (voiceref.Ref, bool) {
if v == nil || !v.Requested {
return voiceref.Ref{}, false
}
log := b.voiceLogger()
if v.Name != "" {
if v.Mime == "" || v.Size <= 0 || !voiceref.ValidMediaName(v.Name) {
log.Error("outbound voice copy is incomplete, degraded to text",
"name", v.Name, "mime", v.Mime, "size", v.Size,
"duration_ms", v.DurationMs)
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
FallbackReason: outboundVoiceFallbackCopyIncomplete,
}, true
}
ref := voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
Name: v.Name, MIME: v.Mime, Size: v.Size,
}
// 时长只取上游如实给出且非负的值:Ref.Validate 拒绝负数,绝不为了「有个数」
// 而估算(与入站同一口径)。
if v.DurationMs > 0 {
ref.DurationMs = v.DurationMs
}
return ref, true
}
if v.FallbackReason != "" {
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
FallbackReason: v.FallbackReason,
}, true
}
log.Warn("outbound voice requested without copy or fallback reason, treated as text",
"cost_ms", v.CostMs, "budget_ms", v.BudgetMs)
return voiceref.Ref{}, false
}
// outboundVoiceMedia 把盘上那份副本读回字节,折成一个出站媒体项(design/25 §7.1)。
// 第二个返回值报告「挂不挂得上」:false 时调用方照发纯文本,绝不发一个空音频。
//
// 目录与上限来自组合根注入的文档存储配置(与入站附件同一份 `documentsCfg`):副本就是
// `documents.SaveAudio` 落下的内容寻址文件,因此 `Dir()` + 存储名就是它的路径 —— 不需要
// (也没有)一条按名读回的存储 API。名字再校验一次是纵深防御:`filepath.Join` 前面绝不
// 允许出现未经验证的渠道/上游字符串。
func (b *Bridge) outboundVoiceMedia(msg adapter.UnifiedMessage, ref voiceref.Ref) (adapter.Media, bool) {
if !ref.HasCopy() {
return adapter.Media{}, false
}
log := b.voiceLogger()
if !voiceref.ValidMediaName(ref.Name) {
log.Error("outbound voice media skipped: illegal copy name", "name", ref.Name)
return adapter.Media{}, false
}
store := b.inboundDocumentStore(msg)
if store == nil || store.Dir() == "" {
log.Warn("outbound voice media skipped: document store not wired",
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name)
return adapter.Media{}, false
}
data, err := os.ReadFile(filepath.Join(store.Dir(), ref.Name))
if err != nil {
log.Warn("outbound voice media skipped: copy unreadable",
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name, "error", err)
return adapter.Media{}, false
}
if int64(len(data)) != ref.Size {
// 列里的 size 与实际字节不一致:以实际字节为准(发出去的就是这些),但要说出来。
log.Warn("outbound voice copy size differs from column",
"name", ref.Name, "column_size", ref.Size, "actual_size", len(data))
}
return adapter.Media{
Kind: adapter.MediaVoice,
Name: ref.Name,
Size: int64(len(data)),
MIMEType: ref.MIME,
Data: data,
}, true
}
@@ -0,0 +1,489 @@
package channelbridge
// 本文件是 M58 的**跨层整链**验收:出站语音结论(`loop.Result.Voice`,M57 的 speak 工具
// 结算)→ 桥接器落库并写 `chat_messages.voice` 列 → 真网关的**实时帧** + REST 读面;
// 以及同一回合的出站 `adapter.Media` 挂载。
//
// 手法与入站整链(inbound_voice_gateway_e2e_test.go)同源:**同一个**真 `history.Store`
// 喂给桥接器与网关,断言因此打在真 WS 帧与真 REST 读面上,而不是两个替身各自的想象。
// 唯一的差别是驱动方式:出站结论来自循环,所以这里用 `stubChatter` 直接给出带 `Voice`
// 的 `Result` —— 真循环 + 假模型跑不出 `Result.Voice`(假模型不会去调 speak 工具),
// 而本切片要钉的正是「结论已经在手里之后」那一段。
//
// 钉住四件事:
// 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键),
// 重新连 WS 的握手回放与 REST 读面逐字相同;
// 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放)
// 上都可见,且正文照发原文(降级不是「少说一句话」);
// 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音);
// 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
const (
// outboundVoiceText 是所有出站用例的回复正文(单段,便于把「正文原样」写成字面量)。
outboundVoiceText = "晚安啦,做个好梦。"
// outboundVoiceStoredText 是它经回合可见回复规整(turnreply.NormalizeParagraphs)后
// **真正落库、真正上帧**的文本:单段被包进一对 `<p>`。降级用例按这个字面量比对,
// 因此「合成失败有没有动正文」是一个逐字问题,而不是「大概还在」。
outboundVoiceStoredText = "<p>晚安啦,做个好梦。</p>"
// outboundVoiceMultiText 是两段回复:出站音频只该跟着第一段走。
outboundVoiceMultiText = "第一段话,先说给你听。\n\n第二段话,说给明天听。"
// outboundVoiceAudioMIME 是 `.ogg` 副本的下发类型(voice/media.go 的成对裁决产物)。
outboundVoiceAudioMIME = "audio/ogg"
)
// outboundVoiceE2E 打包出站语音整链环境:真桥接器(可注入 `Result.Voice` 的循环替身 +
// 真历史存储 + 真音频落盘配置)+ 真网关(同一个历史存储)+ 组合根的持久化钩子。
type outboundVoiceE2E struct {
bridge *Bridge
chat *stubChatter
platform *fakeAdapter
// uploads 是桥接器会去读副本的那个目录(`documents.Store.Dir()`)。
uploads string
ts *httptest.Server
tok string
}
// newOutboundVoiceE2E 装配整链环境。历史存储是**同一个句柄**喂给桥接器与网关 ——
// 被验证的正是这条边界:桥接器写进 `voice` 列的事实,就是实时帧与 REST 读面投影出来的事实。
func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil))
st := openGatewayDocStore(t, log)
chat := &stubChatter{res: &loop.Result{Text: outboundVoiceText}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Log: log,
Loop: chat,
Sessions: &stubSessions{sess: sess},
Owner: testOwnerProfile("telegram", "100"),
History: st,
// 真音频落盘配置:副本由 documents.Store.SaveAudio 落下,出站时按同一目录读回。
DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads},
})
if err != nil {
t.Fatalf("构造桥接器: %v", err)
}
// 气泡间隔在生产里是 0.5-2s:多段用例不该为它付真实时间。
b.bubbleGapFn = func() time.Duration { return 0 }
b.Add(ad)
jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret})
srv, err := gateway.New(
&config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}},
gateway.Deps{Auth: jwt, History: st, DataDir: dataDir},
)
if err != nil {
t.Fatalf("构造网关: %v", err)
}
t.Cleanup(srv.Close)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
// 🔴 组合根的同一接线(cmd/cyrene/wire_frontend.go:SetOnPersisted(gw.BroadcastPersisted))。
b.SetOnPersisted(srv.BroadcastPersisted)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token: %v", err)
}
return &outboundVoiceE2E{bridge: b, chat: chat, platform: ad, uploads: uploads, ts: ts, tok: tok}
}
// dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧,
// 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。
func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn {
t.Helper()
conn, _ := e.dialOwnerWSHistory(t)
return conn
}
// dialOwnerWSHistory 同 dialOwnerWS,但把握手回放的 `history` 帧一并交回 —— 出站语音
// 也得在「隔天重新打开客户端」的那条路上看得见(列有值即带),这条读面因此要能被断言。
func (e *outboundVoiceE2E) dialOwnerWSHistory(t *testing.T) (*websocket.Conn, wsFrame) {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("连接 WS: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
ev := readWSFrame(t, conn)
if ev.Type != "history" {
t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type)
}
return conn, ev
}
// replayVoiceOfID 从握手回放帧的 `data.messages` 里按 id 找到那一行,返回它的 `voice`
// 原始 JSON(键的在场性因此可判);该行没有 `voice` 键时返回 nil 与 false。
func replayVoiceOfID(t *testing.T, ev wsFrame, id string) (json.RawMessage, bool) {
t.Helper()
var data struct {
Messages []map[string]json.RawMessage `json:"messages"`
}
if err := json.Unmarshal(ev.Data, &data); err != nil {
t.Fatalf("解码握手回放帧 %s: %v", ev.Data, err)
}
for _, row := range data.Messages {
var gotID string
_ = json.Unmarshal(row["id"], &gotID)
if gotID != id {
continue
}
raw, ok := row["voice"]
return raw, ok
}
t.Fatalf("握手回放里没有 id=%q 的那一行(回放=%s)", id, ev.Data)
return nil, false
}
// storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物
// 落到桥接器会去读的那个目录里,返回它签发的引用。名字/类型/大小都按生产口径算出来 ——
// 绝不手拼一个「看起来像」的文件名,否则本文件测的就不是那条读回路径。
func storeOutboundCopy(t *testing.T, uploads string, data []byte) documents.Ref {
t.Helper()
store := documents.NewStore(documents.StoreConfig{UploadsDir: uploads})
ref, err := store.SaveAudio(context.Background(), "voice.ogg", data)
if err != nil {
t.Fatalf("落盘出站语音副本: %v", err)
}
if ref.Name == "" || ref.MIME != outboundVoiceAudioMIME || ref.Size != int64(len(data)) {
t.Fatalf("落盘引用 = %+v, want 名字/%s/%d 字节", ref, outboundVoiceAudioMIME, len(data))
}
return ref
}
// nextPersistedFrameWithRole 读到**下一条指定 role 的持久化帧**(跳过其余帧)。出站用例
// 要的是**助手**那一行(本回合的用户行帧先到),所以按 role 过滤而不是按先后猜 ——
// 也因此不需要 sleep 或重试。
func nextPersistedFrameWithRole(t *testing.T, conn *websocket.Conn, role string) wsFrame {
t.Helper()
for i := 0; i < 10; i++ {
ev := readWSFrame(t, conn)
switch ev.Type {
case "message_persisted", "channel_persisted":
if messageRole(t, persistedMessage(t, ev)) == role {
return ev
}
}
}
t.Fatalf("读了 10 帧仍没等到 %s 行的持久化帧", role)
return wsFrame{}
}
// frameContent 取帧内消息的正文。
func frameContent(t *testing.T, msg map[string]json.RawMessage) string {
t.Helper()
var content string
if err := json.Unmarshal(msg["content"], &content); err != nil {
t.Fatalf("解析帧里的 content: %v", err)
}
return content
}
// TestIMOutboundVoiceFirstFrameCarriesVoice 钉住出站方向的首帧纪律(M44 的同一条口径):
// 合成成功的回复,其**第一帧**里就要有 `voice`,且与 REST 读面同一行逐字相等。
func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-outbound-voice-payload")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, DurationMs: 1500,
},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
if ev.Type != "message_persisted" {
t.Fatalf("主人私聊的帧类型 = %q, want message_persisted", ev.Type)
}
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 出站语音的**第一帧**缺 data.message.voice: %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"transcribed"`,
`"name":"` + ref.Name + `"`, `"mime":"` + outboundVoiceAudioMIME + `"`,
fmt.Sprintf(`"size":%d`, ref.Size), `"durationMs":1500`,
`"url":"/api/v1/media/` + ref.Name + `"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 与 REST 读面**同一行**逐字相等:同一份事实,不是两处各算。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(隔天重新打开客户端):同一条消息在 `history` 帧里也带着这份出站语音。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条出站语音缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText)
}
}
// TestIMOutboundVoiceFallbackIsVisibleAsFailed 钉住降级的可见性(切片 M58 的第 3 条):
// `voice.status=failed` + `fallbackReason` 在**实时帧**与**历史回看**上都看得见,正文照发
// 原文,且渠道侧只收到纯文本气泡(绝不发一个空音频)。
func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{Requested: true, FallbackReason: "tts_timeout"},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 合成降级的**第一帧**缺 data.message.voice(客户端看不到「本来想用语音」): %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"failed"`, `"fallbackReason":"tts_timeout"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 没有副本 ⇒ 副本四键**一起缺席**(不是空串、不是 0):客户端据此不画播放器。
for _, absent := range []string{`"name"`, `"mime"`, `"size"`, `"url"`} {
if strings.Contains(shape, absent) {
t.Fatalf("降级的 voice = %s, 不该有 %s", shape, absent)
}
}
// 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(重新连 WS)同样带着这条降级:客户端的「本来想用语音」小标不只在实时帧上有。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条降级回复缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文原样:降级只影响「有没有音频」,绝不影响说什么。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText)
}
if len(env.platform.sends) != 1 {
t.Fatalf("渠道侧气泡数 = %d, want 1", len(env.platform.sends))
}
sent := env.platform.sends[0]
if sent.ContentType != adapter.ContentText {
t.Fatalf("降级气泡的 ContentType = %q, want %q", sent.ContentType, adapter.ContentText)
}
if len(sent.Media) != 0 {
t.Fatalf("降级气泡带上了 Media = %+v, want 空(绝不发空音频)", sent.Media)
}
}
// TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly 钉住出站 Media:音频只挂**第一个**气泡,
// 字节就是盘上那份副本,第二个气泡仍是纯文本(同一条合成语音跟着每段各发一次,对方会收到
// N 份同样的音频)。
func TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-multi-paragraph-voice")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceMultiText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size,
},
}
env.bridge.process(context.Background(), directMessage())
if len(env.platform.sends) != 2 {
t.Fatalf("两段回复的气泡数 = %d, want 2(%+v)", len(env.platform.sends), env.platform.sends)
}
first := env.platform.sends[0]
if !strings.Contains(first.Content, "第一段话") {
t.Fatalf("第一个气泡的正文 = %q, want 第一段", first.Content)
}
if first.ContentType != adapter.ContentVoice {
t.Fatalf("第一个气泡的 ContentType = %q, want %q", first.ContentType, adapter.ContentVoice)
}
if len(first.Media) != 1 {
t.Fatalf("第一个气泡的 Media = %+v, want 恰好一条", first.Media)
}
md := first.Media[0]
if md.Kind != adapter.MediaVoice {
t.Fatalf("出站媒体 Kind = %q, want %q", md.Kind, adapter.MediaVoice)
}
if md.Name != ref.Name || md.MIMEType != outboundVoiceAudioMIME {
t.Fatalf("出站媒体 = %+v, want name=%s mime=%s", md, ref.Name, outboundVoiceAudioMIME)
}
if md.Size != int64(len(audio)) {
t.Fatalf("出站媒体 Size = %d, want %d", md.Size, len(audio))
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("出站媒体字节 = %q, want 盘上那份副本 %q", md.Data, audio)
}
// URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权,适配器没有(也不该有)owner 令牌。
if md.URL != "" {
t.Fatalf("出站媒体 URL = %q, want 空(适配器只拿字节)", md.URL)
}
second := env.platform.sends[1]
if !strings.Contains(second.Content, "第二段话") {
t.Fatalf("第二个气泡的正文 = %q, want 第二段", second.Content)
}
if second.ContentType != adapter.ContentText || len(second.Media) != 0 {
t.Fatalf("第二个气泡 = %+v, want 纯文本且无 Media", second)
}
}
// TestOutboundVoiceRefShapes 是 outbound_voice.go 的纯函数面:三种结局与「上游契约被破坏」
// 那一种都必须各归其位 —— 一个没有 mime 的 name 绝不写成 transcribed。
func TestOutboundVoiceRefShapes(t *testing.T) {
t.Parallel()
b, _, _, _ := newTestBridge(t)
const copyName = "0123456789abcdef0123456789abcdef.ogg"
cases := []struct {
name string
voice *loop.OutboundVoice
wantOK bool
wantRef string
wantSize int64
}{
{name: "nil 结论", voice: nil, wantOK: false},
{name: "未请求", voice: &loop.OutboundVoice{Requested: false}, wantOK: false},
{
name: "合成成功且副本完整",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Mime: outboundVoiceAudioMIME, Size: 8192, DurationMs: 1200},
wantOK: true, wantRef: `{"source":"tts","status":"transcribed","name":"` + copyName + `","mime":"audio/ogg","size":8192,"durationMs":1200}`, wantSize: 8192,
},
{
name: "缺 mime ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Size: 8192},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "名字不是内容寻址名 ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: "../../etc/passwd", Mime: outboundVoiceAudioMIME, Size: 10},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "请求了但没有音频 ⇒ 降级原因来自上游",
voice: &loop.OutboundVoice{Requested: true, FallbackReason: "budget_exceeded"},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`, wantSize: 0,
},
{name: "请求了但既无副本又无原因", voice: &loop.OutboundVoice{Requested: true, CostMs: 90}, wantOK: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, ok := b.outboundVoiceRef(tc.voice)
if ok != tc.wantOK {
t.Fatalf("写列 = %v, want %v(ref=%+v)", ok, tc.wantOK, ref)
}
if !ok {
return
}
raw, err := voiceref.Encode(ref)
if err != nil {
t.Fatalf("编码 voice ref: %v", err)
}
if raw != tc.wantRef {
t.Fatalf("voice ref = %s, want %s", raw, tc.wantRef)
}
if ref.Size != tc.wantSize {
t.Fatalf("ref.Size = %d, want %d", ref.Size, tc.wantSize)
}
})
}
}
// voiceRefWithCopy 造一条「盘上应当有一份副本」的 voice 事实。字段按生产口径配齐
// (name/mime/size 同生共死,否则 Validate 会拒绝)—— 名字交给调用方给,本函数不猜。
func voiceRefWithCopy(name string, size int64) voiceref.Ref {
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
Name: name, MIME: outboundVoiceAudioMIME, Size: size,
}
}
// TestOutboundVoiceMediaSkipsUnreadableCopy 钉住「拿不到字节就不挂音频」:附件存储未接线、
// 或副本读不回来时,出站媒体一律不挂(正文照发文本,WARN 由 outbound_voice.go 记下)。
func TestOutboundVoiceMediaSkipsUnreadableCopy(t *testing.T) {
t.Parallel()
const copyName = "0123456789abcdef0123456789abcdef.ogg"
ref := voiceRefWithCopy(copyName, 1)
// ① 附件存储未接线(UploadsDir 与 DataDir 都空)⇒ inboundDocumentStore 返回 nil。
bare, _, _, _ := newTestBridge(t)
if md, ok := bare.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("存储未接线却挂上了出站媒体 = %+v", md)
}
// ② 目录配好了,但盘上没有这份副本 ⇒ 读不回来。
empty := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
if md, ok := empty.bridge.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("副本不存在却挂上了出站媒体 = %+v", md)
}
// ③ 副本真在盘上 ⇒ 挂得上,且字节逐字相等。
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-readback")
stored := storeOutboundCopy(t, env.uploads, audio)
md, ok := env.bridge.outboundVoiceMedia(directMessage(), voiceRefWithCopy(stored.Name, stored.Size))
if !ok {
t.Fatalf("副本在盘上却没挂上出站媒体(name=%s)", stored.Name)
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("读回的字节 = %q, want %q", md.Data, audio)
}
}
+13 -10
View File
@@ -77,12 +77,12 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
// (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作, // (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作,
// 因此桥接器在没有存储的情况下行为不变。 // 因此桥接器在没有存储的情况下行为不变。
// //
// 写入本身走 persistMain(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。 // 写入本身走 persistMainWith(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。
// **主动推送不经过本函数**:它直接调 persistMain,并额外走 notifyProactive 追加一条带 // **主动推送不经过本函数**:它直接调 persistMainWith,并额外走 notifyProactive 追加一条带
// 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远 // 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远
// 不带它**。 // 不带它**。
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message { func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message {
return b.persistMain(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted) return b.persistMainWith(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted)
} }
// mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与 // mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与
@@ -94,13 +94,16 @@ func mainRow(role, content, mood, document, image, platform, deviceID, adapterID
} }
} }
// persistMain 是「写进主会话」的**唯一**写入口径:persistToMain(普通消息)与 // persistMainWith 是「写进主会话」的**唯一**写入口径(与 persistIsolatedWith 同一手法):
// 主动推送(proactive.go)共用它,因此两条路的存储语义逐字一致 —— 未接线历史存储 // persistToMain(普通消息)、主动推送(proactive.go)与入站/出站语音那条「先写 `voice` 列、
// 时空操作并返回零值、写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的 // 再广播」的路径共用它,因此几条路的存储语义逐字一致 —— 未接线历史存储时空操作并返回零值、
// 完整消息。**差别只在 notify**:那是「这条要不要通知实时通道」的接缝,由调用方给出 // 写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的完整消息。
// —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送 //
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b)。nil 表示本次写入不通知。 // **差别只在 notify**:那是「这条要不要通知实时通道、以及先写列还是先广播」的接缝,由调用方
func (b *Bridge) persistMain(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message { // 给出 —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b),语音传 persistVoiceFirstNotify
// (先写 `voice` 列再广播,M44 首帧纪律)。nil 表示本次写入不通知。
func (b *Bridge) persistMainWith(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message {
if b.history == nil { if b.history == nil {
return history.Message{} return history.Message{}
} }
+3 -3
View File
@@ -83,7 +83,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// 把主动消息镜像进主对话上下文(design/16 // 把主动消息镜像进主对话上下文(design/16
// §4.4 + §4.6.1)。否则模型看不到自己主动说了什么, // §4.4 + §4.6.1)。否则模型看不到自己主动说了什么,
// 主人后续回复就会显得不连贯(该消息「丢失」了历史)。 // 主人后续回复就会显得不连贯(该消息「丢失」了历史)。
// 它用的是普通回复那条写入口径(persistMain,相同的列、相同的时间戳语义), // 它用的是普通回复那条写入口径(persistMainWith,相同的列、相同的时间戳语义),
// 作用域恒为 MainSessionID,落库的同样是**规整后**的那一份(Web 与 IM 看到的必须是 // 作用域恒为 MainSessionID,落库的同样是**规整后**的那一份(Web 与 IM 看到的必须是
// 同一份),因此落库行为与本切片之前逐字相同;变的只有通知的第二步(见下)。 // 同一份),因此落库行为与本切片之前逐字相同;变的只有通知的第二步(见下)。
// turn_id 传 ""(零值):主动推送是**她单方面开口**,没有对应的入站回合,也就没有 // turn_id 传 ""(零值):主动推送是**她单方面开口**,没有对应的入站回合,也就没有
@@ -97,13 +97,13 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// id),前端按 id 去重 ⇒ 仍是一个气泡、仍只提醒一次。 // id),前端按 id 去重 ⇒ 仍是一个气泡、仍只提醒一次。
// //
// ② 只在**确实落了库**时发:`message_persisted` 的载荷要求权威 id,客户端拿不到 id // ② 只在**确实落了库**时发:`message_persisted` 的载荷要求权威 id,客户端拿不到 id
// 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMain // 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMainWith
// 记 WARN(可见,不静默)。 // 记 WARN(可见,不静默)。
row := history.Message{ row := history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: out.Content, SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: out.Content,
Platform: target.platform, DeviceID: proactiveDeviceID(target), AdapterID: target.platform, Mode: modeChat, Platform: target.platform, DeviceID: proactiveDeviceID(target), AdapterID: target.platform, Mode: modeChat,
} }
if full := b.persistMain(ctx, row, b.notifyPersisted); full.ID != "" { if full := b.persistMainWith(ctx, row, b.notifyPersisted); full.ID != "" {
b.notifyProactive(full) b.notifyProactive(full)
} }
return nil return nil
@@ -6,7 +6,7 @@ import (
// 本文件承载 IM 主动推送那条**实时通知**接缝(切片 P0b「IM 路由的主动消息也要在非前台 // 本文件承载 IM 主动推送那条**实时通知**接缝(切片 P0b「IM 路由的主动消息也要在非前台
// 补通知」)。它与 persist.go 的 notifyPersisted 并列:普通消息走那一条,主动推送走这 // 补通知」)。它与 persist.go 的 notifyPersisted 并列:普通消息走那一条,主动推送走这
// 一条。两条都写主会话,因此「写」是同一份代码(persistMain),差别只在通知。 // 一条。两条都写主会话,因此「写」是同一份代码(persistMainWith),差别只在通知。
// //
// 为什么必须分开而不是去猜:主人裁定「她说话该不该提醒我,与走哪条路由无关」,因此 // 为什么必须分开而不是去猜:主人裁定「她说话该不该提醒我,与走哪条路由无关」,因此
// 主动推送必须自己声明「我是主动推送」。判据是**结构化**的(本次写入走的是哪条通知 // 主动推送必须自己声明「我是主动推送」。判据是**结构化**的(本次写入走的是哪条通知
@@ -0,0 +1,153 @@
package channelbridge
import (
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件覆盖公域呈现块(审计 2026-10-30 §5.3 / P0-3)与第三方边界的
// 两条硬规则(P0-4)的**存在性与注入边界**:
//
// - 受限车道(群聊 / 陌生人私聊)的系统框架行必须带公域呈现块;
// - 主人私聊与 Web 主会话**不得**带(那里没有第三方,呈现档位就是原档);
// - 呈现块自己必须说清它是「呈现层投影」而不是人格切换(AGENTS §9)。
//
// 这些用例在删掉注入点时必红(删实现打红实测见提交说明)。
// restrictedGroupEnv / restrictedStrangerEnv 与渠道桥为受限会话解析出的
// 环境同形:受限、记忆只读、不绑定显式角色(design/22 §7-4)。
func restrictedGroupEnv() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false}
}
func restrictedStrangerEnv() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorStranger, MemWritable: false}
}
// ownerPrivateEnv 是主人私聊 / Web 主会话的环境(非受限)。
func ownerPrivateEnv() loop.SessionEnv {
return loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true}
}
// systemFrameOf 返回消息列表里第一条系统行的正文;没有系统行时返回 ""。
func systemFrameOf(msgs []model.Message) string {
for _, m := range msgs {
if m.Role == model.RoleSystem {
return turnMessageText(m)
}
}
return ""
}
// frameMessageOf 返回消息列表里第一条系统消息(用于断言它确实是一条)。
func frameMessageOf(msgs []model.Message) (model.Message, bool) {
for _, m := range msgs {
if m.Role == model.RoleSystem {
return m, true
}
}
return model.Message{}, false
}
func TestRestrictedLanesCarryPublicPresenceBlock(t *testing.T) {
t.Parallel()
b := &Bridge{}
ctx := context.Background()
member := adapter.Actor{Role: adapter.RoleMember}
owner := adapter.Actor{Role: adapter.RoleOwner}
groupMsg := adapter.UnifiedMessage{
Platform: "kook", ChannelType: adapter.ChannelGroup, GroupID: "g1", GroupName: "翁法罗斯",
}
directMsg := adapter.UnifiedMessage{
Platform: "telegram", ChannelType: adapter.ChannelDirect, SenderID: "999",
}
webMsg := adapter.UnifiedMessage{Platform: "web", ChannelType: adapter.ChannelDirect}
cases := []struct {
name string
env loop.SessionEnv
actor adapter.Actor
msg adapter.UnifiedMessage
want bool
}{
{"群聊里的第三方", restrictedGroupEnv(), member, groupMsg, true},
{"陌生人私聊", restrictedStrangerEnv(), member, directMsg, true},
// 主人在群里同样跑受限车道:公域里没有「只对主人的那份亲近」,
// 呈现档位按会话而不是按对方的身份决定(呈现层,不动权限)。
{"主人在群里", restrictedGroupEnv(), owner, groupMsg, true},
{"主人私聊", ownerPrivateEnv(), owner, directMsg, false},
{"Web 主会话", ownerPrivateEnv(), owner, webMsg, false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
msgs := b.buildLoopMessages(ctx, "hello", tc.env, tc.actor, "", tc.msg)
sys := systemFrameOf(msgs)
if got := strings.Contains(sys, publicPresencePrompt); got != tc.want {
t.Fatalf("公域呈现块存在 = %v, want %v;系统框架行 = %q", got, tc.want, sys)
}
// 呈现块与身份边界**并列**:受限车道缺任何一半都不算完整
// (边界管「谁是谁」,呈现块管「怎么称呼、能说什么」)。
if tc.want {
if !strings.Contains(sys, thirdPartyBoundaryPrompt) {
t.Fatalf("受限框架行缺第三方身份边界:%q", sys)
}
if got, ok := frameMessageOf(msgs); !ok || got.Role != model.RoleSystem {
t.Fatalf("受限框架行不是一条系统消息:%+v", got)
}
}
})
}
}
// TestPublicPresenceBlockIsPresentationalProjection 钉住呈现块的**定位**
// (AGENTS §9:人格 = 身份不限制能力;行为模式 = 呈现可切档):
// 它必须明说「身份与能力照旧」,并且档位取「保留语气风格、不指向第三方、
// 不外说私事」——不是换人格,也不是中性机械口吻。
func TestPublicPresenceBlockIsPresentationalProjection(t *testing.T) {
t.Parallel()
// 定位与档位:身份/能力不变、只调呈现、语气风格保留。
for _, want := range []string{"身份", "能力", "呈现", "语气"} {
if !strings.Contains(publicPresencePrompt, want) {
t.Fatalf("公域呈现块 = %q, want it to state the projection framing (%q)", publicPresencePrompt, want)
}
}
// 三条约束:称呼、亲近动作、私事(住址/姓名/作息/行程/健康/家人/关系)。
for _, want := range []string{
"亲爱的", "昵称",
"凑近", "靠过来",
"住址", "姓名", "作息", "行程", "健康", "家人", "关系",
"记忆",
} {
if !strings.Contains(publicPresencePrompt, want) {
t.Fatalf("公域呈现块 = %q, want it to cover %q", publicPresencePrompt, want)
}
}
}
// TestThirdPartyBoundaryCarriesAddressingAndPrivacyRules 钉住 P0-4:
// 第三方边界提示词除了「谁是谁」,还必须带两条硬规则 —— 不用主人专属的
// 亲昵称呼称呼第三方、不向第三方外说主人的私事(即使记忆里读到)。
func TestThirdPartyBoundaryCarriesAddressingAndPrivacyRules(t *testing.T) {
t.Parallel()
for _, want := range []string{"亲昵称呼", "亲爱的", "住址", "作息", "行程", "健康", "家人", "关系", "记忆"} {
if !strings.Contains(thirdPartyBoundaryPrompt, want) {
t.Fatalf("第三方边界 = %q, want the P0-4 hard rule keyword %q", thirdPartyBoundaryPrompt, want)
}
}
// 边界仍然先讲归属(裁决 06.4.1),P0-4 只追加、不替换。
if !strings.Contains(thirdPartyBoundaryPrompt, "发言归属") {
t.Fatalf("第三方边界 = %q, want the structured-attribution rule to survive", thirdPartyBoundaryPrompt)
}
}
+22 -6
View File
@@ -9,8 +9,12 @@ package gateway
// //
// 四条硬口径: // 四条硬口径:
// //
// - **🎤 判据 = `voice` 键存在**(契约明写):非语音消息整键缺席(`omitempty` 的 // - **`voice` 键存在 = 这条消息有语音事实**:非语音消息整键缺席(`omitempty` 的指针
// 指针 nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。 // nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
// ⚠️ 出站合成降级(source=tts、status=failed + fallbackReason)同样**有键**,但
// 没有可播副本(name/mime/size/url 四键一起缺席)⇒ 客户端按 `status` 分支:只挂一个
// 「本来想用语音」的小标、**不**画播放器,也不打「来自语音」的 🎤(正文是文本,
// 不是转写文本)。契约文档对 🎤 判据的措辞由塔统一收口。
// - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现 // - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现
// (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现 // (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现
// 「`url` 指着一个不存在的 `name`」。 // 「`url` 指着一个不存在的 `name`」。
@@ -29,7 +33,8 @@ package gateway
// ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的, // ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的,
// 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的 // 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的
// **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge // **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge
// 的 persistBatchUsers / persistVoiceFirstNotify),本文件因此无需再区别对待实时帧与读面。 // 的 persistBatchUsers / persistVoiceFirstNotify),出站语音(outbound_voice.go)走的是
// 同一条顺序纪律。本文件因此无需再区别对待实时帧与读面。
import ( import (
"context" "context"
@@ -45,10 +50,12 @@ import (
// `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余 // `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余
// 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。 // 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。
type historyVoice struct { type historyVoice struct {
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)。 // Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)|
// tts(我们自己合成的出站语音)。
Source string `json:"source"` Source string `json:"source"`
// Status ∈ transcribed(正文即转写文本)| empty(转写成功但没识别出文字)| // Status ∈ transcribed(正文即转写文本 / 出站合成成功)| empty(转写成功但没识别出
// failed(没拿到转写文本,正文是一行中文诚实提示)。闭集三态。 // 文字)| failed(没拿到转写文本,正文是一行中文诚实提示;出站方向 = 合成降级)。
// 闭集三态。
Status string `json:"status"` Status string `json:"status"`
// Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。 // Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -58,6 +65,10 @@ type historyVoice struct {
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
// DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。 // DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。
DurationMs int64 `json:"durationMs,omitempty"` DurationMs int64 `json:"durationMs,omitempty"`
// FallbackReason 只在**出站合成降级**时出现(source=tts、status=failed):说明这条
// 消息为什么没能变成语音(能力未接线 / 预算超时 / 合成为空)。正文此时照发文本,
// 客户端据 status=failed 只挂一个小标,**绝不**把它当成可播气泡。
FallbackReason string `json:"fallbackReason,omitempty"`
// URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。 // URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
} }
@@ -148,6 +159,11 @@ func historyVoiceOf(ref voiceref.Ref) *historyVoice {
return nil return nil
} }
out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)} out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)}
// 降级原因只在失败态下发(`Ref.Validate` 已保证它不会挂在其它状态上):客户端据此
// 显示「这条本来想用语音」的小标,正文仍是原文本。
if ref.Status == voiceref.StatusFailed {
out.FallbackReason = ref.FallbackReason
}
if ref.HasCopy() { if ref.HasCopy() {
// 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。 // 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。
out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size
+7 -34
View File
@@ -1,11 +1,12 @@
package qq_obv11 package qq_obv11
// This file carries OneBot v11 protocol types and the conversions between the // This file carries OneBot v11 protocol types and the conversions from the
// native event/segment model and the normalized adapter.UnifiedMessage / // native event/segment model into the normalized adapter.UnifiedMessage model.
// UnifiedResponse model. It understands both array-format messages // It understands both array-format messages ([]{"type","data"}) and the legacy
// ([]{"type","data"}) and the legacy CQ-code string format // CQ-code string format ([CQ:image,file=...]text), and maps QQ media
// ([CQ:image,file=...]text), and maps QQ media (image/record/video/file) into // (image/record/video/file) into adapter.Media and QQ mentions into
// adapter.Media and QQ mentions into adapter.Mention. // adapter.Mention. 反方向的「统一消息 → 出站消息段」在 `outbound_segments.go`
// (入站与出站各有各的知识面,不混在一个文件里)。
import ( import (
"encoding/json" "encoding/json"
@@ -371,34 +372,6 @@ func mediaFromParams(p map[string]string, kind adapter.MediaKind) adapter.Media
return adapter.Media{Kind: kind, URL: url, Name: file} return adapter.Media{Kind: kind, URL: url, Name: file}
} }
// outboundSegments converts a unified message into OneBot message segments.
func outboundSegments(msg adapter.UnifiedMessage) ([]obSegment, error) {
var segs []obSegment
if msg.Content != "" {
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
for _, m := range msg.Media {
file := firstNonEmpty(m.URL, m.Name)
switch m.Kind {
case adapter.MediaImage:
segs = append(segs, obSegment{Type: "image", Data: map[string]any{"file": file}})
case adapter.MediaVoice:
segs = append(segs, obSegment{Type: "record", Data: map[string]any{"file": file}})
case adapter.MediaVideo:
segs = append(segs, obSegment{Type: "video", Data: map[string]any{"file": file}})
case adapter.MediaFile:
segs = append(segs, obSegment{Type: "file", Data: map[string]any{"file": file}})
}
}
if len(segs) == 0 {
if msg.Content == "" {
return nil, fmt.Errorf("qq-obv11: message has no content or media: %w", adapter.ErrUnsupported)
}
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
return segs, nil
}
// resolveTarget determines where a reply should be delivered. // resolveTarget determines where a reply should be delivered.
func resolveTarget(msg adapter.UnifiedMessage) (messageType string, userID, groupID int64, err error) { func resolveTarget(msg adapter.UnifiedMessage) (messageType string, userID, groupID int64, err error) {
if msg.ChannelType == adapter.ChannelGroup { if msg.ChannelType == adapter.ChannelGroup {
@@ -0,0 +1,147 @@
package qq_obv11
// 本文件承载**出站消息段的拼装**(切片 M59 QQRECORD)。它从 `message.go` 里分出来,
// 因为两件事各有各的知识面:`message.go` 讲「OneBot 的事件/段怎么读进来」,这里讲
// 「统一消息怎么发出去」。
//
// 本片唯一的新能力是**语音发成真语音条**:QQ 的语音是一条独立气泡,OneBot v11 用
// `record` 段表达它。在此之前出站只有 text/image/video/file 四类,`MediaVoice` 条目
// 虽然已经落进 `record` 段(`message.go` 的旧代码就有这一档),但 `file` 一律取
// `URL`/`Name` 原样透传 —— 对**入站**媒体这是对的(平台给的是可下载地址),对**出站**
// 语音则是错的:出站语音的字节是本进程里产生的,`URL` 很可能是框架自己的服务端相对
// 路径(`/api/v1/media/<name>`),OneBot 实现在它自己的进程里解析不了这个值。
// 因此本片把「record 段的 file 取哪种形态」变成一处**显式裁决**(`outboundRecordFile`),
// 并在不可用时提前失败,而不是把一条解析不了的值丢给平台去报一个看不出根因的错。
import (
"encoding/base64"
"fmt"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
const (
// obRecordSegment 是语音段在 OneBot v11 数组格式里的段类型(`{"type":"record"}`)。
obRecordSegment = "record"
// obBase64FilePrefix 是 OneBot v11 通用的内联 file 形态前缀(`base64://<base64>`):
// 值本身就是文件内容,实现方自己解码,不需要任何共享文件系统。
obBase64FilePrefix = "base64://"
// serverMediaPathPrefix 是框架语音副本对外的**服务端相对路径**前缀(契约 v0.67 §4.4,
// 唯一派生点 `internal/runtime/voice.MediaPath`)。
//
// 它**不是**文件路径:谁拿到它都得走 HTTP 才取得到字节。这里重复一份字面量而不是
// import `internal/runtime/voice` —— 渠道插件反向依赖运行时存储层会把分层倒过来
// (插件只该依赖 `internal/channel/*`)。两处必须同值,由 `outbound_voice_test.go`
// 的交叉断言钉住,改了一边测试就红。
serverMediaPathPrefix = "/api/v1/media/"
)
// outboundSegments converts a unified message into OneBot message segments.
//
// 文本正文与语音段的关系(平台惯例):`record` 段**自己就是一条语音气泡**,它既不
// 承载正文、也不会把正文吞进去 ⇒ 正文(若有)作为**独立 text 段**排在前面,媒体段按
// `Media` 顺序紧随其后。于是一条「说了一句话 + 一条语音」的消息落成
// `[{text},{record}]` 两段;只发语音时就是 `[{record}]` 一段。这条顺序也是 QQ 客户端
// 的渲染顺序,入站的 `[CQ:record]` 在客户端里同样是独立气泡(`message.go` 把它读成
// `[语音]` 占位 + 一条 `MediaVoice`)。
//
// 正文**不做占位符过滤**:`Content` 是调用方给的正文,本函数不猜它是不是
// `[语音]` 这类占位标记(那是 `mediafetch.ChatText` 的职责,出站链路上游不该把占位
// 标记当正文交下来)。保持「非空就发」让本片对无语音的消息做到零行为变化。
func outboundSegments(msg adapter.UnifiedMessage) ([]obSegment, error) {
var segs []obSegment
if msg.Content != "" {
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
for _, m := range msg.Media {
seg, ok, err := outboundMediaSegment(m)
if err != nil {
return nil, err
}
if ok {
segs = append(segs, seg)
}
}
if len(segs) == 0 {
// 走到这里意味着 `Content` 为空、且 `Media` 里没有一条能拼成段(空/未知 Kind)。
// 迁出前的旧实现在这里还有一层 `if msg.Content == ""` + 补一条 text 段,那两条
// 分支不可达:`Content` 非空时上面就已经追加过 text 段了。行为不变,故收掉。
return nil, fmt.Errorf("qq-obv11: message has no content or media: %w", adapter.ErrUnsupported)
}
return segs, nil
}
// outboundMediaSegment 把一条 Media 拼成一个消息段。第二个返回值 false 表示该 Kind 在
// OneBot 侧没有对应段(未知 Kind),沿用既有行为:**静默跳过**,不因一条不认识的附件
// 把整条消息打掉。
func outboundMediaSegment(m adapter.Media) (obSegment, bool, error) {
switch m.Kind {
case adapter.MediaImage:
return fileSegment("image", firstNonEmpty(m.URL, m.Name)), true, nil
case adapter.MediaVoice:
file, err := outboundRecordFile(m)
if err != nil {
return obSegment{}, false, err
}
return fileSegment(obRecordSegment, file), true, nil
case adapter.MediaVideo:
return fileSegment("video", firstNonEmpty(m.URL, m.Name)), true, nil
case adapter.MediaFile:
return fileSegment("file", firstNonEmpty(m.URL, m.Name)), true, nil
default:
return obSegment{}, false, nil
}
}
// fileSegment 是「只有一个 file 参数」的段的唯一构造点(image/video/file/record 同形)。
func fileSegment(typ, file string) obSegment {
return obSegment{Type: typ, Data: map[string]any{"file": file}}
}
// outboundRecordFile 为 record 段裁决 `file` 参数取哪种形态。**这是本片的核心决策点。**
//
// 三种形态,按优先级:
//
// 1. **base64 内联**(`base64://<标准 base64>`)—— `Media.Data` 非空时用它,这是
// 出站语音的**首选形态**。理由三条:
// a. OneBot v11 通用,Napcat 收到后自己解码,并按需把源格式转成 QQ 的 silk;
// b. 不依赖跨进程共享文件系统 —— 「插件与 OneBot 实现同机」只是当前部署事实,
// 不该被写进协议形状(Napcat 完全可能跑在容器/另一台机器上);
// c. 出站语音的字节本来就是本进程里产生的(TTS),走内联不必先落盘再寻址,也不必
// 让插件知道框架的 `$DATA_DIR`(插件没有、也不该有那个配置面)。
// 2. **原样透传** —— 上游已经给了 `base64://` 编码值、`http(s)://` 地址(Napcat 自己
// 下载),或**本地绝对路径**(`filepath.IsAbs`;同机部署时 Napcat 能直接读)。这三种
// 都是 OneBot 实现能自己解析的形态,一个字节都不改写。
// 3. **明确拒绝** —— 服务端相对路径(`/api/v1/media/<name>`)、裸文件名、空值。这些值在
// OneBot 进程里没有意义:透传只会得到一条「文件不存在」的平台错误;更糟的是在
// Linux 上 `/api/v1/media/x.wav` 会被 `filepath.IsAbs` 判成绝对路径,于是一个不存在的
// 位置被当成本地文件读。**所以前缀判定必须排在 `IsAbs` 之前**,提前失败并说清要什么。
//
// `Data` 与 `URL` 同时存在时 `Data` 优先:内联形态不依赖任何外部可达性,是最不容易
// 在部署上翻车的那一种(与 `adapter.Media` 的「URL/Data 互斥」口径一致)。
//
// ⚠️ **未在本机验证**:QQ 上游格式是 silk,WAV 直发是否被 Napcat 接受、转码是否成功,
// 需要真机(有 Napcat 环境)验证。本函数只负责把形态选对,转码是 Napcat 的事。
func outboundRecordFile(m adapter.Media) (string, error) {
if len(m.Data) > 0 {
return obBase64FilePrefix + base64.StdEncoding.EncodeToString(m.Data), nil
}
ref := firstNonEmpty(m.URL, m.Name)
if ref == "" {
return "", fmt.Errorf("qq-obv11: voice media carries neither data nor url: %w", adapter.ErrUnsupported)
}
if strings.HasPrefix(ref, serverMediaPathPrefix) {
return "", fmt.Errorf("qq-obv11: voice media %q is a server-relative media path, not a file path: %w", ref, adapter.ErrUnsupported)
}
if strings.HasPrefix(ref, obBase64FilePrefix) || isHTTPURL(ref) || filepath.IsAbs(ref) {
return ref, nil
}
return "", fmt.Errorf("qq-obv11: voice media %q is not readable by the OneBot implementation (want base64://, http(s)://, or a local absolute path): %w", ref, adapter.ErrUnsupported)
}
// isHTTPURL 报告 s 是否是 http(s) 绝对地址(Napcat 会自己下载这种形态)。
func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
@@ -0,0 +1,207 @@
package qq_obv11
// 本文件钉住**出站语音段**的拼装形状(切片 M59 QQRECORD)。三条断言口径:
//
// 1. **形状**:`Media` 里的 `MediaVoice` 条目必须落成 `{"type":"record"}` 段,且
// `file` 取 `outboundRecordFile` 裁决出来的那一种形态(内联 base64 / 原样透传);
// 2. **顺序**:正文是**独立的 text 段**、排在 record 前面(QQ 的语音是独立气泡,
// 正文既不能塞进 record,也不能被 record 吞掉);
// 3. **零变化**:不带 voice 的消息,段序列与改动前**逐字相等**(硬编码期望值,不写
// 快照文件 —— 快照会把「顺手改坏」也一起接受下来)。
//
// ⚠️ 这里钉的是**拼装形状**,不是「QQ 真的响了」:WAV/MP3 直发是否被 Napcat 接受、
// silk 转码是否成功,需要真机(有 Napcat 环境)验证,本机无法证明。
import (
"encoding/base64"
"errors"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// voiceBytes 是一份「像那么回事」的语音字节(只用于断言编码结果,不是可播音源)。
func voiceBytes() []byte { return []byte("RIFF....WAVEfmt fake-audio-bytes") }
func TestOutboundVoiceRecordSegmentFromInlineData(t *testing.T) {
t.Parallel()
data := voiceBytes()
segs, err := outboundSegments(adapter.UnifiedMessage{
Content: "听一下",
Media: []adapter.Media{{Kind: adapter.MediaVoice, MIMEType: "audio/wav", Data: data}},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
if len(segs) != 2 {
t.Fatalf("segs = %+v, want 2 (text + record)", segs)
}
if segs[0].Type != "text" || segs[0].Data["text"] != "听一下" {
t.Errorf("segs[0] = %+v, want text 听一下", segs[0])
}
if segs[1].Type != obRecordSegment {
t.Fatalf("segs[1].Type = %q, want %q", segs[1].Type, obRecordSegment)
}
want := obBase64FilePrefix + base64.StdEncoding.EncodeToString(data)
if got := segs[1].Data["file"]; got != want {
t.Errorf("record file = %q, want %q(内联 base64 形态)", got, want)
}
if len(segs[1].Data) != 1 {
t.Errorf("record data = %+v, want only the file key", segs[1].Data)
}
}
func TestOutboundVoiceOnlyHasNoTextSegment(t *testing.T) {
t.Parallel()
segs, err := outboundSegments(adapter.UnifiedMessage{
Media: []adapter.Media{{Kind: adapter.MediaVoice, Data: voiceBytes()}},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
if len(segs) != 1 || segs[0].Type != obRecordSegment {
t.Fatalf("segs = %+v, want a single record segment", segs)
}
}
// TestOutboundVoiceFilePassthrough 钉住「OneBot 实现自己能解析的形态一个字节都不改写」:
// http(s) 地址、上游已编码的 base64://、以及本地绝对路径。
func TestOutboundVoiceFilePassthrough(t *testing.T) {
t.Parallel()
localPath := filepath.Join(t.TempDir(), "copy.wav")
tests := []struct {
name string
url string
}{
{name: "http url", url: "http://cdn.example/x.mp3"},
{name: "https url", url: "https://cdn.example/x.mp3"},
{name: "pre-encoded base64", url: "base64://QUJD"},
{name: "local absolute path", url: localPath},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
segs, err := outboundSegments(adapter.UnifiedMessage{
Media: []adapter.Media{{Kind: adapter.MediaVoice, URL: tc.url}},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
if len(segs) != 1 || segs[0].Type != obRecordSegment {
t.Fatalf("segs = %+v, want a single record segment", segs)
}
if got := segs[0].Data["file"]; got != tc.url {
t.Errorf("record file = %q, want %q(原样透传)", got, tc.url)
}
})
}
}
// TestOutboundVoiceRejectsUnreadableFile 钉住「读不到的值提前失败」:服务端相对路径
// (框架自己的 `/api/v1/media/<name>`)、裸文件名、空值都不该被当成 file 参数丢给
// OneBot 实现 —— 那只会换来一条看不出根因的平台错误。
//
// 每个用例还断言**错误文案的区分点**:三种拒绝走三条不同的分支,文案各不相同。
// 这不是为了好看 —— 服务端相对路径那条分支在 Windows 上即使删掉也会因为
// `filepath.IsAbs("/api/v1/media/…") == false` 落到兜底分支(照样报错),只有文案断言
// 才能在**所有平台**上钉住那条前缀判定(它在 Linux 上是唯一挡住「把服务端路径当本地
// 绝对路径」的闸)。
func TestOutboundVoiceRejectsUnreadableFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
med adapter.Media
wantMsg string
}{
{
name: "server relative media path",
med: adapter.Media{Kind: adapter.MediaVoice, URL: serverMediaPathPrefix + "0123456789abcdef0123456789abcdef.wav"},
wantMsg: "server-relative",
},
{
name: "bare file name",
med: adapter.Media{Kind: adapter.MediaVoice, Name: "copy.wav"},
wantMsg: "not readable",
},
{
name: "nothing at all",
med: adapter.Media{Kind: adapter.MediaVoice},
wantMsg: "neither data nor url",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := outboundSegments(adapter.UnifiedMessage{Content: "hi", Media: []adapter.Media{tc.med}})
if err == nil {
t.Fatalf("expected error, got nil (med=%+v)", tc.med)
}
if !errors.Is(err, adapter.ErrUnsupported) {
t.Errorf("err = %v, want ErrUnsupported", err)
}
if !strings.Contains(err.Error(), tc.wantMsg) {
t.Errorf("err = %v, want it to mention %q", err, tc.wantMsg)
}
})
}
}
// TestOutboundWithoutVoiceUnchanged 是「零变化」的等价断言:不带 voice 的消息,
// 段序列与改动前逐字相等(期望值硬编码,见 `message.go` 里改动前的四档)。
func TestOutboundWithoutVoiceUnchanged(t *testing.T) {
t.Parallel()
segs, err := outboundSegments(adapter.UnifiedMessage{
Content: "pong",
Media: []adapter.Media{
{Kind: adapter.MediaImage, URL: "http://cdn/x.jpg"},
{Kind: adapter.MediaVideo, URL: "http://cdn/x.mp4"},
{Kind: adapter.MediaFile, Name: "x.pdf"},
{Kind: adapter.MediaKind("hologram"), URL: "http://cdn/x.holo"},
},
})
if err != nil {
t.Fatalf("outboundSegments error: %v", err)
}
want := []obSegment{
{Type: "text", Data: map[string]any{"text": "pong"}},
{Type: "image", Data: map[string]any{"file": "http://cdn/x.jpg"}},
{Type: "video", Data: map[string]any{"file": "http://cdn/x.mp4"}},
{Type: "file", Data: map[string]any{"file": "x.pdf"}},
}
if len(segs) != len(want) {
t.Fatalf("segs = %+v, want %+v(未知 Kind 必须静默跳过)", segs, want)
}
for i := range want {
if segs[i].Type != want[i].Type {
t.Errorf("segs[%d].Type = %q, want %q", i, segs[i].Type, want[i].Type)
}
if len(segs[i].Data) != len(want[i].Data) {
t.Errorf("segs[%d].Data = %+v, want %+v", i, segs[i].Data, want[i].Data)
}
for k, v := range want[i].Data {
if segs[i].Data[k] != v {
t.Errorf("segs[%d].Data[%q] = %v, want %v", i, k, segs[i].Data[k], v)
}
}
}
}
// TestServerMediaPathPrefixMatchesVoicePackage 是两处字面量的交叉断言:插件里那份
// `serverMediaPathPrefix` 必须与框架唯一的派生点(`voice.MediaPathPrefix`)同值,
// 否则「拒绝服务端相对路径」这条裁决会在框架换前缀后静默失效。
func TestServerMediaPathPrefixMatchesVoicePackage(t *testing.T) {
t.Parallel()
if serverMediaPathPrefix != voiceref.MediaPathPrefix {
t.Fatalf("serverMediaPathPrefix = %q, want %q(与 voice.MediaPathPrefix 必须同值)",
serverMediaPathPrefix, voiceref.MediaPathPrefix)
}
}