54 Commits
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 a0b4c941e4 fix(loop): keep cross-batch voice success and gate speak without dispatcher (M57)
两处 review 必修(review-m57 r1):

P2-1 跨批覆盖:applyBatchVoice 原先无条件用后一批的语音结论覆盖
前一批,于是「先成功合成 abc.wav、下一批换个参数再试却失败」会把
Result.Voice 退化成 tts_failed —— 音频已落盘,退回文本既无收益也
违背那次表达。改为守 captureVoice 的同一条优先级:已落盘的音频不被
后续批次的失败抹掉;先降级后成功仍然覆盖(后到的成功即最终语音)。

P2-2 nil-dispatcher 安全门:speak 由循环自己承载、无 dispatcher 也
能执行(invokeOne 短路),但 applyToolAdmissions 在 dispatcher 缺席
时整段跳过安全门,使「没配 dispatcher」成为绕过 design/11 §3 的
旁路。新增 applyDispatcherlessVoiceGate:与策略引擎对未登记工具的
口径一致(默认关闭、只有 owner 放行),拒绝走同一条 not_allowed
软结果与 tool.reject.not_allowed 审计通道;并改正该处过时注释。

两条回归测试都做过改前红/改后绿:
- TestApplyBatchVoice_KeepsCrossBatchSuccess:注释掉守卫即报
  「voice = ... FallbackReason:tts_failed, want abc.wav」
- TestApplyToolAdmissions_DispatcherlessSpeakGate:去掉 else 分支即报
  「env group_member run = true, want false」

验收:go build ./... / go vet ./... / go test ./... 全绿。
2026-10-27 22:00:18 +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 c9da488749 refactor(loop): extract speak voice backfill into applyBatchVoice (M57) 2026-10-27 21:33:05 +08:00
AskaEth 39ae883a4e test(loop): cover the speak tool and Result.Voice (M57)
speak_tool_test.go pins the leaf behaviour: budget formula, extension
whitelist, argument validation, and the success/timeout/no-audio/store-failed
paths, plus the model-surface advertisement and append-once rule.

voice_result_test.go pins the outbound chain end to end: captureVoice's three
states and its last-success-wins rule, a full turn where speak is advertised,
executed through the injected synth/store and lands on Result.Voice, the
degraded turn that still answers in text, the unwired turn that keeps the
surface byte-identical, and the store adapter the wiring will use.
2026-10-27 21:22:20 +08:00
AskaEth 1347a77d33 feat(loop): wire speak tool into the loop (M57)
- speak_tool.go: the speak tool itself — rune-derived timeout budget, structured
  failure codes, persona/emotion stamped on the synthesis ctx, audio persisted
  through the store seam, structured result for the model.
- voice_deps.go: the wiring seam (VoiceSynth / VoiceStore + VoiceStoreFunc
  adapter), voiceEnabled, withSpeakSpec (appended after every schema shrink),
  and captureVoice (success beats failure, last one wins).
- options.go / loop.go: VoiceSynth, VoiceStore, SpeakBudget injection points.
- request.go: advertise speak on both toolSchemas return paths.
- tools.go / tools_invoke.go: carry the batch voice outcome; split invokeOne /
  emitToolExecution / spanStatusForToolPhase / invokeParallel into their own
  file (tools.go was at the 500-line ceiling) and let speak run without a
  dispatcher.
- run.go: keep the voice outcome across tool batches (a later batch without
  speak must not erase it).

Known, deliberate: speak bypasses the capacity allow-set and skill-round
narrowing (it belongs to no built-in toolset), so it is also advertised in
quiet/awake; execution still passes the tool security gate, where speak is not
registered and non-owner actors are denied fail-closed.
2026-10-27 21:22:14 +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
AskaEth f84a358e30 feat(loop): add OutboundVoice result type for speak tool 2026-10-27 20:22:12 +08:00
AskaEth 07df06b86d Merge branch 'feat/b2a-browserexit-redact-browser-exception' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:27:21 +08:00
AskaEth b040f588b2 fix(browser): M53 追加 Chrome 输出预览打码
preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
2026-10-26 22:19:31 +08:00
AskaEth e8bc8727da Merge branch 'feat/b2c-hostredact-vision-hostof-and-tts-red' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:15:21 +08:00
AskaEth b626b3f5c0 Merge branch 'feat/b2d-weixinname-send-md-name-redact-and-l' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:58 +08:00
AskaEth 50cc60d019 Merge branch 'feat/b2b-judgetext-redact-judge-reason-sensit' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 22:11:05 +08:00
AskaEth a1c9da6e8a test(voice/tts): pin fragment-before-query in redactURL fallback
Review r1 P2-01: the only fragment case had '?' before '#', so degrading
stripURLQuery to cut '?' alone stayed green and the '#'-first branch was
unpinned. Add a scheme-less '#access_token=...' case (OAuth implicit
flow shape) and correct the mutation comment to state which case each
mutation actually kills.
2026-10-26 22:11:01 +08:00
AskaEth e39cc5d096 fix(weixin_ilink): drop user file name from media failure log; fix TestLogin_LogRedaction data race 2026-10-26 22:00:09 +08:00
AskaEth 5a69d6e0ff fix(automations): redact reminder message before logging 2026-10-26 21:58:31 +08:00
AskaEth 8058660537 fix(browser): M53 两条页面侧自由文本出口收敛脱敏
- Page.navigate 的 errorText 是**协议枚举**面(net error 码):先剥 `net::`
  命名空间再交给 trace.ErrorToken,像枚举就原样保留、散文一律丢弃只留字节数;
- Runtime.evaluate 的 exceptionDetails.text 是**页面自由文本**面:压平空白
  (防日志行伪造)+ trace.RedactTextLimited 按 200 rune 截断(档位理由见
  errtext.go:80 会截断真实异常消息,4096 等于允许页面塞 4KB 正文);
- 两类守卫不互换(口径见 trace/errortoken.go 的按字段类别判定),清洗实现
  仍只有 trace 那一份,本片只做「选哪一道 + 补形状前提」。

测试:出口 A/B 各一条经 RenderText 的真实路径断言(含 URL/凭据形状的假文本),
外加 net::ERR_* 正向保留、上限截断/换行压平、netErrorCode 形状表。三条泄露断言
已验证「退回原样直吐即红」。
2026-10-26 21:57:48 +08:00
AskaEth e0793debc3 fix(restrict): redact sensitive param summary before it reaches audit/panel 2026-10-26 21:55:25 +08:00
AskaEth 28a6cd7e53 fix(voice/tts): strip query from scheme-less URLs in redactURL
redactURL's fallback returned the raw string when url.Parse found no host.
A scheme-less BaseURL (127.0.0.1:9880/v1?token=...) fails to parse, so the
signed query reached the WARN log and the error chain verbatim. Strip at
? and # in that path, keeping the '@' full-redaction rule first.

Test pins the scheme-less query/fragment cases and goes red if the fix
is reverted.
2026-10-26 21:54:21 +08:00
AskaEth 5099c21520 fix(audit): redact judge reason before it reaches logs and the panel
判定理理由(verdict.Reason)是模型自由文本,判定器会把它在参数摘要里看到的
凭据原样抄进理由,而这条理由要进审计日志与前端小字两个读面。cleanReason
在收敛空白/截断之后统一过 trace.RedactText(80 rune 档),与其余出图文本
走同一套清洗;maxReasonRunes 退化为「进入清洗前」的上限,避免在无界文本上
跑凭据匹配。
2026-10-26 21:52:44 +08:00
AskaEth a5b3d40a74 fix(vision): report only URL origin when an image source is rejected
fetchURL echoed the whole raw URL (path, query, userinfo) in the
ErrUnsupportedSource message, so a rejected source carrying a signed
query leaked it into logs and tickets. Route the URL exit through
trace.HostOf and fall back to a placeholder instead of the raw string.

Test pins origin-only output and goes red if the fix is reverted.
2026-10-26 21:52:19 +08:00
AskaEth d47185feb8 docs(contract): v0.97 voice WS frames + TTS autolaunch + upstream error safety
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
- voice 进 message_persisted/channel_persisted 首帧与握手回放(M44 fb55769,
  INSERT→写列→notify;两条实现前提进契约)
- emotionTTS 自启默认关(M47,voice-tts-launch.json,probeUrl 单向兜底)
- UpstreamError 构造即安全:code/type 固定令牌守卫、message 默认缺席
  (M49/M50,一处行为变更如实标注)
- U6 双凭据门明示(Bearer JWT 或 X-API-Key,既有能力非新增)
- 改正 14 处「WS 帧不带 voice」假话 + 旧横幅加注;尾注 v0.82→v0.97
2026-10-26 21:44:44 +08:00
AskaEth 656c35f1e8 Merge branch 'feat/m51-lastexits-close-remaining-upstream-f' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
# Conflicts:
#	internal/runtime/trace/errortoken.go
2026-10-26 21:25:48 +08:00
AskaEth 921938d084 Merge branch 'feat/m52-urlcausesweep-sweep-url-error-direct' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 20:03:29 +08:00
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00
AskaEth 969857682f fix(runtime): M52 把 *url.Error 直打的 19 处换成 trace.URLCause
model / multimodal / tts / embedding 里所有「把 net/http 的 *url.Error 直接打进
错误串」的地方(M51 清单的 B1 组 17 处 + 清单外的同根因 2 处)统一改走
trace.URLCause:错误串只少一段地址,底层原因、哨兵分类、状态码、重试/降级判据
一字不动。

清单内(17):
- model/openai.go: Chat / ChatStream 的 NewRequest、doRequest
- model/anthropic.go: chatOnce / streamOnce 的 NewRequest、doRequest
- multimodal/vision/adapter.go: ask 的 NewRequest、client.Do
- multimodal/voice/cloud.go: postJSON 的 NewRequest、client.Do
- multimodal/voice/dashscope.go: download(预签名 OSS 地址)与 dashscopePost 各两处
- voice/tts/client.go: unreachable(返回值与同一行日志的 err 字段都只留原因,
  地址只由 redactURL(endpoint) 出现一次)
- embedding/api.go: embedChunk 的 client.Do、newEmbeddingRequest 的 NewRequest

清单外同根因(2,均为 %w 形态,B1 清单按 %v 搜时漏掉):
- voice/tts/client.go: speak 的 NewRequest 失败
- embedding/endpoint.go: normalizeEndpointURL 的 url.Parse 失败

测试:5 个新文件 18 个用例,逐处可变异打红(实测:把任一处的 URLCause 去掉 ⇒
对应用例红);dashscope 预签名地址单独一条用例,并另有一条钉住 403 状态码仍在。
2026-10-26 19:55:05 +08:00
AskaEth 604f9ebaa9 Merge branch 'feat/m50-envelopesafe-drop-envelope-message-o' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:51:33 +08:00
AskaEth bfbf5057bc refactor(weixin): runLogin 的守卫说明上移到函数头,回到 80 行以内
复审 r2:runLogin 70 → 82 行越过 AGENTS.md §10 的函数 ≤80 上限。代码行本来
只有 56,超的是 M50 加在循环体内的两段 🔴 说明。把 status/redirect_host 的
出镜口径整段上移到函数头注释(不参与计数),循环体内只留一行指向函数头;
**控制流与守卫代码零改动**。

改后 runLogin = 76 行(全 plugins/channels 最长 79,其余均 ≤80)。
2026-10-26 19:48:54 +08:00
AskaEth 7fbb8d6232 fix(channelbridge): M51 追加 —— 参与度判定 reason 出镜前打码压平
Debug 日志与返回值共 7 处(engage.go 222/227/228/231/250/256/257 原行号)此前把
判定模型回的 d.Reason 原样写出:模型可以把 Authorization / sk-… 或换行塞进 reason,
前者进日志就是凭据泄漏,后者能按行聚合的日志里伪造一条新记录。

新增 engageReason(trace.RedactTextLimited + strings.Fields,上限 60 rune):
- 控制流零改动:分支条件、状态迁移、返回值拼接结构一个字未动,
  只把「进去的那份文本」从 d.Reason 换成 engageReason(d.Reason);
- 上限 60 rune 的理由写在常量注释里(reason 是一句短语,超过即是在复述群聊原文)。

用例 engage_reason_test.go:PROBING / 跟进 / 回避+无兴趣三条出口,
断言日志与返回值都不含 sk-live…、都含 [redacted]、都不含换行。
变异实测:摘 RedactText ⇒ 三条红;摘压平 ⇒ 三条红。
2026-10-26 19:47:35 +08:00
AskaEth d6493d79f4 fix(upstream): M51 收敛剩余上游自由文本出口 - CDP/MCP/flowgate/channelbridge WARN
- channelbridge: 拨号失败的 WARN 不再带原始 dial 错误(含 ?access_token=),
  URLCause 剥壳 + 内嵌地址 MaskQuery 打码 + HostOf 保留目标来源;控制流零改动
- browser/cdp.go: CDP error.message 只有像协议枚举(ErrorToken)才出镜,其余只留字节数
- mcp: 第三方 server 的错误文本默认不带,只留 server/工具/JSON-RPC 码/message 长度,
  分类链(errors.Is/As、context.Canceled)经 Unwrap 保留
- channel/adapter/flowgate: 审查输出摘要由 40 字节改 40 rune(防切 UTF-8)并过 RedactText

四处均补了会随回退变红的用例(变异已实测)。
2026-10-26 19:44:22 +08:00
AskaEth 1b2576ecaf fix(weixin): 扫码上游字段过令牌守卫,errortoken 契约改写
复审 p2 两条:

① 第五个出口:get_qrcode_status 的 status 与 scaned_but_redirect 的
redirect_host 此前原样进 INFO;同一 status 经 setPhase 还从复用二维码那条
INFO(phase)二次出镜。三处一律过 trace.ErrorToken;**控制流仍用原值**
(状态机读 status.Status、路由读 status.RedirectHost、confirmingPhase 读
phase),用例同时钉住「日志变空串」与「路由/判定不变」两个方向。

② 顺带补扫出一处 URL 类:get_bot_qrcode 的 qrcode_img_content(上游给的
图片地址,可能是带签名的 URL 或 data: 内联图)原样进 INFO ⇒ 改走
trace.MaskQuery(凭据类 query 打码、保留路径、解析不出 host 回空串)。

③ errortoken.go 的契约改写:原「本包之外不应出现 ErrorToken 调用」已被
M50 的正当调用点证伪。新口径按**字段类别**判定:协议枚举字段(status/
code/type/request_id)可在渲染点调用;自由文本字段(message/description/
msg/wording)不得靠它出镜 —— 那类默认不带,要带必须 opt-in + RedactText。
dashscope 的 request_id 例外一并写入。

每条用例都做了变异验证(去掉守卫 / 把守卫挪进控制流 / 改回原串,均打红)。
2026-10-26 19:41:28 +08:00
AskaEth 2702777950 Merge branch 'feat/m45-extracttemporal-extractor-temporal-m' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:33:37 +08:00
AskaEth 82e9213804 test(memory): M45 复看 P2 —— 补「temporal 不许被判定读」的源码级断言 + 注释纠正
P2-1(真缺口):r1 复看的变异 C 证明「在异步审查侧拿 item.Temporal 改判」当时
**整个 memory 包全绿** —— 已落库的行在异步侧被改判这条口径没有守卫,而异步侧
(applyVerdict / setIngestRetention)恰恰最容易被人随手接线。

新增 TestTemporalIsNotWiredIntoJudgement:扫生产代码(非 _test.go、非注释行)里
所有含 `.Temporal` 的行,逐行要求命中 temporalFieldSites 白名单(校验 3 条 / 产出 1 条,
**片段级**而不是文件级 —— 按文件放行会让同一文件里新写的接线路过),违规时报
「文件:行号: 表达式片段」;同时校验白名单每一条确实命中过,防止它老化成空头许可证。

变异实测(复看给的写法,插在 preinsert.go 的 applyVerdict 入口):
  --- FAIL: TestTemporalIsNotWiredIntoJudgement
      temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)…:
          preinsert.go:476: if item.Temporal == TemporalOneOff {
  删掉接线后全绿。

P2-2(文案与 D 矛盾):store_temporal.go 的归属清单原写「判定在 resolveRetention
(唯一入口,第 4 步)」—— D 之后第 4 步是 N1~N7、没有 temporal 步,那句等于给后来者
指了接线入口。改为:temporal 只被记录、不参与判定;要把 temporal 接进判定必须先拿到
模型真实标签分布,并只能通过 temporalFieldSites 白名单(有意的接线决定)进来。

另按复看提醒:temporal.go 头注里那两条统计 SQL 标明「**运维分析用,不属于读侧代码
路径**」,并写明读侧(timegate 与任何读侧 SQL)明令不许对 created_at/expires_at 用
datetime() —— 那是定宽文本比较,抄进查询层会静默判错。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race 38.0s 全绿。
2026-10-26 19:29:16 +08:00
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00
AskaEth c7c0294a16 refactor(memory): M45 裁决 D —— temporal 位撤出判定,只保留观测
按 r1 复审裁决 D:这一位先只做观测,`resolveRetention` 不许读它。理由(实测):
无条件 one_off ⇒ temp 会当天删 21 条长期约定;只放开 N6 位置判据能翻的 13 条
逐条是约定、而复审点名的三条目标行 0/3 救得了。在「模型真实标签分布」这份证据
缺位时不该做 7 天后物理删的不可逆决定。

撤接线(结论集逐字回到 M45 之前):
- retention.go:resolveRetention 不再读 item.Temporal;与之对比 dev base
  (af61b8c)本文件**只有注释差异**,规则与判定入口逐字未变。
- extract_retention.go:judgeExtractionRetention 签名回退单参;删除
  judgeRetentionTimepointWide 与 n6_timepoint_wide 规则码。与 dev base 对比
  **同样只有注释差异** ⇒ 「结论集 = 不含 temporal 的那个集合」是结构性的,
  不是靠调用点记得别传。
- 连带取消初版的 durable / recurring ⇒ long(零影响必须是双向的)。

保留观测面(一律未动):迁移 0027 的列、自举前滚、提取器的 temporal 产出、
prompt 枚举、闭集与 normalizeTemporal、store_temporal.go 的校验/编码。
另外把 prompt 里「标成 one_off 会让它进临时层、到期消失」这句**撤接线后已是假话**
改成中性标注说明 —— 否则会诱导模型避开 one_off,观测数据直接被污染。

测试:
- TestTemporalBitDoesNotAffectRetention —— 4 类语料 × 5 个取值,(档位,规则码)
  与未分型逐字相同(核心正断言);
- TestTemporalOneOffDoesNotMakeAnythingTemporary / ...DoesNotRescueRuleHitRows
  —— 零影响的双向点名;
- TestTemporalIsStillRecorded —— one_off 的约定行仍落 temporal='one_off' 而档位
  long、expires 空(观测在、判定不动);
- TestTemporalDoesNotTouchExplicitTierOrRestrictedSource —— 人工入口与受限来源不变。
变异实测:把 `if item.Temporal == TemporalOneOff { return temp }` 插回
resolveRetention ⇒ 上述四条用例全红(FAIL 输出已贴给塔),恢复后全绿。

IngestRuleVersion 保持 d3.5,注释改准为「记录 temporal 信号,但不参与判定」
(留痕面变了:库里多了「模型当时标了什么」这一维,回退会让新旧留痕混在一起)。

P2 三条保留:store.go 500 行(拆 store_temporal.go)、temporal.go 头注('' 的口径
+ 两次被否的接线方案 + 接线前必须看的那两条统计 SQL)、报告语境段。

回归:go build ./... + go vet ./... 全绿;memory/db 包测试全绿;memory -race
38.2s 全绿。
2026-10-26 19:21:24 +08:00
AskaEth 3a0cc64160 Merge branch 'feat/m49-upstreamsafe-move-token-guard-into-t' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
2026-10-26 19:18:39 +08:00
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
2026-10-26 19:13:38 +08:00
AskaEth 91101dedfb feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定
背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

产出侧
- temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown,
  空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜
  同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。
- llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累
  同批其他候选。

落库
- 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
  down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚
  (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与
  留痕不分叉。

判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改)
- one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀:
  显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 ——
  命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时
  21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。
- durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7
  (不许因为「正文看着像今天的事」就判 temp)。
- IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。

验证
- go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/...
  ./internal/db/... 全绿;memory -race 全绿。
- 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与
  temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测:
  摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。
- 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore),
  新加一条迁移不再打红 0025/0026 的用例。
2026-10-26 18:57:38 +08:00
162 changed files with 10922 additions and 619 deletions

No files matched your search

+59 -27
View File
@@ -6,7 +6,7 @@
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本——**两者均已修复**:前者已由 v0.97(切片 M44)、后者已由 v0.80(切片 W4)——见 §4.5 / §7.2 / §7.4-29)。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
@@ -37,9 +37,9 @@
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
@@ -123,7 +123,7 @@
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **鉴权 = 既有双凭据门(v0.97 明示)**:`Authorization: Bearer <owner JWT>` **或** `X-API-Key: <已签发 key>`,两分支同 `authorize(RoleOwner)`——Bearer 走完整 JWT 校验 + owner-only(**不因「带了 Bearer」就放行**);X-API-Key 一律 owner 级,未知/吊销 key 一律 `401` 且**不回落** Bearer(fail-closed);key 表未注入时只认 Bearer。无凭据/无效凭据 `401`,错误形状同 §4.1 信封。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
@@ -375,7 +375,7 @@
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
```
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。✅ **`voice` 在握手回放里(v0.97 起,切片 M44)**:与 REST 读面同一收口点,形状与 §4.4 一致,非语音消息键缺席(v0.67~v0.96 期间曾长期缺席,那时的「不要在握手回放上等 `voice`」口径已作废)。
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
- 回放是尽力而为:存储未接或读出错静默跳过。
@@ -489,7 +489,7 @@
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
> **`voice` 覆盖同一批读取面(v0.67 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。
> **`voice` 覆盖同一批读取面(v0.67 登记;✅ **v0.79 落地 REST 面,v0.97 补齐 WS 面**)**:消息 DTO 的可选 `voice` 对象覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.97 起全部在读面上填充**:`message_edited` 与 4 个 REST 读取面自 v0.79 起;`message_persisted` / `channel_persisted` 帧与握手回放自 v0.97(切片 M44,落库顺序改为 INSERT→写列→notify,**首帧即带**)⇒ 客户端可以统一按「`voice` 键存在 = 语音消息」处理所有读面。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的可选键。
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
@@ -610,9 +610,9 @@
{ "name": "web_search", "status": "ok", "durationMs": 1200,
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
],
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态)
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);v0.98 起也承载她的出站语音;非语音消息键缺席;🎤 判据 = 本键存在 && status != "failed"(v0.98)
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)| "tts"(她的出站合成语音,v0.98)
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态;failed 时 fallbackReason 可选在场,v0.98)
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
"mime": "audio/ogg",
"size": 12345,
@@ -649,9 +649,9 @@
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)。
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?,fallbackReason?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同;`fallbackReason` 仅 `status=="failed"` 时在场,v0.98)。
- **🎤 判据 = `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`)/ **`"tts"`(v0.98:她的出站合成语音——她调了模型侧 `speak` 工具且合成成功;播放与入站语音走同一 `GET /api/v1/media/{name}`)**。
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
@@ -659,7 +659,7 @@
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。
- ✅ **覆盖率(v0.97,如实)**:`voice` 已在**全部读面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**(v0.79 起),以及 **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放(v0.97,切片 M44)**。首帧即带的保证来自落库顺序 **INSERT → 写 `voice` 列 → notify**(两条实现前提见 §6 v0.97);帧内值是**本帧发出那一刻**的即时列读(每帧 +1 次读列、亚毫秒同步)。
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
**`documents` 数组语义(核心)**:
@@ -713,10 +713,11 @@
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
- **emotionTTS 自启(v0.97,切片 M47,默认关)**:后端启动时探活本机 emotionTTS,未起则异步拉起(失败只 WARN 不阻塞启动)。开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(面板配置文件、零新 `.env` 键;`probeUrl` 空 = `http://127.0.0.1:9880`,且**单向兜底**取 `voice-tts.json` 的 `baseUrl`)。⚠️ 快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效,记档不修)。见 §6 v0.97。
**入站语音的音频副本与播放端点(v0.67 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(✅ **已由 v0.97(切片 M44)修复**,见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
@@ -1582,7 +1583,38 @@ curl -s -X POST -H "X-API-Key: $KEY" \
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
### v0.96(当前契约版本)
### 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;以纯增量为主,一处行为变更如实标注)**
- **`voice` 进 WS 实时帧与握手回放(M44,纯增量)**:`message_persisted` / `channel_persisted` 帧内的 `message` 对象、以及握手 `history` 回放的每条消息,现在携带可选 `voice`(形状与语义与 §4.4 的 DTO **完全一致**,同一收口点 `attachVoiceTo → historyVoiceOf`;非语音消息整键缺席)。🔴 **首帧就带**:入站语音的落库顺序已改为 **INSERT → 写 `voice` 列 → notify** ⇒ 这条语音消息的**第一条** `message_persisted` / `channel_persisted` 帧就带 `voice`,客户端不用等回读。
- 🔴 **两条实现前提(新写入路径必须保持,否则首帧拿不到值)**:① `voice` 列的写入必须**先于** notify;② 帧内 `voice` 是**本帧发出那一刻**的即时列读(每帧 +1 次读列,被写列同步阻塞,亚毫秒级),不是消息结构体的缓存字段。
- ✅ **本版改正正文中已成假话的「WS 实时帧 / 握手回放不带 `voice` ⇒ 客户端不要在实时帧上等 `voice`」**(§3.2 / §4.4 / §7.2 / §7.4-29 及 v0.79 / v0.80 版本横幅已加注)。安卓端为此做的容忍逻辑(回读兜底)可以撤了。
- ⚠️ **不在本批范围**:`voice_output`(语音回合的定向回帧)形状未动;删除消息 / 会话时音频副本引用计数不释放的旧缺口仍在(见 §7.2)。
- **emotionTTS 自启(M47,默认关,运维面)**:后端启动时探活本机 emotionTTS,未起则异步拉起(`一键启动.bat`),**失败只 WARN 不阻塞启动**;探活在线则不重复拉起。
- 开关 = `$DATA_DIR/config/voice-tts-launch.json` 的 `{"enabled": true}`(**面板配置文件,零新 `.env` 键**);键:`enabled`(bool,**缺省/文件缺失 = 不启用**)、`probeUrl`(探活地址,空 = `http://127.0.0.1:9880`)。
- `probeUrl` 为空时**单向兜底**取 `voice-tts.json` 的 `baseUrl`(只降级不回写,方向唯一)。非法探活地址 = 不启用(宁可不自启,不拉错目标)。
- ⚠️ **已知限制(记档不修)**:快速 restart 两次会拉起两个 TTS 进程(跨进程去重不生效——防得住本进程并发,防不住上一进程拉起的孤儿)。
- **上游错误出口「构造即安全」(M49 / M50 / M48 / M52;🔴 一处行为变更)**:`trace.UpstreamError` 构造函数本身就是清洗边界——`error.code` / `error.type` **固定过令牌守卫**(≤64 字节、仅 `[A-Za-z0-9_.-]`,构造后注入走 `SetCode`/`SetType` 同一道守卫);**上游响应体的任何字节都不进错误**(只留长度 + sha256 前缀,仍能做相关性比对)。
- 🔴 **行为变更(如实)**:上游错误的 `error.message` **默认缺席**(此前部分出口会带上游自由文本)。要它必须调用点显式 opt-in(`WithUpstreamMessage`),且仍过凭据打码 + 128 rune 截断。理由:`{"error":{"message":"Invalid value: <用户原文>"}}` 这类回显真实存在,默认带 = 把她的原话进日志。分类排障用 `status/code/type/bodyLen/sha256` 足够。
- `trace.URLCause` 统一剥 `*url.Error` 的「方法 + 地址段」因果链(**层数上限 8**,环不会栈溢出);全仓 19 处 `%v` 直打已清零。URL 查询串里的凭据从此不出现在错误出口。
- **契约形状零变化**:REST / WS 错误信封(`{error:{code,message}}`)的形状、错误码集合都没动;这是**内容脱敏口径**的收敛,客户端按既有信封编程即可。
- **同批已核实、无契约面变化的内部项**:M45 `temporal` 列只观测不判定(`IngestRuleVersion=d3.5`,`resolveRetention` 不读它);M51 MCP keepalive 日志面收口(redactingLogger);M46/M48 `URLCause` 五处收敛与防环。
- **U6 `/audio/speech` 鉴权双凭据明示(本版补写,既有能力非新增)**:该端点自诞生起挂的就是 `authorize(RoleOwner)` 双分支门——**owner Bearer JWT 与 `X-API-Key` 都能用**(与全部 owner 端点同一扇门,见 §1.2 U6 更新)。裁决 2026-10-26(主人选 B):Android 不内置 API Key 入口、用现有 owner JWT 调 TTS。⚠️ Bearer 路径的真机 curl 属「同门已验证」的结构性结论(`auth` 层 `WithAPIKey` 双分支有核心断言;`/audio/speech` 与聊天端点共用同一门),**独立真机 Bearer 样例待安卓首次接入时顺手确证**。
### v0.96
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
@@ -1837,7 +1869,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`**;删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`** —— ✅ **已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
### v0.79(上一版)
@@ -1845,11 +1877,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**(需给 `history.Message` 加字段,未做;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`** —— ✅ **该缺口已由 v0.97(切片 M44)修复**(首帧即带,见 §6 v0.97);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`(✅ **已由 v0.97(切片 M44)修复**)、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
### v0.78(上一版)
@@ -1860,10 +1892,10 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
- **破坏性**:**无**。
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`;AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`(✅ **已由 v0.97(切片 M44)修复**);AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
### v0.77(上一版)
@@ -1993,11 +2025,11 @@ curl -s -X POST -H "X-API-Key: $KEY" \
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`——**已由 v0.97(切片 M44)修复**;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
- **纯增量**:
① 新端点 `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` 回放不带**(见 §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` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
④ 新错误形态 **`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);未给窗口 ⇒ 不注入窗口)。
@@ -2092,7 +2124,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
### 7.1 在飞切片
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(仍残留 WS 帧/握手回放不带 `voice`;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息的用户可见面已由 v0.79 落地**(WS 帧/握手回放不带 `voice` 的缺口**已由 v0.97(切片 M44)修复**;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §7.2。
| 项 | 状态 | 冻结位置 |
|---|---|---|
@@ -2117,7 +2149,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
| 项 | 现状 |
|---|---|
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,**未做**;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值)⇒ **客户端不要在实时帧/握手回放上等 `voice`**;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ **生产端到端未跑**(未重启后端)。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① ✅ **已修(v0.97,切片 M44)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放已带 `voice`**(落库顺序改 INSERT→写列→notify,**首帧即带**,见 §6 v0.97);② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ ⚠️ **`weixin-ilink` 语音无副本是刻意的**(平台自带转写就不白下载音频),不受 M44 影响。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
@@ -2161,7 +2193,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
- **`voice` 只在 4 个 REST 读取面 + `message_edited` 填充(v0.79 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,未做);且入站语音的 `voice` 列是**落库之后**回填的 ⇒ 即使加了字段,`message_persisted`(在落库那一刻发出)也拿不到值。**客户端不要在实时帧 / 握手回放上等 `voice`**(见 §3.4.4 / §4.4 / §4.5)。
- **`voice` 已在全部读面填充(v0.97 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放自 v0.97(切片 M44)带 `voice`,且首帧即带**(落库顺序 INSERT→写列→notify;即时列读,每帧 +1 次读列、亚毫秒)。`voice_output` 定向回帧不在该批,形状未动(见 §3.4.4 / §4.4 / §4.5)。
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
@@ -2215,7 +2247,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ **`message_persisted` / `channel_persisted` / 握手 `history` 回放不带 `voice`**(见 §4.4 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ ✅ **已修(v0.97,切片 M44)**:`message_persisted` / `channel_persisted` / 握手 `history` 回放**已带 `voice` 且首帧即带**(见 §4.4 / §6 v0.97 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
@@ -2223,4 +2255,4 @@ curl -s -X POST -H "X-API-Key: $KEY" \
---
*本文件由「以代码为真源」维护(当前 `v0.82`;`v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.82`(三面 —— Y1 `resultSummary` / Y3 自排成本可见面(`?trigger=` + `/admin/backthink/self-wake`)/ Y4 `browser` 面板与六个动作)同步了 `internal/runtime/model/stream.go`、`internal/runtime/loop/**`(`tools.go` / `tool_frame_summary.go` / `tool_frame_summary_test.go`)、`internal/services/gateway/**`(`ws_bridge.go` / `ws_tool_result_summary_test.go` / `admin_backthink_filter.go` / `admin_backthink_filter_test.go` / `admin_backthink_selfwake.go` / `admin_query.go` / `routes.go`)、`internal/services/backthink/**`(`backthink.go` / `store.go` / `trigger.go` / `trigger_domain.go` / `selfwake_status.go` 及其测试)、`internal/config/runtimeconfig/backthink.go`、`plugins/tools/browser/**`(`panel.go` / `paneldata.go` / `attach.go` / `tool.go` 及测试)、`internal/capability/browser/**`(`state.go` / `manager.go`)、`internal/runtime/multimodal/vision/**`(`shot.go` / `resolver.go`)、`cmd/cyrene/**`(`wire_browser_panel.go` / `wire_browser_panel_data.go` / `wire_browser_context.go` 及测试)`frontend/web/src/admin/modules/Backthink.tsx`、`frontend/web/src/admin/plugin/nav.ts`、`frontend/web/src/lib/api/adminSystem.ts` 与本文件(§2.0 / §2.1 A13 / §2.2 B8·B19 / §3.4.2 / §4.10(d)(e) / §4.14(a)(d) / §6 / §7.1 / §7.2 / §7.4);`v0.81`(自排轮可见面 + 时间轴 `retentionBoundaryReached` + `round` 跨重启单调)同步了 `internal/services/backthink/**`(`trigger.go` / `selfwake*.go` / `store.go`)、`internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go` / `flowgraph.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` 及测试 / `routes.go`)与本文件(§0.1 / §2.2 B18 / §4.10(d) / §4.11.1 / §6 / §7.1);`v0.80`(三批 —— W1 链路图时间轴出口 / W3 搜索 provider 热重载 + 主 provider·降级链 / W4 入站语音 AMR 转码落副本)同步了 `internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` / `admin_flow_timeline_test.go` / `history_toolcalls_gap_test.go` / `routes.go`)、`internal/capability/registry/**`(`registry.go` / `replacetool_test.go`)、`plugins/tools/io/search/**`(`panel.go` / `panel_test.go`)、`cmd/cyrene/**`(`websearch_reload.go` / `websearch_reload_test.go` / `websearch_selection.go` / `websearch_selection_test.go` / `websearch_testenv_test.go` / `websearch_action_data.go` / `wire_io_tools.go` / `wire_plugin_actions.go` / `wire_channels.go` / `wire_channels_test.go`)与本文件(§2.0 / §2.2 B18 / §4.1 / §4.5 / §4.11.1 / §4.14 / §6 / §7.1 / §7.2 / §7.3 / §7.4);`v0.79`(四批 —— L1/PLG-2 插件动作执行面接线 / L45 入库前审查管理面 / L45 语音用户可见面 / CODE-TC-2 链路图回合标识覆盖全部编排回合)同步了 `internal/capability/plugin/**`(`actions.go` / `panelactions.go` / `panels.go`)、`internal/services/gateway/**`(`admin_panels.go` / `admin_memory_review.go` / `history.go` / `history_write.go` / `history_voice.go` / `media.go` / `routes.go`)、`internal/runtime/voice/media.go`、`internal/runtime/documents/store_audio.go`、`internal/runtime/history/voice_column.go`、`internal/runtime/loop/**`(`flow.go` / `run.go`)、`internal/runtime/trace/graph.go`、`internal/runtime/flowgraph/flowgraph.go`、`plugins/tools/io/search/**`(`paneldata.go` / `probe.go`)、`cmd/cyrene/**`(`wire_plugin_actions.go` / `websearch_action_data.go` / `websearch_probe.go` / `wire_frontend.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.2 B16-B17 / §3.4.4 / §4.1 / §4.4 / §4.5 / §4.11 / §4.14 / §6 / §7.2 / §7.3 / §7.4);`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`interLine truncated
*本文件由「以代码为真源」维护(当前 `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,
Flow: flowPub,
Guards: guards,
// speak 的两个依赖(wire_speak.go):emotionTTS 未启用时 synth 为 nil,
// speak 不广告(voiceEnabled() 要求两者齐备)。
VoiceSynth: a.speakSynthForLoop(),
VoiceStore: a.speakStoreForLoop(docDataDir),
PersonaID: personaID,
PersonaName: a.personaName,
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
})
}
+12
View File
@@ -119,6 +119,18 @@ func portListening(ctx context.Context, port int) bool {
// 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测
// (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。
// 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。
//
// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚:
//
// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview
// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在
// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir
// 路径,adopt_test 钉着它必须可见);
// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里
// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。
//
// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在
// 拼接处再洗一遍。
func withChromeOutput(err error, preview string) error {
if preview == "" {
return fmt.Errorf("%w;浏览器没有输出任何内容", err)
+25 -2
View File
@@ -5,10 +5,13 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被
@@ -30,8 +33,28 @@ type cdpError struct {
Message string `json:"message"`
}
// Error 实现 error 接口。
func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) }
// Error 渲染协议错误(实现 error 接口)。
//
// 🔴 Message 是**上游自由文本**:Chrome 与各类兼容实现可以往里面放任何东西 —— 包括
// 它刚被要求操作的表达式、地址或页面内容。原样进错误等于把那段内容抄进日志与模型
// 可见视图,因此默认**不出镜**:
// - 只有像协议枚举的短串(trace.ErrorToken:≤64 字节、仅 [A-Za-z0-9_.-])才保留
// —— 真正的枚举(context_not_found 之类)能过,散文与回显一律过不了;
// - 其余只留**长度**(字节数),够判断「对端给了句话」而不带那句话。
//
// 方法名由调用点补齐(cdpClient.call / Call 的 %w 文案),诊断面并不只剩一个码。
func (e *cdpError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "CDP %d", e.Code)
switch token := trace.ErrorToken(e.Message); {
case token != "":
b.WriteString(": ")
b.WriteString(token)
case e.Message != "":
fmt.Fprintf(&b, " (message %d bytes withheld)", len(e.Message))
}
return b.String()
}
// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。
//
+52 -6
View File
@@ -3,6 +3,7 @@ package browser
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -105,11 +106,23 @@ func TestCDPClient_CallRoundTrip(t *testing.T) {
}
}
// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此
// 判定「连接级问题 ⇒ 丢会话重起」)。
func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
// TestCDPClient_ProtocolErrorKeepsNoFreeText 钉住两件事:协议错误被 %w 包成 ErrCDP
// (上层据此判定「连接级问题 ⇒ 丢会话重起」),而 CDP 的 error.message **不出镜**。
//
// 为什么 message 不能出镜:它是**上游自由文本** —— Chrome 与各类兼容实现可以往里面
// 放它刚被要求操作的东西(表达式、地址、页面内容)。只有像协议枚举的短串
// (trace.ErrorToken)才保留,其余只留字节数。
//
// 夹具刻意用**带空格的散文 + 会被回显的页面文本**:真正的 CDP message 就长这样
// ("Cannot find context with specified id"),而像令牌的单字("boom")本来就会过
// 守卫 —— 用例不该拿它当「自由文本」。
//
// 变异判据:把 cdpError.Error() 换回 `fmt.Sprintf("CDP %d: %s", e.Code, e.Message)`
// ⇒ 哨兵文本出现在错误串里,本用例红。
func TestCDPClient_ProtocolErrorKeepsNoFreeText(t *testing.T) {
const message = "Cannot resolve context for <script>PAGE-ECHO-SECRET</script>"
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32000, Message: "boom"}
return nil, &cdpError{Code: -32000, Message: message}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
@@ -118,14 +131,47 @@ func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) {
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err)
if err == nil {
t.Fatal("协议错误应当返回 error")
}
if strings.Contains(err.Error(), "PAGE-ECHO-SECRET") {
t.Fatalf("CDP 的自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "CDP -32000") {
t.Errorf("错误没带上协议码: %v", err)
}
if want := fmt.Sprintf("message %d bytes withheld", len(message)); !strings.Contains(err.Error(), want) {
t.Errorf("错误没带上被扣下文本的长度(期望 %q): %v", want, err)
}
// 方法名是代码给的名字,必须仍在 —— 扣掉 message 之后诊断面靠它定位。
if !strings.Contains(err.Error(), "Runtime.evaluate") {
t.Errorf("错误丢了方法名: %v", err)
}
if !strings.Contains(err.Error(), "CDP 调用失败") {
t.Errorf("错误没带上 ErrCDP 文案: %v", err)
}
}
// TestCDPClient_ProtocolErrorKeepsProtocolToken 是「协议枚举仍可读」的正向证据:
// 取值像令牌(≤64 字节、仅 [A-Za-z0-9_.-])的 message 原样保留。
//
// 变异判据:把 cdpError.Error() 改成「message 一律不带」⇒ 本用例红。
func TestCDPClient_ProtocolErrorKeepsProtocolToken(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
return nil, &cdpError{Code: -32602, Message: "context_not_found"}
})
wsURL, _ := pageTargetWS(context.Background(), f.base)
c, err := newCDPClient(context.Background(), wsURL)
if err != nil {
t.Fatalf("newCDPClient: %v", err)
}
defer func() { _ = c.Close() }()
err = c.Call(context.Background(), "Runtime.evaluate", nil, nil)
if err == nil || !strings.Contains(err.Error(), "context_not_found") {
t.Fatalf("协议枚举形状的 message 应保留:%v", err)
}
}
// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。
func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) {
f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) {
@@ -0,0 +1,154 @@
package browser
// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune +
// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然
// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。
//
// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件
// 的凭据断言(以及日志读面那条)会全红。
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
"unicode/utf8"
)
// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的
// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的
// 普通原话一个字都不许少。
func TestChromeOutputPreviewRedactsCredentials(t *testing.T) {
t.Parallel()
const (
complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*"
credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890"
)
var out chromeOutput
for _, line := range []string{complaint, credURL, bearer} {
if _, err := out.Write([]byte(line + "\n")); err != nil {
t.Fatalf("chromeOutput.Write 永不返回错误: %v", err)
}
}
got := out.preview()
t.Logf("实测预览=%q", got)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(got, secret) {
t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got)
}
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got)
}
// 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。
for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} {
if !strings.Contains(got, want) {
t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got)
}
}
}
// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes
// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃
// 量级)。没有它,读者会把半截输出误当成完整输出。
func TestChromeOutputPreviewIsBounded(t *testing.T) {
t.Parallel()
t.Run("单行超长只留上限并标截断", func(t *testing.T) {
t.Parallel()
var out chromeOutput
if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.HasSuffix(got, "…") {
t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8))
}
if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 {
t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n)
}
})
t.Run("字节截断时仍报总量", func(t *testing.T) {
t.Parallel()
var out chromeOutput
// 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。
if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil {
t.Fatalf("Write: %v", err)
}
got := out.preview()
if !strings.Contains(got, "已截断,共 5000 字节") {
t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40))
}
})
}
// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒
// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半)
// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。
func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) {
t.Parallel()
const (
complaint = "不支持的标记: --remote-allow-origins=*"
credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345"
bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890"
)
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0,
}, 5*time.Second, log)
_, stop, err := l.Dial(context.Background())
if stop != nil {
t.Error("失败路径不该交出停止函数")
}
if err == nil {
t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错")
}
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
msg := err.Error()
logs := buf.String()
t.Logf("实测 error=%v\n实测日志=%s", err, logs)
for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} {
if strings.Contains(msg, secret) {
t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err)
}
if strings.Contains(logs, secret) {
t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("打码应当看得见([redacted]): %v", err)
}
// 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉,
// 浏览器原话那半是这条出口存在的理由。
for _, want := range []string{
"绝不杀别人的浏览器", l.profileDir,
complaint, "载入失败",
} {
if !strings.Contains(msg, want) {
t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err)
}
}
if !strings.Contains(logs, complaint) {
t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs)
}
}
// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。
func tailRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[len(r)-n:])
}
+124
View File
@@ -0,0 +1,124 @@
package browser
// 本文件是**对端自由文本出口**的收敛点:有三处会把页面/浏览器自己说的话原样交给我们,
// 它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。
//
// 三处**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go
// 的「按字段类别判定」):
//
// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码
// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守:
// 像枚举就保留,散文一律丢弃;
// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以
// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带
// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断;
// - 浏览器自己的 stdout/stderr 预览(launchoutput.go 的 `preview()`)也是自由文本:
// 参数被拒时 Chromium 会把**本次 argv** 与地址原话打出来(`chromeOutputPreview`)。
//
// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段
// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住
// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。
import (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。
//
// 为什么不复用既有的两个档位:
// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 ——
// `TypeError: Cannot read properties of undefined (reading 'addEventListener')`
// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索;
// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的;
// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 ——
// 而这条错误串同时进日志与模型可见视图。
//
// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时
// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。
const maxExceptionTextRunes = 200
// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。
//
// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含
// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」
// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议
// 常量**,不是从对端抄来的文本,因此剥它是安全的。
const netErrorNamespace = "net::"
// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。
// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定
// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。
const netErrorCodePrefix = "ERR_"
// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。
// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。
func netErrorCode(raw string) string {
text := strings.TrimSpace(raw)
namespace := ""
if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok {
namespace, text = netErrorNamespace, rest
}
if !strings.HasPrefix(text, netErrorCodePrefix) {
return ""
}
token := trace.ErrorToken(text)
if token == "" {
return ""
}
return namespace + token
}
// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。
// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。
//
// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」,
// 不带那句话(与 cdpError.Error 同一口径)。
func navigateCause(raw string) string {
if code := netErrorCode(raw); code != "" {
return code
}
return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw))
}
// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行
// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+
// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。
func pageExceptionText(raw string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes)
}
// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。
func evaluateCause(raw string) string {
if text := pageExceptionText(raw); text != "" {
return text
}
// 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。
return "页面异常(对端未给文本)"
}
// chromeOutputPreviewRunes 是浏览器 stdout/stderr 预览出镜时的 rune 上限:**600**
// (= 12 行 × 50 rune)。
//
// 为什么是这个量级:预览的**行**预算已经是 12 行(chromeOutputMaxLines),50 rune 大致
// 是一个短分句 —— Chromium 的抱怨句式是「原因在前」(`ERROR:...] 参数被拒:...`),
// 所以 600 保得住原因那一句,又把「一整块转储」挡在模型可见的错误串之外。
//
// 为什么不沿用另外两档:80(`trace.RedactText` 摘要档)连**一行**浏览器日志都放不下;
// 4096(`chromeOutputMaxBytes` 缓存档、网关日志页 maxLogMsgRunes)是「运维读全文」的
// 量级,对浏览器可控的字段等于允许把 4KB 正文塞进工具结果。
//
// 为什么 600 不会把「到底说了多少」藏起来:本上限只压在**预览正文**上,调用方
// (launchoutput.go)随后仍会追加「已截断,共 N 字节」——读者看到的永远是「这里有 600
// rune 的原话 + 它其实说了 N 字节」,截断与量级都看得见。
const chromeOutputPreviewRunes = 600
// chromeOutputPreview 是浏览器 stdout/stderr 预览出镜前的唯一处理:凭据形状打码 +
// 截断(清洗实现只有一份:trace.RedactTextLimited)。它服务**两个**读面 —— 失败日志的
// `chrome_output` 字段与返回给调用方的错误串(launch.go 的 launchOnce),因此收敛在
// preview() 里,两个读面不会判得不一致。
func chromeOutputPreview(text string) string {
return trace.RedactTextLimited(text, chromeOutputPreviewRunes)
}
+200
View File
@@ -0,0 +1,200 @@
package browser
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable
// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。
func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
switch method {
case "Page.enable":
return map[string]any{}, nil
case "Page.navigate":
return map[string]any{"errorText": errorText}, nil
}
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
})
}
// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP:
// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。
func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP {
t.Helper()
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
switch method {
case "Page.enable", "Page.navigate":
return map[string]any{}, nil
case "Page.getFrameTree":
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
case "Page.createIsolatedWorld":
return map[string]any{"executionContextId": 1}, nil
case "Runtime.evaluate":
expr, _ := params["expression"].(string)
if strings.Contains(expr, "readyState") {
return map[string]any{"result": map[string]any{"value": map[string]any{
"url": "https://example.com/", "title": "t", "readyState": "complete",
}}}, nil
}
return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil
}
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
})
}
// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关
// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度
// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。
//
// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。
func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) {
t.Parallel()
const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)"
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("err = %v, want ErrNavigate", err)
}
msg := err.Error()
for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "已隐去") {
t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg)
}
if !strings.Contains(msg, strconv.Itoa(len(raw))) {
t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg)
}
}
// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的
// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给
// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。
func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) {
t.Parallel()
for _, code := range []string{
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_CONNECTION_REFUSED",
"net::ERR_TIMED_OUT",
"ERR_ABORTED",
} {
m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrNavigate) {
t.Fatalf("%s: err = %v, want ErrNavigate", code, err)
}
if !strings.Contains(err.Error(), code) {
t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error())
}
}
}
// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西,
// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由
// 文本的处置口径是「带上限地带」,见 errtext.go)。
//
// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。
func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) {
t.Parallel()
const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345"
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
for _, leak := range []string{"ABCDEFGH12345", "sk-live"} {
if strings.Contains(msg, leak) {
t.Errorf("错误串泄露了凭据 %q: %q", leak, msg)
}
}
if !strings.Contains(msg, "[redacted]") {
t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg)
}
if !strings.Contains(msg, "TypeError") {
t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg)
}
}
// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。
// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行
// 就能在按行聚合的日志里伪造出另一条记录)。
func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) {
t.Parallel()
const tail = "TAILMARKER"
raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail
m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw)))
_, err := m.RenderText(context.Background(), "https://example.com/")
if !errors.Is(err, ErrEvaluate) {
t.Fatalf("err = %v, want ErrEvaluate", err)
}
msg := err.Error()
if strings.Contains(msg, tail) {
t.Errorf("超出上限的尾部不该出镜: %q", msg)
}
if !strings.HasSuffix(msg, "…") {
t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg)
}
if strings.ContainsAny(msg, "\r\n") {
t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg)
}
}
// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个
// rune 加一个省略号,一个不多。
func TestPageExceptionTextLimitsRunes(t *testing.T) {
t.Parallel()
got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50))
if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want {
t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("截断应补省略号: %q", got)
}
// 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。
short := pageExceptionText("TypeError: body is gone")
if short != "TypeError: body is gone" {
t.Errorf("未超限不该改动文本: %q", short)
}
// 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。
if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" {
t.Errorf("空白异常文本的处理 = %q", got)
}
}
// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本
// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌
// 的自由文本」(`hunter2`)不会被误当成错误码保留。
func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want string
}{
{"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"},
{" net::ERR_ABORTED ", "net::ERR_ABORTED"},
{"ERR_FAILED", "ERR_FAILED"},
{"hunter2", ""},
{"Failed loading https://example.com/", ""},
{"net::ERR_NOT_A_CODE because spaces", ""},
{"net::hunter2", ""},
{"", ""},
}
for _, c := range cases {
if got := netErrorCode(c.in); got != c.want {
t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+13 -2
View File
@@ -20,10 +20,12 @@ const (
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
//
// 两条口径:
// 三条口径:
//
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状
// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
type chromeOutput struct {
@@ -65,6 +67,13 @@ func (o *chromeOutput) totalBytes() int {
// preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时
// 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串,
// 调用方据此判断「浏览器一个字都没说」。
//
// 🔴 正文出镜前必过 chromeOutputPreview(凭据形状打码 + 600 rune 上限,见 errtext.go):
// 这个字符串**是浏览器可控的自由文本**,而它会同时进失败日志与**返回给调用方的错误串**
// (launchOnce ⇒ 用户侧工具结果)。真机上 Chromium 会把本次 argv、被拒的参数、地址原话
// 打出来 —— 一旦里面出现 `?access_token=` 或 `Bearer ...`,原样透出就是凭据落进日志与
// 模型上下文。上限只压正文:下面的「已截断,共 N 字节」后缀是我们自己的定长说明,不进
// 这份预算,因此截断与量级都照实可见。
func (o *chromeOutput) preview() string {
o.mu.Lock()
raw := truncateRune(o.head)
@@ -84,7 +93,9 @@ func (o *chromeOutput) preview() string {
break
}
}
text := strings.Join(kept, " | ")
// 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开)
// 只有拼起来之后才看得见。
text := chromeOutputPreview(strings.Join(kept, " | "))
if truncated && text != "" {
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
}
+5 -2
View File
@@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) {
return pageState{}, err
}
if text := strings.TrimSpace(res.ErrorText); text != "" {
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text)
// errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。
return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text))
}
if err := s.waitReady(ctx); err != nil {
return pageState{}, err
@@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error {
return err
}
if res.ExceptionDetails != nil {
return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text)
// text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token
// 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。
return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text))
}
if out == nil || len(res.Result.Value) == 0 {
return nil
+13 -7
View File
@@ -33,7 +33,6 @@ func (c *Client) Connect(ctx context.Context) error {
if err != nil {
return fmt.Errorf("mcp connect %q: %w", c.cfg.Name, wrapConnectErr(err))
}
info := serverInfoFrom(session, c.hostFeatures())
c.mu.Lock()
c.session = session
@@ -62,7 +61,7 @@ func (c *Client) Close() error {
if session != nil {
if err := session.Close(); err != nil {
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, err)
return fmt.Errorf("mcp close %q: %w", c.cfg.Name, upstreamError(err))
}
}
return nil
@@ -82,7 +81,7 @@ func (c *Client) ListTools(ctx context.Context) ([]ToolInfo, error) {
out := make([]ToolInfo, 0, 8)
for tool, err := range session.Tools(ctx, nil) {
if err != nil {
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, err)
return nil, fmt.Errorf("mcp list tools %q: %w", c.cfg.Name, upstreamError(err))
}
out = append(out, toolInfoFrom(tool))
}
@@ -112,7 +111,7 @@ func (c *Client) callTool(ctx context.Context, name string, args map[string]any,
}
res, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: name, Arguments: prepared})
if err != nil {
return ToolResult{}, fmt.Errorf("mcp call tool %q: %w", name, err)
return ToolResult{}, fmt.Errorf("mcp call tool %q on %q: %w", name, c.cfg.Name, upstreamError(err))
}
return ToolResult{
Content: toContentBlocks(res.Content),
@@ -165,7 +164,9 @@ func (c *Client) handleToolListChanged(context.Context, *mcpsdk.ToolListChangedR
// newSDKClient 按配置构建官方 SDK 客户端:只宣告宿主真正具备的客户端能力。
func (c *Client) newSDKClient() *mcpsdk.Client {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, &mcpsdk.ClientOptions{
Logger: c.log,
// 🔴 交给 SDK 的 Logger 必须过 redactingLogger(sdklog.go):SDK 自己会把对端
// 错误原样打进日志(keepalive 那条已实测),那条路绕开了本包 upstreamError 的出口。
Logger: redactingLogger(c.log),
Capabilities: c.clientCapabilities(),
CreateMessageHandler: c.wrapSampling(),
ElicitationHandler: c.wrapElicitation(),
@@ -188,12 +189,17 @@ func (c *Client) sessionOptions() *mcpsdk.ClientSessionOptions {
}
// wrapConnectErr 把 SDK 报的「协议版本不支持」映射到本包哨兵错误,便于调用方判定。
//
// 它同时是握手失败的**脱敏点**:原始 SDK 错误(第三方 server 的自由文本)只经
// upstreamError 留下 code 与长度,绝不被 %w 带进文案 —— 而哨兵映射仍然成立,因为
// upstreamFailure 的 cause 保留了整条链,errors.Is 不受影响。
func wrapConnectErr(err error) error {
safe := upstreamError(err)
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) && wire.Code == mcpsdk.CodeUnsupportedProtocolVersion {
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, err)
return fmt.Errorf("%w: %w", ErrUnsupportedProtocolVersion, safe)
}
return err
return safe
}
// normalizeArgs 把 agent 传来的参数值规整成 MCP arguments 对象(必须是 JSON 对象)。
+71
View File
@@ -0,0 +1,71 @@
package mcp
import (
"errors"
"fmt"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// 本文件是「第三方 MCP server 的错误怎么写进本包错误」的**唯一**收敛点。
//
// 为什么需要它:MCP server 是第三方进程/远端服务,它的错误文本是**上游自由文本** ——
// 它可以把刚收到的工具参数、读到的文件内容、甚至是它自己的系统提示词塞进
// `error.message`。原样经 SDK error 进本包错误,那条文本就会随 `errors.Is` 分类链
// 一路进日志、进管理面板、进模型可见视图。与 trace.UpstreamError 同一口径:
// **默认不带自由文本**,只留可分类的骨架。
//
// 留下的骨架(够定位,不够回显):
// - 哪台 server / 哪把工具 —— 由调用点的 %w 文案给(名字来自配置与 tools/list,
// 不是错误文本);
// - JSON-RPC code —— 协议枚举(-32601 method not found、-32602 invalid params …),
// 是本文件唯一允许出镜的「对端给的值」,且以数字渲染;
// - message 的**字节数** —— 能区分「对端给了句话」与「对端什么都没说」,不带那句话。
// upstreamFailure 是一次第三方 server 失败在本包错误链里的形态。它的 Error() 只
// 渲染 code 与长度;原始原因挂在 cause 上,只供 errors.Is / errors.As 分类。
//
// 为什么不直接把 SDK 的 err 用 %w 包上:%w 只加前缀,被包的那条文本仍然在
// err.Error() 里 —— 包一层不是修复,只是换个位置泄漏(同 trace.URLCause 的口径)。
type upstreamFailure struct {
// code 是 JSON-RPC 错误码;hasCode 为 false 表示这条错误不是线上响应里的
// 结构化错误(传输层失败、SDK 内部错误、ctx 取消等),此时不带任何码。
code int64
hasCode bool
// length 是被扣下的自由文本的字节数。
length int
// cause 只用于分类,绝不参与 Error()。
cause error
}
// Error 渲染一行只含 code / 长度的诊断;自由文本一个字节都不出。
func (f *upstreamFailure) Error() string {
if f.hasCode {
return fmt.Sprintf("mcp server error: code=%d message_bytes=%d", f.code, f.length)
}
return fmt.Sprintf("mcp server error: %T message_bytes=%d", f.cause, f.length)
}
// Unwrap 让 errors.Is/As 仍然看得见原始原因(context.Canceled、
// context.DeadlineExceeded、*jsonrpc.Error 的码…),而 Error() 绝不渲染它。
// 少了它,取消会被上层当成普通工具失败继续跑。
func (f *upstreamFailure) Unwrap() error { return f.cause }
// upstreamError 把一次第三方 server 失败收敛成**不带自由文本**的错误。
//
// 收 code 用的 errors.As 会遍历整条链:SDK 常把线上错误再包一层(fmt 包装、
// JSON-RPC 信封),所以不能只断言最外层。
func upstreamError(err error) error {
if err == nil {
return nil
}
failure := &upstreamFailure{cause: err, length: len(err.Error())}
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
failure.code = wire.Code
failure.hasCode = true
// WireError.Error() 就是 Message 本身,所以这里量的正是被扣下的那段文本。
failure.length = len(wire.Message)
}
return failure
}
+94
View File
@@ -0,0 +1,94 @@
package mcp
// 本文件钉住「第三方 MCP server 的错误文本不进错误与日志」这条口径(errors.go)。
//
// 生产风险:MCP server 是第三方进程/远端服务,它可以把刚收到的工具参数、读到的文件
// 内容、甚至自己的系统提示词塞进 error.message。原样经 SDK error 进本包错误,那条
// 文本就会随分类链一路进日志、进管理面板、进模型可见视图。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 upstreamError 换成裸 err(或 `%w` 包原始 err)⇒ 哨兵文本出现在错误串里;
// - 把 upstreamFailure 的 Error() 改成渲染 cause ⇒ 同上;
// - 摘掉 Unwrap ⇒ 「分类仍成立」那条断言红(取消会被当成普通工具失败)。
import (
"context"
"errors"
"fmt"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// errSentinel 是「绝不能出现在错误串里」的第三方自由文本标记。
const errSentinel = "MCP-UPSTREAM-SENTINEL-原文回显"
// sentinelServer 起一台会在 tools/call 里返回带哨兵文本的 JSON-RPC 错误的 server。
func sentinelServer() *mcpsdk.Server {
srv := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "sentinel-mcp", Version: "1.0.0"}, nil)
srv.AddTool(&mcpsdk.Tool{
Name: "boom",
InputSchema: map[string]any{"type": "object"},
}, func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
return nil, &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: errSentinel}
})
return srv
}
// TestCallTool_KeepsServerErrorTextOut 是 CallTool 的正面证据:错误里只剩
// server / 工具 / 协议码 / 长度,哨兵文本一个字节都没有。
func TestCallTool_KeepsServerErrorTextOut(t *testing.T) {
t.Parallel()
c := connectFakeClient(t, sentinelServer())
_, err := c.CallTool(context.Background(), "boom", map[string]any{})
if err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("第三方 server 的自由文本进了错误串:%v", err)
}
for _, want := range []string{"boom", "fake", "code=-32603"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("错误里应保留可分类的骨架 %q:%v", want, err)
}
}
}
// TestUpstreamError_KeepsClassificationButNotText 直接钉住 errors.go 的契约:
// Error() 只有骨架,errors.Is/As 仍然看得见原始原因。
func TestUpstreamError_KeepsClassificationButNotText(t *testing.T) {
t.Parallel()
cause := fmt.Errorf("sdk wrapper: %w", &sdkjsonrpc.Error{Code: -32601, Message: errSentinel})
err := upstreamError(cause)
if strings.Contains(err.Error(), errSentinel) {
t.Fatalf("自由文本进了错误串:%v", err)
}
if !strings.Contains(err.Error(), "code=-32601") {
t.Errorf("错误里丢了 JSON-RPC 码:%v", err)
}
// 分类面:WireError 的 Is 比的是码,链不能断。
if !errors.Is(err, &sdkjsonrpc.Error{Code: -32601}) {
t.Errorf("errors.Is 丢失了 JSON-RPC 码分类:%v", err)
}
var wire *sdkjsonrpc.Error
if !errors.As(err, &wire) || wire.Code != -32601 {
t.Errorf("errors.As 取不到原始 JSON-RPC 错误:%v", err)
}
}
// TestUpstreamError_KeepsContextCancellation 钉住「取消仍可分类」:上层靠
// errors.Is(err, context.Canceled) 区分「用户取消」与「工具失败」,
// 收敛文本时绝不能把这条链切断。
func TestUpstreamError_KeepsContextCancellation(t *testing.T) {
t.Parallel()
err := upstreamError(fmt.Errorf("mcp: %w", context.Canceled))
if !errors.Is(err, context.Canceled) {
t.Fatalf("errors.Is(err, context.Canceled) = false,取消被吞掉了:%v", err)
}
if strings.Contains(err.Error(), "context canceled") {
t.Errorf("取消的原文也被带进了错误串:%v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package mcp
// 本文件是「交给官方 SDK 的 Logger」的唯一收敛点。
//
// 为什么需要它(探针证据见 sdklog_probe_test.go):官方 SDK v1.8.0 的客户端侧会自己
// 打日志,其中 keepalive 那条把**对端错误原样**交给 logger:
//
// logger.Error("keepalive ping failed; closing session", "error", err, …)
//
// 而 err 是 SDK 自己的包装(`calling "ping": <对端 error.message>`),**不是**本包
// upstreamError 的产物 —— 于是第三方 server 的自由文本可以借 SDK 的手进我们的日志,
// 绕开 client.go 里那条「默认不带自由文本」的出口。探针实测:未加本文件时日志里出现
// 了对端回显的整句话。
//
// 收敛口径(与 client.go 同一刻画,不做第二套):
// - **线上错误**(链上有 *jsonrpc.Error):自由文本一律不带,只留
// `upstreamError` 渲染的 code + message_bytes;
// - **本地错误**(context deadline / canceled、传输错误):文本是代码自己产生的,
// 保留并以 maxSDKLogRunes 截断 —— 运维靠它判断「为什么 session 被关掉」;
// - 消息本身与所有字符串属性同样过 trace.RedactTextLimited(抹凭据形状 + 截断)。
//
// 为什么不是「一律 RedactText」:RedactText 只抹**凭据形状**,不删「她的话」——
// 对端把用户正文回显进 error.message 时,凭据清洗一个字符都不会动它。要挡住这一类,
// 判据必须是「这条文本来自线上」而不是「它像不像凭据」。
import (
"context"
"errors"
"log/slog"
"strings"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxSDKLogRunes 是 SDK 日志行(消息与字符串属性)允许出镜的 rune 上限。
//
// 取 300 的理由:这些行是「方法名 + 原因」的合成串(`calling "ping": …`),
// 300 rune 容得下任何真实诊断,同时挡住 MiB 级对象把日志行撑爆;与网关日志页
// 的 maxLogMsgRunes(4096,整页一行要留全文)刻意不同 —— 这里的行是我们自己
// 产出的诊断,不需要全文,只需要「哪一步失败、为什么」。
const maxSDKLogRunes = 300
// redactingLogger 返回交给 SDK 的日志器:同一底层 handler,包一层日志面收敛。
// 它不改变级别与输出格式(Enabled 原样委托),因此宿主注入的 Logger 行为不变。
func redactingLogger(l *slog.Logger) *slog.Logger {
return slog.New(&redactingHandler{inner: l.Handler()})
}
// redactingHandler 是 redactingLogger 的 handler 形态。
type redactingHandler struct {
inner slog.Handler
}
// Enabled 原样委托:收敛发生在写出时,不影响任何级别判定。
func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
// Handle 先收敛一条记录(消息 + 全部属性),再交给底层 handler。
func (h *redactingHandler) Handle(ctx context.Context, r slog.Record) error {
out := slog.NewRecord(r.Time, r.Level, scrubSDKText(r.Message), r.PC)
r.Attrs(func(a slog.Attr) bool {
out.AddAttrs(scrubSDKAttr(a))
return true
})
return h.inner.Handle(ctx, out)
}
// WithAttrs 让预置属性(logger.With(...))同样过收敛 —— 否则「先 With 再打」的
// 记录会绕过本文件。
func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
scrubbed := make([]slog.Attr, len(attrs))
for i, a := range attrs {
scrubbed[i] = scrubSDKAttr(a)
}
return &redactingHandler{inner: h.inner.WithAttrs(scrubbed)}
}
// WithGroup 透传分组,组内属性仍由 Handle 逐条收敛。
func (h *redactingHandler) WithGroup(name string) slog.Handler {
return &redactingHandler{inner: h.inner.WithGroup(name)}
}
// scrubSDKText 是一条自由文本进入日志前唯一处理:压平空白(一个换行就能在按行聚合
// 的日志里伪造出「另一条记录」)+ 凭据形状清洗 + 截断。
func scrubSDKText(s string) string {
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxSDKLogRunes)
}
// scrubSDKAttr 收敛一条属性:字符串与 error 是自由文本面,分组递归,其余(数字、
// 时间、布尔)原样保留 —— 它们是体积与状态,没有内容。
func scrubSDKAttr(a slog.Attr) slog.Attr {
switch a.Value.Kind() {
case slog.KindString:
a.Value = slog.StringValue(scrubSDKText(a.Value.String()))
case slog.KindAny:
if err, ok := a.Value.Any().(error); ok && err != nil {
a.Value = slog.StringValue(scrubSDKError(err))
}
case slog.KindGroup:
group := a.Value.Group()
scrubbed := make([]slog.Attr, len(group))
for i, item := range group {
scrubbed[i] = scrubSDKAttr(item)
}
a.Value = slog.GroupValue(scrubbed...)
}
return a
}
// scrubSDKError 区分两类错误来源(口径见文件头):
// - 链上有 *jsonrpc.Error ⇒ 文本来自对端,走 upstreamError 的骨架(code +
// message_bytes),一个字节的自由文本都不留;
// - 否则是本地产生的错误(context deadline / canceled、传输层、SDK 自身),
// 保留原因(运维要靠它判断失败性质),只做凭据清洗与截断。
func scrubSDKError(err error) string {
var wire *sdkjsonrpc.Error
if errors.As(err, &wire) {
return upstreamError(err).Error()
}
return scrubSDKText(err.Error())
}
@@ -0,0 +1,175 @@
package mcp
// 本文件是「SDK 自己的 Logger 日志面会不会漏第三方 server 的自由文本」的**探针**,
// 由塔 2026-10-26 复审 p2 要求:先证明有没有泄漏,再决定要不要上基础设施。
//
// 探针覆盖 go-sdk v1.8.0 客户端侧两处会打日志的点:
// 1. 正常的方法调用失败(tools/call 返回 JSON-RPC error)—— SDK 是否记录它;
// 2. keepalive ping 被对端用 JSON-RPC error 拒绝 —— SDK 明确会
// `logger.Warn("keepalive ping failed;…", "error", err)`,err 就是**对端的自由文本**。
//
// 结论(v1.8.0 实测):第 1 条不打日志;第 2 条会,且原样带出 message —— 因此
// newSDKClient 交给 SDK 的 Logger 必须经过 redactingLogger(见 sdklog.go)。
//
// 变异判据:把 newSDKClient 的 redactingLogger 摘掉 ⇒ 本文件的哨兵断言红。
import (
"bytes"
"context"
"strings"
"sync"
"testing"
"time"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// sdkLogSentinel 是第三方 server 塞进 JSON-RPC error.message 的哨兵(模拟「她刚才
// 说的那句话」被对端回显)。
const sdkLogSentinel = "SENTINEL-MCPLOG 她刚才说的那句话"
// pingErrorTransport 包一层传输:把客户端的 `ping` 拦下来,直接用一条**带哨兵文本的
// JSON-RPC error** 回答它(不转发给真 server),从而稳定复现「对端 free text 进
// keepalive 日志」这条路径。
//
// 为什么要在传输层拦 ping:SDK server 的 ping 应答是内置的(总回空 result),无法让
// 它回错误;而 ping 失败正是客户端侧唯一一处「把对端 error 原样交给 logger」的地方。
type pingErrorTransport struct {
inner mcpsdk.Transport
}
func (t *pingErrorTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) {
conn, err := t.inner.Connect(ctx)
if err != nil {
return nil, err
}
return newPingErrorConn(conn), nil
}
// pingErrorConn 把内层连接的消息泵到一个自己的 channel,好让拦下的 ping 能**插队**
// 回一条错误响应(直接在 Read 里塞响应不行:读循环已经阻塞在内层 Read 上)。
type pingErrorConn struct {
inner mcpsdk.Connection
msgs chan sdkjsonrpc.Message
done chan struct{}
once sync.Once
}
func newPingErrorConn(inner mcpsdk.Connection) *pingErrorConn {
c := &pingErrorConn{
inner: inner,
msgs: make(chan sdkjsonrpc.Message, 16),
done: make(chan struct{}),
}
go c.pump()
return c
}
// pump 把内层消息搬进 msgs;连接结束就关掉 msgs(读侧据此如实报错)。
func (c *pingErrorConn) pump() {
defer close(c.msgs)
for {
msg, err := c.inner.Read(context.Background())
if err != nil {
return
}
select {
case c.msgs <- msg:
case <-c.done:
return
}
}
}
func (c *pingErrorConn) Write(ctx context.Context, msg sdkjsonrpc.Message) error {
req, ok := msg.(*sdkjsonrpc.Request)
if !ok || req.Method != "ping" {
return c.inner.Write(ctx, msg)
}
select {
case c.msgs <- &sdkjsonrpc.Response{
ID: req.ID,
Error: &sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: sdkLogSentinel},
}:
return nil
case <-c.done:
return context.Canceled
}
}
func (c *pingErrorConn) Read(ctx context.Context) (sdkjsonrpc.Message, error) {
select {
case msg, ok := <-c.msgs:
if !ok {
return nil, context.Canceled
}
return msg, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *pingErrorConn) Close() error {
c.once.Do(func() { close(c.done) })
return c.inner.Close()
}
// SessionID 满足 mcpsdk.Connection(SDK 把它当作传输的身份标签,探针原样透传)。
func (c *pingErrorConn) SessionID() string { return c.inner.SessionID() }
// TestSDKLogger_ToolCallErrorIsNotLogged 证明探针不是空转:tools/call 的 JSON-RPC
// 错误**确实**会被我们的(redacted)日志器看到 —— 只是 SDK 并不记录它。
func TestSDKLogger_ToolCallErrorIsNotLogged(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
c := New(MCPConfig{Name: "fake"},
WithTransport(startFakeServer(t, sentinelServer())),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
if _, err := c.CallTool(context.Background(), "boom", map[string]any{}); err == nil {
t.Fatal("server 返回协议错误时 CallTool 应当报错")
}
if got := buf.String(); strings.Contains(got, errSentinel) {
t.Fatalf("tools/call 路径的 SDK 日志里出现哨兵(原先认为它不打日志,结论要改):\n%s", got)
}
t.Logf("tools/call 路径 SDK 日志(预期不含哨兵):\n%s", buf.String())
}
// TestSDKLogger_KeepaliveErrorTextStaysOut 是探针的主断言:keepalive 被对端用
// JSON-RPC error 拒绝时,SDK 会把 err 原样打进 WARN —— 哨兵绝不能出现在日志里。
func TestSDKLogger_KeepaliveErrorTextStaysOut(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
log := logger.New(logger.Options{Format: "text", Level: "debug", Out: &buf})
tr := &pingErrorTransport{inner: startFakeServer(t, newFakeServer())}
c := New(MCPConfig{Name: "fake"},
WithTransport(tr),
// 固定到 keepalive 生效的旧协议版本(同 keepalive_test.go 的口径)。
WithProtocolVersion("2025-11-25"),
WithKeepAlive(20*time.Millisecond, 1),
WithLogger(log))
if err := c.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
defer func() { _ = c.Close() }()
waitFor(t, 5*time.Second, "keepalive 报出 ping 失败", func() bool {
return strings.Contains(buf.String(), "keepalive ping failed")
})
logged := buf.String()
// 探针本身要可证伪:日志里确实有那条 keepalive 记录,否则「没哨兵」是空转。
if !strings.Contains(logged, "keepalive ping failed") {
t.Fatalf("探针没触发 keepalive 日志,结论不可用:\n%s", logged)
}
if strings.Contains(logged, sdkLogSentinel) {
t.Fatalf("SDK 的 keepalive 日志把对端自由文本原样打出来了:\n%s", logged)
}
t.Logf("keepalive 路径 SDK 日志(预期含记录、不含哨兵):\n%s", logged)
}
+59
View File
@@ -0,0 +1,59 @@
package mcp
// 本文件钉住 sdklog.go 的两条分支(线上错误 / 本地错误),sdklog_probe_test.go 则
// 用真 server 证明这条日志面确实走过收敛。
//
// 变异判据:把 scrubSDKError 的分支判据摘掉(一律保留原文)⇒ 第一条子用例红;
// 反过来「一律只留骨架」⇒ 第二、三条子用例红(运维会丢掉「为什么 session 被关掉」)。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
sdkjsonrpc "github.com/modelcontextprotocol/go-sdk/jsonrpc"
)
// TestScrubSDKError_SplitsWireFromLocal 是分类判据的正面证据。
func TestScrubSDKError_SplitsWireFromLocal(t *testing.T) {
t.Parallel()
// 1. 线上错误(链上有 *jsonrpc.Error):自由文本一个字节都不留,只留码与长度。
wire := fmt.Errorf("calling %q: %w", "ping",
&sdkjsonrpc.Error{Code: sdkjsonrpc.CodeInternalError, Message: "SENTINEL-MCPLOG 她刚才说的话"})
got := scrubSDKError(wire)
if strings.Contains(got, "SENTINEL-MCPLOG") {
t.Fatalf("线上错误的自由文本进了日志:%q", got)
}
if !strings.Contains(got, "code=-32603") {
t.Fatalf("线上错误的协议码被丢掉了:%q", got)
}
// 2. 本地错误:原因必须保留(运维靠它判断失败性质)。
if got := scrubSDKError(context.DeadlineExceeded); !strings.Contains(got, "deadline exceeded") {
t.Fatalf("本地错误的原因被吞掉了:%q", got)
}
// 3. 本地错误里的凭据形状仍要打码,换行仍要压平。
leaky := errors.New("dial failed: Authorization: Bearer sk-live-0123456789abcdef\nsecond line")
got = scrubSDKError(leaky)
if strings.Contains(got, "sk-live") {
t.Fatalf("本地错误里的凭据没被打码:%q", got)
}
if strings.Contains(got, "\n") || !strings.Contains(got, "second line") {
t.Fatalf("本地错误的换行没被压平(或压平把内容吞了):%q", got)
}
}
// TestScrubSDKAttr_KeepsScalars 钉住「只收敛自由文本面」:数字/布尔/时间是体积与
// 状态,不该被字符串化或截断。
func TestScrubSDKAttr_KeepsScalars(t *testing.T) {
t.Parallel()
if got := scrubSDKAttr(slog.Int("consecutiveFailures", 3)); got.Value.Kind().String() != "Int64" {
t.Fatalf("数字属性被改成了 %s(应保持数字)", got.Value.Kind())
}
}
+11 -2
View File
@@ -3,6 +3,8 @@ package restrict
import (
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
@@ -45,7 +47,8 @@ var SensitiveOps = []SensitiveOp{
}
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
// 同时限制进入前端/日志的文本块大小。它只管**长度** —— 秘密由 summarize 里的
// trace.RedactText 负责打码,不靠截断「顺手甩掉」。
const sensitiveSummaryRunes = 80
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
@@ -135,8 +138,14 @@ func httpCall(args map[string]any) (SensitiveOp, string, bool) {
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
// 多字节字符);目标为空时只返回动作。
//
// 目标先过 trace.RedactText 打码:这个摘要会进审计与前端小字两个读面,而
// terminal 的 command / http 的 url 里常常就带着凭据(`curl -H 'api_key=…'`、
// `?token=…`),截断并不能让它们变得安全。RedactText 自身已按同一档(80 rune)
// 截断,后面的 clipRunes 保留是为了让长度上限继续由本包的
// sensitiveSummaryRunes 掌握 —— 两处同档,叠加不改变结果。
func summarize(action, target string) string {
target = strings.TrimSpace(target)
target = trace.RedactText(strings.TrimSpace(target))
if target == "" {
return action
}
@@ -77,3 +77,41 @@ func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
}
}
// TestSensitiveCall_SummaryIsRedacted 锁定「参数摘要进审计与前端小字之前必须
// 打码」:terminal 的 command、http 的 url 里常常就带着凭据,截断不是脱敏。
func TestSensitiveCall_SummaryIsRedacted(t *testing.T) {
t.Parallel()
const secret = "sk-live-9f8e7d6c5b4a3210"
cases := []struct {
name string
args map[string]any
start string // 摘要应保留的开头(打码不能把摘要打没)
}{
{
name: "terminal",
args: map[string]any{"command": "curl -H 'api_key=" + secret + "' https://x"},
start: "terminal ",
},
{
name: "http",
args: map[string]any{"method": "POST", "url": "https://x/hook?token=" + secret},
start: "http POST ",
},
}
for _, tc := range cases {
_, detail, sensitive := SensitiveCall(tc.name, tc.args)
if !sensitive {
t.Fatalf("%s 应判定为敏感", tc.name)
}
if strings.Contains(detail, secret) {
t.Fatalf("%s 摘要泄露了凭据原文: %q", tc.name, detail)
}
if !strings.Contains(detail, "[redacted]") {
t.Fatalf("%s 摘要应带打码标记: %q", tc.name, detail)
}
if !strings.HasPrefix(detail, tc.start) {
t.Fatalf("%s 摘要应以 %q 开头(动作不能被一起打掉): %q", tc.name, tc.start, detail)
}
}
}
+14 -8
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
@@ -13,9 +15,13 @@ import (
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
const utf8BOM = "\ufeff"
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(字节)。它只含模型
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(**rune**)。它只含模型
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
// 消息内容带进日志。
//
// 🔴 单位是 rune 不是字节:审查模型回的中文/全角标点每个字 3 字节,按 40 **字节**
// 截会把其中一个字切成半个 —— 错误串变成非法 UTF-8,终端显示 U+FFFD、JSON 客户端
// 各自乱码。带上限出镜是刻意的(失败原因对排障有用),但上限必须以字符计。
const reviewExcerptLen = 40
// ParseReviewIndices decodes the keep-index array a review model returned, rising
@@ -83,12 +89,12 @@ func unwrapCodeFence(s string) string {
return s
}
// reviewExcerpt truncates text for an error message so the next parse failure is
// diagnosable without logging the whole completion.
// reviewExcerpt 把模型输出裁成一段可以进错误串的摘要。两步都不是可选的:
//
// - trace.RedactTextLimited 先清洗凭据形状(审查网关偶有把 Authorization 回显进
// 输出的),再按 **rune** 截到 reviewExcerptLen —— 按字节截会把多字节字符切成
// 半个,错误串因此变成非法 UTF-8;
// - 压平空白:一条诊断只占一行,也挡掉换行注入。
func reviewExcerpt(s string) string {
s = strings.TrimSpace(s)
if len(s) <= reviewExcerptLen {
return s
}
return s[:reviewExcerptLen] + "…"
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), reviewExcerptLen)
}
@@ -9,6 +9,7 @@ import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
@@ -126,3 +127,45 @@ func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
}
}
}
// TestReviewExcerpt_TruncatesByRune 钉住「上限按字符计,不按字节计」:审查模型回的中文
// 每个字 3 字节,按 40 **字节**截会把其中一个字切成半个,错误串因此成为非法 UTF-8
// (终端显示 U+FFFD、JSON 客户端各自乱码)。
//
// 变异判据:把 reviewExcerpt 换回 `s[:reviewExcerptLen] + "…"` 的字节截断 ⇒ 本用例红
// (utf8.ValidString 为 false)。
func TestReviewExcerpt_TruncatesByRune(t *testing.T) {
t.Parallel()
got := reviewExcerpt(strings.Repeat("中", reviewExcerptLen*2))
if !utf8.ValidString(got) {
t.Fatalf("截断后不是合法 UTF-8(按字节截断了半个汉字):%q", got)
}
body := strings.TrimSuffix(got, "…")
if n := utf8.RuneCountInString(body); n != reviewExcerptLen {
t.Fatalf("保留 %d 个字符,期望 %d:%q", n, reviewExcerptLen, got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("超限时应当带省略号:%q", got)
}
}
// TestReviewExcerpt_RedactsCredentialShapes 钉住「带上限出镜 ≠ 原样出镜」:摘要仍要
// 过 trace.RedactText,网关回显的凭据形状(sk-… / token=… 等)必须被打码。
//
// 变异判据:让 reviewExcerpt 只做截断、不过 trace.RedactTextLimited ⇒ 本用例红。
func TestReviewExcerpt_RedactsCredentialShapes(t *testing.T) {
t.Parallel()
const secret = "sk-abcdefghijklmnop"
got := reviewExcerpt("[0, 1] Authorization: Bearer " + secret)
if strings.Contains(got, secret) {
t.Fatalf("凭据形状没被打码:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("打码标记缺失:%q", got)
}
if !strings.Contains(got, "[0, 1]") {
t.Errorf("摘要丢了诊断价值(编号数组):%q", got)
}
}
+61 -21
View File
@@ -5,9 +5,16 @@
// future platforms; existing adapters (Telegram, OneBot) that already carry
// their own transport are left untouched.
//
// It depends only on the standard library and internal/logger. The auth token is
// never logged or surfaced in errors. The Authorization scheme is configurable
// (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
// It depends only on the standard library, internal/logger and internal/runtime/trace.
// The auth token is never logged or surfaced in errors. The Authorization scheme is
// configurable (default "Bearer" for OAuth2-style tokens; KOOK robots use "Bot").
//
// The envelope's message is an upstream free-text channel: upstreams echo the rejected
// request body into it, and that body holds what she said. It is therefore **not**
// carried in errors by default (see WithEnvelopeMessage); code and HTTP status are. The
// same rule — code/status stay, free text goes — applies to every channel adapter's own
// envelope handling (Telegram description, OneBot msg, iLink CDN x-error-message); see
// upstream_text.go.
package httpclient
import (
@@ -40,6 +47,9 @@ type Client struct {
token string
authScheme string
log *slog.Logger
// keepEnvelopeMessage 是**默认关闭**的开关:信封 message 只有显式 opt-in
// (WithEnvelopeMessage)才会进错误文本。零值 = 安全,见该函数的注释。
keepEnvelopeMessage bool
}
// Option customizes a Client at construction time.
@@ -81,6 +91,23 @@ func WithLogger(l *slog.Logger) Option {
}
}
// WithEnvelopeMessage 显式要求把信封的 message 保留在错误文本里。**默认不带**。
//
// 🔴 默认不带是刻意的,理由与 trace.UpstreamError 的 message 同源:上游会把被拒的输入
// 回显在 message 里(KOOK 实测 `invalid content: <她原话>`),于是那句话会随错误进
// 调用方的日志。排障真正用得到的分类信息是 `code` 与 `status`,两者**恒在**;message
// 只是人话提示。宁可少一条提示,也不让她的原话进日志。
//
// 只有在你**确认该上游不回显用户内容**时才打开。打开后 message 仍会压平空白、清洗
// 凭据形状并截到 maxUpstreamTextRunes(40 rune)—— 上限不像 trace 的 128,是这层没有
// 「一屏摘要」的诉求,短到装不下一句正文。
//
// 函数式选项的形状让最常用的 `New(...)` 天然就是最安全的那个:想放宽必须在这里显式
// 写出来,代码评审时一眼可见(与 trace.WithUpstreamMessage 同一个范式)。
func WithEnvelopeMessage() Option {
return func(c *Client) { c.keepEnvelopeMessage = true }
}
// New builds a Client. With no base URL the caller must pass absolute URLs (via
// the Do/Get helpers' path being an absolute URL).
func New(opts ...Option) *Client {
@@ -133,8 +160,8 @@ func (c *Client) resolve(path string) string {
//
// 地址在这一层只以来源形态出镜:target 由调用方给(GetJSON 的 query 可能带凭据、
// 绝对 path 可能带 user:pass@),完整串既不进错误也不进日志。响应体原文同理 ——
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的
// code/message(清洗并截断),其余字节一概不留。
// 上游可能把被拒的输入回显在体里,因此 4xx/5xx 只收敛成状态码 + 信封的 code
// (message 默认不带,见 WithEnvelopeMessage),其余字节一概不留。
func (c *Client) do(ctx context.Context, method, target string, body any) (json.RawMessage, error) {
host := trace.HostOf(target)
var buf bytes.Buffer
@@ -168,7 +195,7 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
return nil, fmt.Errorf("httpclient: read %s %s response: %w", method, host, trace.URLCause(err))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, apiFailure(method, host, resp, raw)
return nil, c.apiFailure(method, host, resp, raw)
}
var envelope APIEnvelope
@@ -176,36 +203,49 @@ func (c *Client) do(ctx context.Context, method, target string, body any) (json.
return raw, nil
}
if envelope.Code != 0 {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
method, host, envelope.Code, envelopeMessage(envelope.Message), ErrAPI)
if detail := c.envelopeDetail(envelope.Message); detail != "" {
return nil, fmt.Errorf("httpclient: %s %s: code %d: %s: %w",
method, host, envelope.Code, detail, ErrAPI)
}
return nil, fmt.Errorf("httpclient: %s %s: code %d: %w", method, host, envelope.Code, ErrAPI)
}
return envelope.Data, nil
}
// apiFailure 把一次非 2xx 响应收敛成一条不携带响应体原文的错误:来源主机 + 状态码,
// 外加(当体确实是 `{code,message}` 信封时)清洗并截断过的 code/message。其余字节只
// 经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份报文」这类相关性判断。
func apiFailure(method, host string, resp *http.Response, raw []byte) error {
// 外加(当体确实是 `{code,message}` 信封时)code —— message 同样只在显式 opt-in 时
// 才出现。其余字节只经 trace.UpstreamError 留下体积与 sha256 前缀,供「是不是同一份
// 报文」这类相关性判断。
func (c *Client) apiFailure(method, host string, resp *http.Response, raw []byte) error {
upstream := trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), raw)
var envelope APIEnvelope
if err := json.Unmarshal(raw, &envelope); err == nil {
message := envelopeMessage(envelope.Message)
detail := c.envelopeDetail(envelope.Message)
switch {
case envelope.Code != 0 && message != "":
case envelope.Code != 0 && detail != "":
return fmt.Errorf("httpclient: %s %s: code %d: %s: %w: %w",
method, host, envelope.Code, message, upstream, ErrAPI)
method, host, envelope.Code, detail, upstream, ErrAPI)
case envelope.Code != 0:
return fmt.Errorf("httpclient: %s %s: code %d: %w: %w", method, host, envelope.Code, upstream, ErrAPI)
case message != "":
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, message, upstream, ErrAPI)
case detail != "":
return fmt.Errorf("httpclient: %s %s: %s: %w: %w", method, host, detail, upstream, ErrAPI)
}
}
return fmt.Errorf("httpclient: %s: %w: %w", method, upstream, ErrAPI)
}
// envelopeMessage 是信封 message 进入错误前的唯一处理:压平空白,再交给
// trace.RedactText —— 它既清洗凭据形状,也把长度压到 80 rune(超出的带省略号)。
// 上游常把被拒的输入回显在 message 里,因此这一层不是可选的。
func envelopeMessage(s string) string {
return trace.RedactText(strings.Join(strings.Fields(s), " "))
// envelopeDetail 返回**允许进错误文本**的信封 message:默认空串(不带)。
//
// 为什么默认不带(这条取舍是实测得出的):上游会把被拒的请求正文回显在 message 里,
// 而那条正文就是她的话。M49 复审的探针在隔离副本里拿到的实际文本是
// `... code 1: invalid content: 她刚才说的那句话: ...: status=400` —— 她的原话既进了
// 错误也进了调用方的日志。排障需要的是 code 与 status(两者恒在),不是那句话。
//
// 只有 WithEnvelopeMessage() 显式打开后,这里才返回 upstreamText(压平空白 + 凭据打码
// + 截到 40 rune)。
func (c *Client) envelopeDetail(message string) string {
if !c.keepEnvelopeMessage {
return ""
}
return upstreamText(message)
}
@@ -1,10 +1,12 @@
package httpclient
// 本文件钉住本客户端的两条出镜边界(M41 #10/#11 那一族):
// 本文件钉住本客户端的出镜边界(M41 #10/#11 那一族,M50 补信封 message):
//
// - **非 2xx 的响应体原文不进错误**:旧实现把体截到 512B 拼进 error,而调用方
// (KOOK 适配器)会把这条 error 直接落日志 —— 上游在体里回显什么,日志里就有
// 什么。现在只留状态码、来源主机,以及信封的 code/message(已清洗、已截断)。
// 什么。现在只留状态码、来源主机,以及信封的 code(message 默认不带)。
// - **信封 message 默认不带**:上游会用它在被拒时回显请求正文(也就是她的话)。
// 要带必须显式 WithEnvelopeMessage(),那时仍过 upstreamText(凭据打码 + 40 rune)。
// - **请求地址的凭据不进错误**:target 可能带 `?access_token=…` 或 `user:pass@`,
// 而 net/http 的 *url.Error 会把整条地址拼进错误串。
//
@@ -12,13 +14,17 @@ package httpclient
// 打进日志的那段字符串。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// failingTransport 让 http.Client.Do 以传输层错误收场。选定它而不是拨一个真端口,
@@ -32,8 +38,8 @@ func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) {
// TestDoJSON_StatusErrorDoesNotEchoBodySentinel 4xx 体里放哨兵 ⇒ 错误里搜不到。
//
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留
// code/message,非信封体则一个字节都不留。
// 用非信封体(代理返回的 HTML 错误页就是这样):信封类的体按设计会保留 code(message
// 默认不带),非信封体则一个字节都不留。
func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-4XX-BODY"
@@ -67,44 +73,117 @@ func TestDoJSON_StatusErrorDoesNotEchoBodySentinel(t *testing.T) {
}
}
// TestDoJSON_StatusErrorKeepsEnvelopeCode 信封体仍要给出 code/message:这是本客户端
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。
// TestDoJSON_StatusErrorKeepsEnvelopeCode 非 2xx 的信封体仍要给出 code:这是本客户端
// 唯一认识的诊断面,替换掉体原文之后不能连它一起丢。而 message 是上游自由文本面,
// **默认不带**(M50 口径)—— 上游会用它在被拒时回显她的话。
func TestDoJSON_StatusErrorKeepsEnvelopeCode(t *testing.T) {
t.Parallel()
const sentinel = "invalid content: 她刚才说的那句话"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"code":40101,"message":"invalid token","data":null}`))
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + sentinel + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("401 却报成功")
t.Fatal("400 却报成功")
}
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "invalid token") {
t.Fatalf("信封的 code/message 丢了:%v", err)
if !strings.Contains(err.Error(), "code 40101") || !strings.Contains(err.Error(), "status=400") {
t.Fatalf("信封的 code / 上游 status 丢了:%v", err)
}
// 哨兵出现在 message 里 ⇒ 错误与日志都不得带它(变异:把 envelopeDetail 改回
// 无条件 upstreamText(message) ⇒ 本断言立刻红)。
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("send failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("信封 message(上游回显的她的原话)进了错误或日志:%s", surface)
}
}
// 哨兵链不变:分类靠 errors.Is,不靠文本。
if !errors.Is(err, ErrAPI) || !errors.Is(err, trace.ErrUpstream) {
t.Fatalf("err = %v, want both ErrAPI and trace.ErrUpstream", err)
}
}
// TestDoJSON_NonZeroCodeRedactsMessageSecrets 信封 message 是上游自由文本面:里面的
// 凭据形状必须被清洗掉,长度必须被截断。改回原样透传就变红。
func TestDoJSON_NonZeroCodeRedactsMessageSecrets(t *testing.T) {
// TestDoJSON_NonZeroCodeDropsEnvelopeMessage 是 2xx + `code != 0` 这**另一个分支**:
// 它此前把 message 直接拼进错误(M49 探针复现的原文里 `code 1: invalid content: …`
// 走的就是跨过 apiFailure 的这一支)。code 必须留下,message 必须不带。
func TestDoJSON_NonZeroCodeDropsEnvelopeMessage(t *testing.T) {
t.Parallel()
const sentinel = "invalid content: 她刚才说的那句话"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 200 + 非零 code:KOOK 一类平台的「业务失败也 200」形态。
_, _ = w.Write([]byte(`{"code":1,"message":"` + sentinel + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"content": "她刚才说的那句话"})
if err == nil {
t.Fatal("非零 code 却报成功")
}
if strings.Contains(err.Error(), "她刚才说的那句话") {
t.Fatalf("200 + code!=0 分支仍把 message 拼进错误:%v", err)
}
if !strings.Contains(err.Error(), "code 1") {
t.Fatalf("2xx 分支的 code 丢了(那一条没别的分类信息):%v", err)
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
}
// TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets opt-in(WithEnvelopeMessage)是
// 唯一能带 message 的通道:带上的那一刻仍必须清洗凭据形状。
func TestDoJSON_EnvelopeMessageOptInRedactsMessageSecrets(t *testing.T) {
t.Parallel()
const secret = "big-secret-value-123456"
body := `{"code":40101,"message":"invalid access_token=` + secret + `","data":null}`
body := `{"code":40101,"message":"access_token=` + secret + `","data":null}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()), WithToken(secret))
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
WithLogger(discardLogger()), WithToken(secret), WithEnvelopeMessage())
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("非零 code 却报成功")
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("信封 message 里的凭据原样进了错误:%v", err)
t.Fatalf("opt-in 的信封 message 没有清洗凭据形状:%v", err)
}
if !strings.Contains(err.Error(), "[redacted]") {
t.Fatalf("opt-in 生效后错误里没有 message(无法证明这条通道还活着):%v", err)
}
}
// TestDoJSON_EnvelopeMessageOptInIsBounded 钉住 opt-in 下 message 的长度口径:40 rune
// (maxUpstreamTextRunes)+ 省略号。这条让「到底哪一层在截、截到多少」变成可断言的事实 ——
// 上界刻意比 trace.RedactText 的 80 更短(错误里那句话是提示,不是摘要)。
func TestDoJSON_EnvelopeMessageOptInIsBounded(t *testing.T) {
t.Parallel()
long := strings.Repeat("a", 300)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()),
WithLogger(discardLogger()), WithEnvelopeMessage())
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("401 却报成功")
}
if !strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes)+"…") {
t.Fatalf("信封 message 没有按 %d rune 截断:%v", maxUpstreamTextRunes, err)
}
if strings.Contains(err.Error(), strings.Repeat("a", maxUpstreamTextRunes+1)) {
t.Fatalf("信封 message 超过了 %d rune 上限:%v", maxUpstreamTextRunes, err)
}
}
@@ -153,28 +232,3 @@ func TestGetJSON_UserinfoNeverEntersError(t *testing.T) {
t.Fatalf("剥掉地址时把来源一起丢了:%v", msg)
}
}
// TestDoJSON_EnvelopeMessageIsBoundedByRedactText 钉住长度口径:信封 message 只有一道
// 清洗(trace.RedactText,80 rune + 省略号)。本片曾按 200 rune 自己再截一刀,那层
// 永远命中不了 —— 这条用例让「到底哪一层在截」变成可断言的事实。
func TestDoJSON_EnvelopeMessageIsBoundedByRedactText(t *testing.T) {
t.Parallel()
long := strings.Repeat("a", 300)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":40101,"message":"` + long + `","data":null}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodGet, "/gateway/index", nil)
if err == nil {
t.Fatal("401 却报成功")
}
if !strings.Contains(err.Error(), strings.Repeat("a", 80)+"…") {
t.Fatalf("信封 message 没有按 80 rune 截断:%v", err)
}
if strings.Contains(err.Error(), strings.Repeat("a", 81)) {
t.Fatalf("信封 message 超过了 80 rune 上限:%v", err)
}
}
@@ -0,0 +1,48 @@
package httpclient
// 本文件是**渠道侧上游自由文本的唯一清洗入口**(M50)。它管的是同一件事在四个出口上
// 的形态:KOOK 一类 `{code,message}` 信封的 `message`、Telegram Bot API 的
// `description`、OneBot v11 信封的 `msg` / `wording`、iLink CDN 的 `x-error-message`
// 头。四者都是上游给的**人话**,都可能把被拒的请求内容回显出来 —— 而请求内容里就有
// 她的原话。
//
// 🔴 契约(改本文件前先读):
// - 上层口径是 **默认不带**:这些字段默认不进 error、不进日志(见 client.go 的
// WithEnvelopeMessage 与各渠道调用点的注释)。本文件的函数**只在调用方已经显式
// 选择保留**时才被调用 —— 它是「保留时的上限」,不是「可以带」的许可。
// - 清洗只有一份:`trace.RedactTextLimited`(凭据形状打码 + rune 截断)。本包**不**
// 另写一套正则或截断,另写一套必然漂移,而漂移的方向总是「其中一条出口开始原样
// 吐凭据」。
// - 压平空白也是必需的一步:一条诊断只许占一行,否则上游可以用换行伪造日志行
// (log injection)。
import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// maxUpstreamTextRunes 是上游自由文本允许保留的 rune 上限:**40**。
//
// 为什么比 `trace.RedactText` 的 80 更短:那一档服务的是**摘要素材**(出图、工具结果
// 摘要),读者只看这一条;本文件的产物进的是**错误链与日志**,同一条信息还会被调用方
// 再落一次,长度预算没有理由按摘要给。而 40 rune 已足够装下渠道侧真正有用的那类说明
// (`invalid token`、`参数错误`、`缺少 code`、`FLOOD_WAIT_30`),又短到装不下一句她
// 说的话 —— 上游回显 `invalid content: <正文>` 时,前半句就吃掉大半预算。
const maxUpstreamTextRunes = 40
// upstreamText 把一段上游自由文本收敛成可以出镜的短诊断:压平空白,再交给
// `trace.RedactTextLimited` 打码凭据形状并截到 maxUpstreamTextRunes(超出的带省略号)。
//
// 空串进 ⇒ 空串出;调用方据此判断「这一段有没有东西可显示」。
//
// 🔴 它**不**决定该不该带(默认不带)。把它的结果无条件拼进 error 仍然是把她的原话
// 送上日志 —— 调用点必须自己持有那个显式的开关(本包只有 WithEnvelopeMessage 一处)。
// 渠道插件当前**不带**任何自由文本,因此没有第二个调用点;将来若真要开,也要回到
// 这一条(`trace` 的唯一清洗 + 40 rune),不要在插件里另写一套截断。
func upstreamText(s string) string {
if s == "" {
return ""
}
return trace.RedactTextLimited(strings.Join(strings.Fields(s), " "), maxUpstreamTextRunes)
}
@@ -0,0 +1,56 @@
package httpclient
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
// 本客户端一行没改也应当自动安全 —— 上游把散文塞进 error.code / error.type 时,
// 错误文本与日志里都搜不到它,而 status / body_bytes 这些诊断面照旧。
//
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里**没有任何本地守卫**,
// 也就没有任何本地补丁能掩盖缺口。M46 的教训正是「守卫加在调用方 ⇒ 新调用点默认
// 不安全」,所以这一条故意不去调用点补。
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestDoJSON_StatusErrorDropsFreeTextUpstreamCode 4xx 报文的 error.code / error.type
// 里带散文(带空格与标点,不像协议令牌)⇒ 两个字段整体消失,而状态码与体积摘要仍在。
func TestDoJSON_StatusErrorDropsFreeTextUpstreamCode(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"error":{"code":"` + prose + `","type":"` + prose + `"}}`))
}))
defer srv.Close()
c := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()), WithLogger(discardLogger()))
_, err := c.DoJSON(context.Background(), http.MethodPost, "/message/create", map[string]any{"a": 1})
if err == nil {
t.Fatal("502 却报成功")
}
if !errors.Is(err, ErrAPI) {
t.Fatalf("err = %v, want ErrAPI", err)
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("upstream call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
}
}
// 可诊断性不能一起丢:状态码与体积摘要仍是分类与相关性比对的依据。
for _, want := range []string{"status=502", "body_bytes="} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("错误里缺少 %q:%v", want, err)
}
}
}
@@ -0,0 +1,18 @@
-- 0027 的回滚:只撤结构(删列),**不改写任何一行的数据**。
--
-- 回滚的代价(如实登记):`temporal` 这一列的值全部丢失。它对本迁移之前写入的行
-- 本来就恒为 `''`(未分型),因此丢的只是「本迁移之后提取器标出的时间性位」。
--
-- 真正需要说明的是**判定行为**:`retention_tier` / `expires_at` 是另两列,本回滚
-- **刻意不动它们**。
-- - 已被判成 temp 的行(其中一部分是 `temporal=one_off` 带来的)保持原状:读侧
-- 时效闸与后台清扫都只认 `expires_at`(纯 SQL,不依赖本列),因此回滚后它们的
-- 行为**逐字不变**,到期照旧消失、照旧被清扫;
-- - 若回滚时顺手把那些行的 `expires_at` 清空,那是一次 schema 回滚**改数据**,
-- 会把已经判定的临时记忆全部升回永久,同时还留在 `retention_tier='temp'` 上
-- —— 那正是「temp ⇔ 非空 expires_at」这条不变量的反面。
-- 要重判档位就重跑判定:列会以默认值 `''` 重建,历史行回到「未分型」。
--
-- SQLite 的 DROP COLUMN 要求该列不被索引/视图/触发器引用:本列没有索引(见 up),
-- 因此无需先删索引(0026 的 down 因为带索引才要以 DROP INDEX 开头)。
ALTER TABLE memory_items DROP COLUMN temporal;
@@ -0,0 +1,43 @@
-- 0027_memory_temporal:提取器产出侧的**时间性元数据位**(切片 M45)。
--
-- 为什么需要这一列(而不是再调一次词项阈值):M37 复审的量化结论是「defer 命中
-- 2/500 = 0.4%」不是阈值太严,而是**判定链缺信息位** —— 真正卡住上游的是提取器的
-- 改写:一次性事件被写成 30~80 字的第三人称长期陈述,规则闸只看落库正文,因此
-- 看不见「这是今天的一次性事件」这一位。实测反证:把 N6 的时点词窗口从 12 放宽到
-- 24 字,命中 2→14,多出的 11 条逐条都是**同一长期约定的重复陈述**(「一起晒太阳」
-- 暂时搁置 ×5、「傍晚出门走十分钟…今天没走成明天补上」×6)—— 放宽阈值等于 7 天后
-- 把约定本身删掉,违背 fail-safe。所以补的是信息位,不是阈值。
--
-- 为什么是**列**而不是新增一种 kind:`kind`(关于什么)、`scope`(谁的)、
-- `retention_tier`(留多久)、本条 `temporal`(**说的是一次发生的事,还是反复/长期
-- 成立的事**)是四根正交的轴。时间性是**提取器对原始对话的判断结果**,它必须能被
-- 单独存下来(事后巡检「模型当时标了什么」、以及将来回填),塞进 kind 就丢了这条
-- 观测线;靠提示词里的自然语言描述则违反 AGENTS §9「判据靠元数据,不靠提示词」。
--
-- 列值闭集(代码侧定义:internal/runtime/memory/temporal.go 的 TemporalKind):
-- one_off 一次性事件(今天 / 这次 / 某一次)⇒ 判定为 temp + 非空 expires_at
-- recurring 反复发生的规律(每天 / 每周 / 每次都…)⇒ long
-- durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份 ⇒ long
-- unknown 模型拿不准或没给出合法值 ⇒ **没有结论**,交给既有 N1~N7 规则判
-- '' 尚未分型:本迁移之前的历史行,以及非提取器的写入路径(默认值)
--
-- 🔴 默认值 `''` 而**不是** `unknown`:两者在判定上走同一条路(都落到旧规则),但
-- 含义不同 —— `''` 是「没问过」,`unknown` 是「问过,模型拿不准」。把历史行写成
-- `unknown` 会让「模型答了什么」这个观测面从第一行起就掺假。本迁移**刻意不回填**:
-- 历史行没有对应的提取器输出,拿正文反推「它当时是不是一次性事件」正是本片要消灭
-- 的那种猜测。
--
-- 幂等性:`ALTER TABLE ADD COLUMN` 在 SQLite 里**没有** IF NOT EXISTS 形式,因此本
-- 迁移与 0019 / 0026 同一手法 —— 依赖启动顺序「先 MigrateUp、后 NewStore」
-- (cmd/cyrene/wire.go):生产库里这一步先跑,随后 NewStore 的自举前滚
-- (internal/runtime/memory/temporal_schema.go 的 ensureTemporalSchema)走到
-- "duplicate column name" 是预期路径。列的形状必须与自举 DDL
-- (internal/runtime/memory/store_schema.go)**逐字同形**,由
-- internal/runtime/memory/temporal_migration_test.go 钉住。
--
-- 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入时的判定输入 +
-- 事后巡检的留痕列)。将来整理侧真要按它筛,再补索引不迟(两条路径一起加)。
--
-- 命名口径:表名复数(AGENTS §10 已登记例外)、列名与代码的 JSON 键同名
-- (`temporal`),与 0026 的 `retention_tier` 同族。
ALTER TABLE memory_items ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
@@ -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);
+2 -2
View File
@@ -231,7 +231,7 @@ func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector,
resp, err := e.client.Do(req)
if err != nil {
return nil, fmt.Errorf("embedding: request: %w", err)
return nil, fmt.Errorf("embedding: request: %w", trace.URLCause(err))
}
defer func() { _ = resp.Body.Close() }()
@@ -298,7 +298,7 @@ func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) (
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("embedding: build request: %w", err)
return nil, fmt.Errorf("embedding: build request: %w", trace.URLCause(err))
}
req.Header.Set("Content-Type", "application/json")
if e.cfg.APIKey != "" {
+4 -1
View File
@@ -5,6 +5,8 @@ import (
"net"
"net/url"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件只管**端点地址的规范化**:把「连接语义上不可靠」的回环主机名别名改写
@@ -39,7 +41,8 @@ const loopbackIPv4 = "127.0.0.1"
func normalizeEndpointURL(raw string) (string, error) {
u, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("embedding: parse api url: %w", err)
// 不回原文:url.Parse 的 *url.Error 会把整条地址(query 里可能是 api_key)渲染进去。
return "", fmt.Errorf("embedding: parse api url: %w", trace.URLCause(err))
}
if u.Scheme != "http" && u.Scheme != "https" {
// 不是本客户端能拨的 scheme:原样返回,让后续的报错如实暴露配置问题,
@@ -0,0 +1,57 @@
package embedding
// 本文件是 M49 的**出口回归探针**:令牌守卫上移进 trace.UpstreamError 之后,
// embedding 这一处一行没改也应当自动安全 —— 兼容层把散文塞进 error.code /
// error.type 时,错误文本与日志里都搜不到它,而 status / body_bytes 照旧。
//
// 断言形状是「摘掉 trace.UpstreamError 里的守卫就变红」:这里没有任何本地守卫。
// 这一处最敏感:/embeddings 的 input 就是她的记忆正文,回显它的散文不能进日志。
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestAPIEmbedder_Non200DropsFreeTextErrorCode 400 报文的 error.code / error.type
// 里带散文 ⇒ 两个字段整体消失,而状态码与体积摘要仍在。
func TestAPIEmbedder_Non200DropsFreeTextErrorCode(t *testing.T) {
t.Parallel()
const prose = "Invalid value: '她刚才说的那句话'"
rt := roundTripFunc(func(*http.Request) (*http.Response, error) {
body := `{"error":{"code":"` + prose + `","type":"` + prose + `"}}`
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "https://embed.example.com/v1/embeddings"},
WithRoundTripper(rt),
)
_, err := emb.Embed(context.Background(), []string{"hi"})
if err == nil {
t.Fatal("400 却报成功")
}
var logBuf bytes.Buffer
slog.New(slog.NewJSONHandler(&logBuf, nil)).Error("embed call failed", "err", err)
for _, surface := range []string{err.Error(), logBuf.String()} {
if strings.Contains(surface, "她刚才说的那句话") {
t.Fatalf("上游 error.code/type 里的散文进了错误或日志:%s", surface)
}
}
for _, want := range []string{"status=400", "body_bytes="} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("错误里缺少 %q:%v", want, err)
}
}
}
@@ -0,0 +1,89 @@
package embedding
// 本文件是 M52 的出口回归探针:embedding 客户端里「把 net/http 的 *url.Error 直接
// 打进错误串」的两处 —— `embedChunk` 的传输失败与 `normalizeEndpointURL` 的解析失败 ——
// 一律先过 trace.URLCause。
//
// 为什么这两处特别敏感:/embeddings 的端点常常配成带 `?api_key=` 的网关地址,而
// `client.Do` 失败时 net/http 会把**整条地址**渲染进错误串(密码被打码、用户名与 query
// 不打码)。原文进日志就等于把网关凭据交出去。
//
// 这一包与 model/vision 的差别:这里原本用的是 `%w`(不是 `%v`),因此除了「去地址」,
// 还必须钉住 `%w` 的链没被削弱 —— 把 URLCause 换成「新建一个错误」会让
// errors.Is(err, context.DeadlineExceeded) 变红。
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
const (
urlCauseQuerySecret = "SECRET-2f8a1c"
urlCauseUser = "svc-user"
urlCauseBadHost = "bad host.example.com"
)
func embedURLCauseGoodURL() string {
return "https://" + urlCauseUser + ":svc-pass@embed.example.com/v1/embeddings?api_key=" + urlCauseQuerySecret
}
func embedURLCauseBadURL() string {
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1/embeddings?api_key=" + urlCauseQuerySecret
}
// TestEmbedDropsURLFromTransportError 钉住 embedChunk 里 `e.client.Do` 失败那一处,
// 并钉住 `%w` 的哨兵链仍然成立(context.DeadlineExceeded 可 errors.Is)。
func TestEmbedDropsURLFromTransportError(t *testing.T) {
t.Parallel()
emb := NewAPI(
config.EmbeddingConfig{APIURL: embedURLCauseGoodURL()},
WithRoundTripper(roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, fmt.Errorf("net/http: %w", context.DeadlineExceeded)
})),
)
_, err := emb.Embed(context.Background(), []string{"hi"})
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) {
t.Fatalf("错误串带出了 query 里的凭据:%v", err)
}
if strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
}
if !strings.Contains(err.Error(), "deadline exceeded") {
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
}
// `%w` 链必须还在:URLCause 只剥 *url.Error 外壳,内层原因原样返回。
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("err = %v, want errors.Is(context.DeadlineExceeded)(哨兵链不许被带偏)", err)
}
}
// TestEmbedDropsURLFromParseError 钉住 normalizeEndpointURL 的解析失败:
// url.Parse 的 *url.Error 里就是整条 api URL。
func TestEmbedDropsURLFromParseError(t *testing.T) {
t.Parallel()
emb := NewAPI(config.EmbeddingConfig{APIURL: embedURLCauseBadURL()})
_, err := emb.Embed(context.Background(), []string{"hi"})
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("解析失败的串带出了 api URL 里的凭据:%v", err)
}
if !strings.Contains(err.Error(), "invalid character") {
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
}
if !strings.Contains(err.Error(), "parse api url") {
t.Fatalf("错误串丢了来源标记:%v", err)
}
}
+8
View File
@@ -185,6 +185,9 @@ func (l *Loop) attributeConversation(ctx context.Context, msgs []model.Message)
// 既有角色(她自己的历史发言),也有人(带昵称的第三方人员与
// 主人本人),而模型只有被告知这两种标注的区别,才读得懂
// 「哪些话是主人说的、哪些话是外人说的」(design/02 §3)。
// 受限档还带两句呈现口径(对第三方不用主人专属称呼、主人的私事
// 即使读到也不外说)—— 归属解决「谁是谁」,这两句解决「怎么称呼、
// 能说什么」,二者缺一都会在群里复现审计 2026-10-30 的病灶。
// 非受限会话逐字保持多角色支持落地时的文案。
func (l *Loop) roleContextMessage(env speakerEnv) model.Message {
var b strings.Builder
@@ -197,6 +200,11 @@ func (l *Loop) roleContextMessage(env speakerEnv) model.Message {
if env.restricted {
b.WriteString("本会话的发言按下面的「发言归属」标注分属不同发言者:标了昵称的来自第三方人员(非管理员账户),标了主人的来自主人本人——" +
"不要把第三方的话当成主人说的,也不要把别人的话当成你自己说过的、不要继承他们的身份或口吻;" +
// 以下两句是审计 2026-10-30 的 P0-4(与 channelbridge 的第三方边界 /
// 公域呈现块同一口径的纵深防御):归属只解决「谁是谁」,不解决「怎么
// 称呼」与「能说什么私事」——群员被叫「亲爱的」、主人的住址被复述进群,
// 走的正是这个缺口。
"对第三方人员不要用只属于主人的亲昵称呼(如「亲爱的」),也不要把主人的住址、姓名、作息、行程、健康、家人、关系等私事告诉对方——即使这些信息出现在记忆里;" +
"该标注从其出现处起生效,直到下一条「发言归属」为止。")
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)
}
}
+15 -1
View File
@@ -85,6 +85,11 @@ type Result struct {
// 用户消息的 document 列或随响应回传,使客户端能看到「这份附件读到了 /
// 读不到」—— 没有它,抽取失败对客户端就是不可见的。
Documents []history.DocumentRef
// Voice 是本回合的**出站语音结论**:nil 表示本回合没有请求语音(纯文本);
// Name 非空表示合成成功且音频副本已落盘;Requested 为真但 Name 为空、
// FallbackReason 非空表示她请求了语音但降级为文本(如 tts_timeout)。
// 字段语义详见 OutboundVoice。
Voice *OutboundVoice
// docSeen 是 Documents 的去重索引(存储名 → 下标),作用域限于本次
// Result,绝不跨 Run 共享。
docSeen map[string]int
@@ -138,7 +143,13 @@ type Loop struct {
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
skillFace *SkillFace
log *slog.Logger
// voiceSynth / voiceStore 是 speak 的注入依赖(M57)。两者都非 nil 时
// 循环向模型广告 speak 并自行承载它的执行(见 voice_deps.go);
// speakBudget 是超时预算的替换公式,nil 用内置公式。
voiceSynth VoiceSynth
voiceStore VoiceStore
speakBudget func(int) int
log *slog.Logger
}
// New 由 opts 构建 Loop 并应用默认值。MaxRounds 非正时
@@ -211,6 +222,9 @@ func New(opts Options) *Loop {
imageResolver: opts.Vision,
documentResolver: opts.DocumentResolver,
documentInjectChars: docInjectChars,
voiceSynth: opts.VoiceSynth,
voiceStore: opts.VoiceStore,
speakBudget: opts.SpeakBudget,
log: sourcedLog(opts.Log),
}
}
+11 -15
View File
@@ -7,21 +7,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// FilterSharedVisible 返回群聊/陌生人(受限环境)可见的记忆子集:只保留
// 非敏感的共享域(用户习惯 ScopeUser 与项目 ScopeProject,人人可读),
// 并剔除 L4 意图记录(capacity.RecordIntent 的回写格式,内容是主人逐句
// 原话的机器可读形态 —— 虽落在共享域以便 L4 闭环学习,但只该暴露
// 「不敏感的习惯结论」,不该暴露主人原话)。角色日常(ScopePersona)
// 是主人私有的敏感项,不在可见域内(design/03 §3/§5「不泄露主人敏感信息」)。
// FilterSharedVisible 返回群聊/陌生人(受限环境)可见的记忆子集:按**作用域**
// 只保留共享域(用户习惯 ScopeUser 与项目 ScopeProject,人人可读),剔除主人
// 私有的角色日常(ScopePersona);再按**敏感度**剔除敏感标记为真的条目
// (MemoryItem.SharedVisible,迁移 0028);并剔除 L4 意图记录
// (capacity.RecordIntent 的回写格式,内容是主人逐句原话的机器可读形态 —— 虽落在
// 共享域以便 L4 闭环学习,但只该暴露「不敏感的习惯结论」,不该暴露主人原话)。
//
// 它是**唯一**的隔离判据:循环的记忆注入(retrieveMemoryBlock)与模型
// 可达的 knowledge 检索工具都调用它。knowledge 若自造一套过滤(或不过滤),
// 一旦管理面板把它放行给群聊/陌生人,就会返回角色日常与主人原话
// (审计 2026-10-05:knowledge 绕过群聊/陌生人召回过滤,P0 红线)。
//
// 🔴 **它只决定「受限车道看不看得见」**:主人车道(`!env.Restricted()`)根本不过
// 这道过滤,照常读到全部记忆(含 sensitive=1)。sensitive 是数据层标记,不是
// 权限系统(切片 M63、审计 2026-10-30 §7 P0-1)。
func FilterSharedVisible(results []memory.Result) []memory.Result {
visible := make([]memory.Result, 0, len(results))
for _, r := range results {
if !sharedScope(r.Item.Scope) {
if !r.Item.SharedVisible() {
continue
}
if capacity.IsIntentRecord(r.Item.Content) {
@@ -32,14 +36,6 @@ func FilterSharedVisible(results []memory.Result) []memory.Result {
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 由检索结果构建前导系统块。非空
// 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)
}
}
+9
View File
@@ -214,6 +214,15 @@ type Options struct {
// 或重排模型所见的消息。它是循环中与
// OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
VoiceSynth VoiceSynth
VoiceStore VoiceStore
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
SpeakBudget func(runes int) int
// Log 是基础 logger。为 nil 时替换为标记来源
// "runtime/loop" 的丢弃 logger。
Log *slog.Logger
+2 -2
View File
@@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
}
schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() {
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas))
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
}
names := make([]string, 0, len(schemas))
@@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
out = append(out, s)
}
}
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out))
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
}
// capacityTexts 为 capacity 判定器提取近期对话文本:
+1
View File
@@ -187,6 +187,7 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
}
msgs = batch.messages
res.ToolCallsExecuted += batch.executed
applyBatchVoice(res, &batch)
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
// 关键工具循环安全网(或终止钩子)不等真正的最终答案
+336
View File
@@ -0,0 +1,336 @@
package loop
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
// 调了 speak = 这条想用语音;不调 = 发文本。
//
// 三条执行口径:
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
const (
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
speakToolName = "speak"
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
speakBudgetBaseMs = 3000
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
speakBudgetPerRuneMs = 25
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
// 绝不让她的一条消息把整个回合挂住。
speakBudgetMaxMs = 20000
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
speakMaxRunes = 1200
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
speakAudioOrigName = "speak."
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
// 客户端据此决定怎么显示「这条本来想用语音」)。
speakReasonTimeout = "tts_timeout"
speakReasonFailed = "tts_failed"
speakReasonNoAudio = "tts_no_audio"
speakReasonStoreFailed = "tts_store_failed"
speakReasonTooLong = "text_too_long"
speakReasonInvalidArgs = "invalid_args"
)
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
type speakTool struct {
synth VoiceSynth
store VoiceStore
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
budget func(runes int) int
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
// (发音人绑人格,链本身不动)。
persona func(context.Context) string
log *slog.Logger
}
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
var _ sdk.Tool = speakTool{}
// Name 返回工具标识。
func (speakTool) Name() string { return speakToolName }
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
func (speakTool) Description() string {
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
}
// Parameters 返回工具输入的 JSON Schema。
func (speakTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{
"type": "string",
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
},
"emotion": map[string]any{
"type": "string",
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
"这个字段只做额外微调,不会换掉发音人。",
},
},
"required": []string{"text"},
}, nil
}
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
type speakArgs struct {
text string
emotion string
runes int
}
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
in, err := parseSpeakArgs(args)
if err != nil {
return nil, err
}
if t.synth == nil || t.store == nil {
return nil, &speakError{Code: speakReasonFailed,
Err: errors.New("speak: voice synthesis is not configured")}
}
budget := t.budgetMs(in.runes)
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
defer cancel()
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
started := time.Now()
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
cost := int(time.Since(started) / time.Millisecond)
if err != nil {
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
}
ext, extOK := speakExt(audio.Format)
if len(audio.Data) == 0 || !extOK {
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
}
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
if err != nil {
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
}
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
return map[string]any{
"name": ref.Name,
"mime": ref.MIME,
"size": ref.Size,
"durationMs": int64(0),
"budgetMs": budget,
"costMs": cost,
}, nil
}
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
if t.persona != nil {
if pid := t.persona(turnCtx); pid != "" {
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
}
}
if emotion != "" {
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
}
return synthCtx
}
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
func (t speakTool) budgetMs(runes int) int {
if t.budget != nil {
if ms := t.budget(runes); ms > 0 {
return ms
}
}
return speakBudgetMs(runes)
}
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
code := speakReasonFailed
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
code = speakReasonTimeout
}
if t.log != nil {
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
t.log.Warn("speak degraded to text",
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
}
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
}
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
if t.log == nil {
return
}
t.log.Info("speak synthesized",
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
}
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
type speakError struct {
Code string
BudgetMs int
CostMs int
Err error
}
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
func (e *speakError) Error() string {
if e.Err == nil {
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
}
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
}
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
func (e *speakError) Unwrap() error { return e.Err }
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
// 归到 argument-validation 而不是执行失败。
func parseSpeakArgs(args any) (speakArgs, error) {
m, err := speakArgMap(args)
if err != nil {
return speakArgs{}, err
}
text, _ := m["text"].(string)
text = strings.TrimSpace(text)
if text == "" {
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
}
runes := utf8.RuneCountInString(text)
if runes > speakMaxRunes {
return speakArgs{}, &speakError{Code: speakReasonTooLong,
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
}
emotion, _ := m["emotion"].(string)
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
}
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
func speakArgMap(args any) (map[string]any, error) {
if args == nil {
return map[string]any{}, nil
}
m, ok := args.(map[string]any)
if !ok {
return nil, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
}
return m, nil
}
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
func speakBudgetMs(runes int) int {
if runes < 0 {
runes = 0
}
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
if ms > speakBudgetMaxMs {
return speakBudgetMaxMs
}
if ms < speakBudgetBaseMs {
return speakBudgetBaseMs
}
return ms
}
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
// 绝不拿一个读不出 Content-Type 的名字落盘。
func speakExt(format string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(format)) {
case "":
return "wav", true
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
return strings.ToLower(strings.TrimSpace(format)), true
default:
return "", false
}
}
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
func (l *Loop) speakSpec() *model.ToolSpec {
if !l.voiceEnabled() {
return nil
}
t := l.speakTool()
params, err := t.Parameters()
if err != nil {
return nil
}
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
}
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
func (l *Loop) speakTool() speakTool {
return speakTool{
synth: l.voiceSynth,
store: l.voiceStore,
budget: l.speakBudget,
persona: l.personaFor,
log: l.log,
}
}
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
// panic 兜底与允许集软判对普通工具仍然生效。
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
if tc.Name == speakToolName && l.voiceEnabled() {
return l.speakTool().Execute(ctx, tc.Args)
}
if d == nil {
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
}
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
}
+334
View File
@@ -0,0 +1,334 @@
package loop
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
// audio 决定成功时的产物,provider 供日志断言。
type stubSynth struct {
audio mvoice.Audio
provider string
err error
delay time.Duration
gotText string
}
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
s.gotText = text
if s.delay > 0 {
select {
case <-time.After(s.delay):
case <-ctx.Done():
return mvoice.Audio{}, "", ctx.Err()
}
}
if s.err != nil {
return mvoice.Audio{}, "", s.err
}
return s.audio, s.provider, nil
}
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
type stubStore struct {
ref VoiceRef
err error
gotName string
gotData []byte
}
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
s.gotName = origName
s.gotData = data
if s.err != nil {
return VoiceRef{}, s.err
}
return s.ref, nil
}
// newSpeakTool 装配一个注入了桩的 speak 工具。
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
return speakTool{synth: synth, store: store}
}
// speakCodeOf 取出 speak 失败的结构化原因码。
func speakCodeOf(t *testing.T, err error) *speakError {
t.Helper()
var se *speakError
if !errors.As(err, &se) {
t.Fatalf("error %v is not *speakError", err)
}
return se
}
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
// 长文封顶,负数按 0 处理(绝不返回负预算)。
func TestSpeakBudgetMs(t *testing.T) {
t.Parallel()
cases := []struct {
runes int
want int
}{
{-5, speakBudgetBaseMs},
{0, speakBudgetBaseMs},
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
{680, speakBudgetMaxMs},
{speakMaxRunes, speakBudgetMaxMs},
}
for _, c := range cases {
if got := speakBudgetMs(c.runes); got != c.want {
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
}
}
}
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
// 名字落盘)。
func TestSpeakExt(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want string
ok bool
}{
{"", "wav", true},
{" ", "wav", true},
{"WAV", "wav", true},
{"mp3", "mp3", true},
{" Opus ", "", false},
{"exe", "", false},
}
for _, c := range cases {
got, ok := speakExt(c.format)
if got != c.want || ok != c.ok {
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
}
}
}
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
func TestParseSpeakArgs_Validation(t *testing.T) {
t.Parallel()
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
}
// 恰好上限、且是多字节:按 rune 计数,应当放行。
in, err := parseSpeakArgs(map[string]any{
"text": strings.Repeat("あ", speakMaxRunes),
"emotion": " 温柔 ",
})
if err != nil {
t.Fatalf("at-limit runes rejected: %v", err)
}
if in.runes != speakMaxRunes {
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
}
if in.emotion != "温柔" {
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
}
}
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
// 且 durationMs 恒为 0(未解析,不是零长度)。
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
t.Parallel()
synth := &stubSynth{
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
provider: "emotiontts",
}
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
tk := newSpeakTool(synth, store)
tk.budget = func(int) int { return 777 }
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
if len(store.gotData) != 3 {
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
}
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("result type = %T, want map[string]any", out)
}
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
}
if m["durationMs"] != int64(0) {
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
}
if m["budgetMs"] != 777 {
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
}
if _, ok := m["costMs"].(int); !ok {
t.Fatalf("costMs = %v, want int", m["costMs"])
}
}
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
&stubStore{},
)
tk.budget = func(int) int { return 1 }
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonTimeout {
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
}
if se.BudgetMs != 1 {
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
}
if se.CostMs < 0 {
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
}
}
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
t.Parallel()
cases := []struct {
name string
audio mvoice.Audio
}{
{"empty bytes", mvoice.Audio{Format: "wav"}},
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
}
if store.gotName != "" {
t.Fatalf("store was called with %q, want no write at all", store.gotName)
}
})
}
}
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
&stubStore{err: errors.New("disk full")},
)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonStoreFailed {
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
}
if !errors.Is(err, se.Err) {
t.Fatalf("error chain lost the store error: %v", err)
}
}
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
// 以结构化失败返回,使回合仍能继续。
func TestSpeakTool_NotConfigured(t *testing.T) {
t.Parallel()
for _, tk := range []speakTool{
newSpeakTool(nil, &stubStore{}),
newSpeakTool(&stubSynth{}, nil),
} {
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
}
}
}
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
func TestSpeakSpec(t *testing.T) {
t.Parallel()
if spec := New(Options{}).speakSpec(); spec != nil {
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
}
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
}
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
if spec == nil {
t.Fatal("wired speakSpec = nil, want a spec")
}
if spec.Name != speakToolName {
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
}
if spec.Parameters == nil {
t.Fatal("spec parameters = nil, want the JSON schema")
}
}
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
// (因此不受前面任何收缩影响)、已有同名不重复追加。
func TestWithSpeakSpec(t *testing.T) {
t.Parallel()
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
unwired := New(Options{})
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
}
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
got := wired.withSpeakSpec(base)
if len(got) != len(base)+1 {
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
}
if last := got[len(got)-1].Name; last != speakToolName {
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
}
again := wired.withSpeakSpec(got)
if len(again) != len(got) {
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
}
}
+32
View File
@@ -3,6 +3,7 @@ package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
@@ -128,6 +129,37 @@ func notAllowedReason(name string) string {
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
}
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
//
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
if !l.voiceEnabled() {
return
}
env := sessionEnvFrom(ctx)
if env.ActorEnv == restrict.ActorOwner {
return
}
for i := range calls {
if !actions[i].run || calls[i].Name != speakToolName {
continue
}
reason := notAllowedReason(calls[i].Name)
actions[i] = toolCallAction{run: false, reason: reason}
l.emitAudit(AuditEvent{
Who: string(env.ActorEnv),
What: auditToolRejectNotAllowed,
Result: reason,
})
}
}
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中
// 安全门仍能工作(软 not_allowed 结果)。
@@ -8,7 +8,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
@@ -235,6 +237,75 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
}
}
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
// 主人环境照常放行。
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
cases := []struct {
env restrict.ActorEnv
allow bool
}{
{restrict.ActorGroupMember, false},
{restrict.ActorStranger, false},
{restrict.ActorOwner, true},
}
for _, tc := range cases {
mu.Lock()
audited = nil
mu.Unlock()
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
actions := []toolCallAction{{run: true}}
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
if actions[0].run != tc.allow {
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
}
mu.Lock()
got := append([]AuditEvent(nil), audited...)
mu.Unlock()
if tc.allow {
if len(got) != 0 {
t.Fatalf("env %s audited %+v, want none", tc.env, got)
}
continue
}
if !strings.Contains(actions[0].reason, "not_allowed") {
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
}
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
}
}
// speak 未接线时没有可执行的旁路,门不该动任何调用。
actions := []toolCallAction{{run: true}}
New(Options{}).applyToolAdmissions(
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
nil,
[]agent.ToolCall{{Name: speakToolName}},
actions,
)
if !actions[0].run {
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
}
}
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
t.Helper()
+17 -93
View File
@@ -5,12 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
@@ -77,6 +75,9 @@ type toolRunResult struct {
messages []model.Message
executed int
terminate bool
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
voice *OutboundVoice
}
// toolLoopState 携带关键工具循环安全网的每次 run 状态。
@@ -156,6 +157,10 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
l.applyToolOutcomeHooks(ctx, calls, outcomes)
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
// 出站结论要看**模型最终看到的那份**。
res.voice = l.captureVoice(calls, outcomes)
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮
// (OpenClaw 的 shouldTerminateToolBatch)。
if !res.terminate && shouldTerminateBatch(outcomes) {
@@ -231,11 +236,19 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证
// 此时整批以错误回灌)。
// 逐调用否决。
//
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
// 的旁路。
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
if d != nil {
l.applyToolSecurityGate(ctx, d, calls, actions)
} else {
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
}
if l.hooks.BatchAdmit != nil {
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
@@ -287,95 +300,6 @@ func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall
}
}
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
if d == nil {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
// 不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
// 由 classifyToolBatch 为两种跳过原因返回。
func duplicateCallReason() string {
return "duplicate call with identical arguments already issued in this batch"
+107
View File
@@ -0,0 +1,107 @@
package loop
// 本文件承载**单个/一批工具调用的执行**(span、流式帧、结果归一化、
// 有界并行),与 tools.go 的分类、准入与回渲职责分开 —— 从 tools.go
// 抽出,使两边都留在 500 行以内(AGENTS.md §10)。
import (
"context"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
// speak 由循环自己承载(见 invokeTool),没有 dispatcher 也能执行;其余
// 工具缺 dispatcher 时仍按原样短路,不为此多开 span 或流式帧。
if d == nil && !(l.voiceEnabled() && tc.Name == speakToolName) {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 分派见 invokeTool:路标识桥(tool.WithLegID)与 speak 的循环内承载都在
// 那里。路标识(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把
// 互斥身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路
// 都挂,取值 = 该路的 turnID),工具层凭 tool.LegIDFrom 读到「我是哪一路」
// 做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := l.invokeTool(ctx, d, tc)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
+40
View File
@@ -0,0 +1,40 @@
package loop
// OutboundVoice 是一次 Run 的**出站语音结论**:模型在回合内调用 speak 工具
// 请求合成语音时,循环把这次请求的结局归一化到本结构,随 Result 交还调用方
// (网关据此决定这条回复是发音频还是发文本,持久化层据此记下「这条是语音」
// 与降级原因)。它是进程内类型,不直接序列化给客户端。
//
// 三种状态互斥且由字段组合判定:
//
// - Result.Voice == nil —— 本回合**没有**请求语音,回复就是纯文本;
// - Name != "" —— 合成成功,且音频副本**已经落盘**(Name 即服务端存储名,
// Mime/Size 描述该副本);
// - Requested && Name == "" && FallbackReason != "" —— 她**请求了**语音,
// 但合成降级为文本(如 tts_timeout);FallbackReason 是机器可读的降级码,
// 客户端可以据此特殊显示「这条本来想用语音」。
//
// 语音是她的表达选择,不是身份或权限判定 —— 谁能用语音仍由渠道层 actor
// 元数据 + 工具可达性标记决定,本结构只记录结局。
type OutboundVoice struct {
// Requested 报告本回合是否发起过语音合成请求(成功与降级都为 true)。
Requested bool
// Name 是落盘音频副本的服务端存储名;空表示没有可用音频(降级或未请求)。
Name string
// Mime 是音频副本的媒体类型(如 audio/wav),随 Name 一起有意义。
Mime string
// Size 是音频副本的字节数。
Size int64
// DurationMs 是音频时长(毫秒)。**不知道就是 0** —— 本项目绝不用比特率
// 或字数估算时长(与 voice.Copy.DurationMs 同一口径);0 表示「未解析」,
// 不表示零长度音频。
DurationMs int64
// CostMs 是这次合成实际耗时(毫秒),用于对照预算看是否接近超时。
CostMs int
// BudgetMs 是本次合成被允许的超时预算(毫秒),由文本 rune 数推导
// (见 speakBudgetMs);未请求时为 0。
BudgetMs int
// FallbackReason 是降级原因码(如 tts_timeout),仅在「请求了但没有音频」
// 时非空;它让调用方与客户端能把「想用语音但没成」与「本来就用文本」区分开。
FallbackReason string
}
+172
View File
@@ -0,0 +1,172 @@
package loop
// 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、
// 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。
// 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与
// 预算口径,而这两样只有循环持有。
import (
"context"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。
// 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」,
// 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。
type VoiceSynth interface {
// TTSWithProvider 合成文本并返回音频与**实际**提供者标识
// (降级后是备用的那个),供日志与可观测性使用。
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
}
// VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、
// 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用
// documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会
// import 循环,反向依赖构成测试期的 import cycle。
type VoiceRef struct {
// Name 是服务端存储名(客户端凭它取回音频)。
Name string
// MIME 是存储层字节嗅探所得,不是合成侧的声明。
MIME string
// Size 是该副本的字节数。
Size int64
}
// VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档
// 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经
// VoiceStoreFunc 适配即可接上(见该类型的用法说明)。
type VoiceStore interface {
// SaveAudio 落盘一份音频副本并返回其交付描述。
SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error)
}
// VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。
// 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents:
//
// loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) {
// ref, err := docs.SaveAudio(ctx, name, data)
// return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err
// })
type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error)
// SaveAudio 实现 VoiceStore。
func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) {
return f(ctx, origName, data)
}
// voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 ——
// 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告,
// 让模型面与接线前逐字节一致。
func (l *Loop) voiceEnabled() bool {
return l != nil && l.voiceSynth != nil && l.voiceStore != nil
}
// withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas
// 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与
// 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里,
// 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由
// 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下
// Policy.Allow 以「未知工具」拒绝,fail-closed。
func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec {
spec := l.speakSpec()
if spec == nil {
return schemas
}
for i := range schemas {
if schemas[i].Name == spec.Name {
return schemas
}
}
out := make([]model.ToolSpec, 0, len(schemas)+1)
out = append(out, schemas...)
return append(out, *spec)
}
// applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。
// 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」
// 发生在哪一批都算数。
//
// 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的
// 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次
// 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮
// 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回
// 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖:
// 后到的成功就是这条消息最终的语音。
func applyBatchVoice(res *Result, batch *toolRunResult) {
if batch.voice == nil {
return
}
if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" {
return
}
res.Voice = batch.voice
}
// captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil
// 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯
// 文本呈现 —— 与没有 speak 之前逐字节一致。
//
// 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘,
// 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后
// 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用
// 文本」。
func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice {
var lastOK, lastFail *OutboundVoice
n := len(calls)
if len(outcomes) < n {
n = len(outcomes)
}
for i := 0; i < n; i++ {
if calls[i].Name != speakToolName || outcomes[i].Skipped {
continue
}
if outcomes[i].Err == nil {
lastOK = synthesizedVoice(outcomes[i].Result)
continue
}
lastFail = degradedVoice(outcomes[i].Err)
}
if lastOK != nil {
return lastOK
}
return lastFail
}
// synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。
// 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时
// 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。
func synthesizedVoice(result any) *OutboundVoice {
m, ok := result.(map[string]any)
if !ok {
return nil
}
v := &OutboundVoice{Requested: true}
v.Name, _ = m["name"].(string)
v.Mime, _ = m["mime"].(string)
v.Size, _ = m["size"].(int64)
v.DurationMs, _ = m["durationMs"].(int64)
v.CostMs, _ = m["costMs"].(int)
v.BudgetMs, _ = m["budgetMs"].(int)
return v
}
// degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码
// 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化
// 失败一律归 tts_failed,绝不把空原因码交给客户端。
func degradedVoice(err error) *OutboundVoice {
v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
var se *speakError
if !errors.As(err, &se) {
return v
}
if se.Code != "" {
v.FallbackReason = se.Code
}
v.BudgetMs = se.BudgetMs
v.CostMs = se.CostMs
return v
}
+296
View File
@@ -0,0 +1,296 @@
package loop
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件锁住「speak 调用 → Result.Voice」这条出站链(M57):
// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的
// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。
// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与
// 预算/耗时、没调(或只调了别的工具)返回 nil。
func TestCaptureVoice_States(t *testing.T) {
t.Parallel()
l := New(Options{})
if v := l.captureVoice(
[]agent.ToolCall{{Name: "terminal"}},
[]ToolOutcome{{Result: "ok"}},
); v != nil {
t.Fatalf("non-speak batch voice = %+v, want nil", v)
}
ok := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Result: map[string]any{
"name": "abc.wav", "mime": "audio/wav", "size": int64(9),
"durationMs": int64(0), "budgetMs": 3025, "costMs": 120,
}}},
)
if ok == nil || !ok.Requested {
t.Fatalf("success voice = %+v, want Requested", ok)
}
if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 {
t.Fatalf("success voice = %+v, want the stored ref", ok)
}
if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 {
t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok)
}
if ok.FallbackReason != "" {
t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason)
}
bad := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}},
)
if bad == nil || !bad.Requested {
t.Fatalf("failure voice = %+v, want Requested", bad)
}
if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 {
t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad)
}
if bad.Name != "" {
t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name)
}
// 非结构化失败也要有原因码,绝不把空串交给客户端。
raw := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: errors.New("boom")}},
)
if raw == nil || raw.FallbackReason != speakReasonFailed {
t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed)
}
}
// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先
// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。
func TestCaptureVoice_LastSuccessWins(t *testing.T) {
t.Parallel()
l := New(Options{})
calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}}
// 先成功、后失败:交付已落盘的那份。
got := l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}},
{Err: &speakError{Code: speakReasonFailed}},
})
if got == nil || got.Name != "first.wav" {
t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got)
}
// 两次都成功:取后者。
got = l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav"}},
{Result: map[string]any{"name": "second.wav"}},
})
if got == nil || got.Name != "second.wav" {
t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got)
}
// 被跳过的调用不算数。
if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil {
t.Fatalf("skipped voice = %+v, want nil", v)
}
}
// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用
// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。
func TestRun_VoiceResult(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_1",
Name: speakToolName,
Args: map[string]any{"text": "晚安", "emotion": "温柔"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})
synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"}
store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}}
l := New(Options{
Agent: newTestAgent(t, ad, td),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: synth,
VoiceStore: store,
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
// ① 模型面确实被广告了 speak(否则模型不可能调它)。
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] {
t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
// ② 调用真的走了注入的合成与落盘。
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
// ③ 结论落在 Result.Voice 上。
if res.Voice == nil {
t.Fatal("Result.Voice = nil, want the synthesized audio")
}
if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 {
t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice)
}
if res.Voice.FallbackReason != "" {
t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason)
}
}
// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束
// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。
func TestRun_VoiceDegraded(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_2",
Name: speakToolName,
Args: map[string]any{"text": "晚安"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t)),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: &stubSynth{err: errors.New("upstream down")},
VoiceStore: &stubStore{},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "晚安" {
t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text)
}
if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed {
t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed)
}
if res.Voice.Requested != true {
t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested)
}
}
// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时
// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。
func TestRun_NoSpeakNoVoice(t *testing.T) {
t.Parallel()
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Voice != nil {
t.Fatalf("Result.Voice = %+v, want nil", res.Voice)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] {
t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
}
// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher:
// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。
func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
t.Parallel()
wired := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
})
out := wired.invokeOne(context.Background(), nil, agent.ToolCall{
Name: speakToolName, Args: map[string]any{"text": "晚安"},
})
if out.Err != nil {
t.Fatalf("speak without dispatcher err = %v, want nil", out.Err)
}
plain := New(Options{})
out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"})
if out.Err == nil || out.ErrKind != tool.KindExecution {
t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out)
}
}
// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间
// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成
// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。
// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。
func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) {
t.Parallel()
delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3}
degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
res := &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{voice: degraded})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice)
}
// 先降级后成功:后到的成功就是这条消息最终的语音。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: degraded})
applyBatchVoice(res, &toolRunResult{voice: delivered})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice)
}
// 后一批没有 speak:前面那批的结论照旧算数。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{})
if res.Voice == nil || res.Voice.Name != "abc.wav" {
t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice)
}
}
// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且
// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上,
// 而不让循环反向 import documents)。
func TestVoiceStoreFunc(t *testing.T) {
t.Parallel()
var gotName string
var gotData []byte
var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) {
gotName, gotData = origName, data
return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil
})
ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8})
if err != nil {
t.Fatalf("SaveAudio: %v", err)
}
if gotName != "speak.wav" || len(gotData) != 2 {
t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData))
}
if ref.Name != "saved.wav" || ref.Size != 2 {
t.Fatalf("ref = %+v, want the adapter's own result", ref)
}
}
@@ -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("被审条目没有向量却把自己报成未降级:那会把「算不出来」说成「没有重复」")
}
}
@@ -103,6 +103,8 @@ const (
//
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
// 🔴 时间性元数据位(temporal,迁移 0027)**不是**它的入参:r1 复审裁决 D 之后
// 这一位只观测、不参与判定(见 retention.go 的 resolveRetention 头注与 temporal.go)。
// 🔴 全仓**唯一**调用点是 `resolveRetention`(retention.go):任何地方再跑一遍
// 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因,
// retention_test.go 有一条源码级断言钉住这个唯一性。
@@ -259,6 +261,9 @@ func judgeRetentionOneOff(text string) retentionRule {
// ~0:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安…
// 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点
// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。
// 🆕 **M45 曾把这一条接到时间性元数据位上,r1 复审裁决 D 又撤掉了**(原因见
// temporal.go 头注:位置放宽能翻的行逐条都是长期约定)。因此位置判据对**每一行**
// 无条件生效,与 0027 之前逐字相同。
//
// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致**
+75 -3
View File
@@ -29,6 +29,28 @@ import (
//
// 「允许返回 `[]`」是 Mem0 官方口径(design/23 §2:官方原话「Include examples
// that should produce []」)—— 不写这一句,模型会为了「有产出」而硬凑。
//
// 🆕 2026-10-27(切片 M45):prompt 里追加**时间性位** `temporal`(四个枚举值)。
// 它**不是**「又一段靠自然语言描述判据」的提示词加固:模型只负责**标注**一个值为
// 四个枚举之一,落库是 `temporal` 列(迁移 0027)。
//
// 🔴 **这一位目前不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,判定的
// 结论集与没有它逐字相同 —— 保留它只为积累「模型到底怎么标」的证据(temporal.go
// 头注记着两次被生产重放否掉的接线方案与接线前必须看的那份分布)。因此这段 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 = `你是记忆提取器。从对话中提取**值得长期记住**的记忆项。
只保留这些(正向清单):
@@ -49,21 +71,62 @@ const extractSystemPrompt = `你是记忆提取器。从对话中提取**值得
**允许返回空数组 []**:一轮对话里没有值得长期记住的内容时,正确做法就是回
[],不要为了凑数而提取。
每条还要标一个**敏感位** "sensitive"(布尔值 true / false)。判据只有一句话:
**这句话能不能说给主人的朋友听**。
- true —— 主人只愿在私下被提起的事:住址与所在城市、真实姓名与私人称呼(主人
希望被怎么叫、专属昵称)、家庭成员的姓名与情况、健康与身体约束、作息与出行
行程、收入与工作单位,以及任何「主人告诉了助手、但不该转述给第三方」的内容。
- false —— 对谁都能说的稳定信息:口味与饮食偏好、兴趣爱好、喜欢的作品与游戏、
不涉及上面那些私事的相处方式、项目约定与编号规则。
正反例(照这个尺度标,不要自己另立一套):
- 「用户喜欢冰美式」→ sensitive=false(口味习惯,谁都能知道)。
- 「主人住在花溪」→ sensitive=true(住址)。
- 「主人希望被称作叶酱」→ sensitive=true(私人称呼)。
- 「主人常熬夜,助手会提醒早睡」→ sensitive=true(作息)。
- 「用户喜欢《塞尔达传说》」→ sensitive=false(兴趣爱好)。
- 「项目编号规则是 CY-日期-序号」→ sensitive=false(项目约定)。
拿不准就标 true —— 标成 true 的代价只是群聊里少提一句,标成 false 的代价是主人
的私事被说给外人。这个字段必填,不要省掉、不要填字符串。
每条还要标一个**时间性位** "temporal",只能取下面四个英文值之一:
- "one_off":这条说的是**某一次**发生的事(今天 / 昨天 / 这次 / 某个具体日子),
即使你已经把它写成「用户……」这样的陈述句;
- "recurring":说的是**反复发生**的规律(每天 / 每周 / 每次都…);
- "durable":说的是**长期成立**的事实 / 偏好 / 约定 / 称呼 / 身份;
- "unknown":拿不准,或者上面三个都不贴切。
temporal 只许填这四个英文值之一(不要换成中文、不要自造新值);拿不准就填
"unknown"。这个标记会被记录下来供以后核对用途,如实标注即可,不要为了省字省掉它。
只输出一个 JSON 数组,不要任何其他文字。每个元素形如:
{"content":"记忆内容","scope":"user|persona|project","kind":"relationship|world_event","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。`
// llmCandidate 是提取器要求 LLM 输出的线上形状:每条候选
// 记忆一个 JSON 对象。模型未给出合法值时,Scope/Kind 取提取器配置的
// 默认值。不含 source_conversation:
// 默认值;Temporal 则**一律归 unknown**(temporal.go 的 normalizeTemporal,
// 绝不猜:猜成 one_off 的方向是把该留的记忆删掉)。不含 source_conversation:
// 模型即使回填该键也会被忽略(结构化溯源只来自持久化层)。
type llmCandidate struct {
Content string `json:"content"`
Scope string `json:"scope"`
Kind string `json:"kind"`
Temporal string `json:"temporal"`
Tags []string `json:"tags"`
Confidence float64 `json:"confidence"`
ProjectID string `json:"project_id"`
// Sensitive 是模型给出的**敏感位**(迁移 0028,切片 M63):true = 这条是主人的
// 私事(住址 / 私人称呼 / 家人 / 健康 / 作息/行程 / 收入 …),受限车道不许召回。
//
// 🔴 它**不是**权限判定(AGENTS §9 只约束身份判定):它只是一个数据层标记,主人
// 车道照常读到。模型漏标或干脆没给这个键 → 缺席即 false,再落到 resolveSensitive
// 的第二道判据(已实证短语白名单)上;键的类型填错(例如给字符串 "true")会让
// **整批** JSON 解码失败 —— 与其它字段同一口径,由调用方决定重试还是回退,
// 这里不为了容错去猜模型的意图(猜错的方向是把私事当成可外说)。
Sensitive bool `json:"sensitive"`
}
// LLMExtractor 让 model.Adapter 从对话中推导候选记忆条目
@@ -240,6 +303,15 @@ func (e *LLMExtractor) dedupeAndBuild(cands []llmCandidate) []MemoryItem {
Content: content,
Tags: c.Tags,
Confidence: c.Confidence,
// 时间性位(迁移 0027)在**产出侧**归一:模型只能选四个值之一,
// 其余(空 / 非法 / 自造)一律归 unknown —— 解析失败绝不报错,
// 也不拖累同批的其他候选(见 temporal.go 的 normalizeTemporal)。
Temporal: normalizeTemporal(c.Temporal),
// 敏感位(迁移 0028)在**产出侧**合成:模型的类目判断与已实证短语白名单
// 取或(resolveSensitive)。两次判据都放在这里而不是只等 Store.Add 兜底,
// 是为了让 **dry-run**(只跑 Extract、不落库)看到的位就是将来落库的位 ——
// 否则「提取器判据」这条验收只能靠真落库才看得见。
Sensitive: resolveSensitive(c.Sensitive, content),
}
if !it.Scope.valid() || !it.Kind.valid() {
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 —— 产出侧不许撤标")
}
}
+8 -1
View File
@@ -97,7 +97,14 @@ const (
// 规则。**规则内容与阈值未改**(同一份 N1~N7),但**判据的作用范围变了**:
// 档位与 `expires_at` 现在对**每一行**在同一笔 INSERT 里落下,且不再依赖同步闸
// 是否开启 —— 同一对条目在 d3.3 与 d3.4 下的落库形态因此可能不同,按规矩升版。
const IngestRuleVersion = "d3.4"
// - `d3.5`(切片 M45)**记录 `temporal` 信号,但不参与判定**:提取器在产出侧多
// 标了一维时间性位(`one_off` / `recurring` / `durable` / `unknown`,迁移 0027),
// 随同一笔 INSERT 落库。**判定的结论集与 d3.4 逐字相同**(`resolveRetention`
// 不读这一位,见 retention.go 头注:复审裁决 D —— 两次候选接线都被生产重放否掉)。
// 那为什么仍升版?因为**留痕面变了**:同一对条目在 d3.4 与 d3.5 下虽然结论相同,
// 但 d3.5 的库里多了「模型当时标了什么」这一维可供事后归因;不升版,新旧留痕会
// 混在同一版本号下,将来接线时无法区分「接线前的结论」与「接线后的结论」。
const IngestRuleVersion = "d3.5"
// 调优旋钮(ReviewConfig)与它们的默认值常量在 preinsert_config.go。
@@ -60,6 +60,13 @@ const (
//
// 🔴 与设计稿的对齐(如实登记):design/23 §15 那一行原写 `Timeout=20s`(本次改动
// **之前**的口径),已按上面实测同步为 `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
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。
@@ -19,7 +19,8 @@ import (
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
// 4. `IngestRuleVersion` 升到 `d3.4`(改判定规则必升版,否则新旧留痕无法区分);
// 4. `IngestRuleVersion` 随判定变更升版(改判定规则必升版,否则新旧留痕无法区分;
// 当前是 `d3.5`,M37 合入时是 `d3.4`);
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
@@ -107,8 +108,8 @@ func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
}
if rev.RuleVer != "d3.4" {
t.Fatalf("闸留痕规则版本 = %q, want d3.4", rev.RuleVer)
if rev.RuleVer != IngestRuleVersion {
t.Fatalf("闸留痕规则版本 = %q, want %q(留痕版本必须与常量同源,不写死)", rev.RuleVer, IngestRuleVersion)
}
if s := r.Stats(); s.GateDeferred != 1 {
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
@@ -268,16 +269,22 @@ func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
}
}
// TestIngestRuleVersionIsD34 钉住任务书验收点④:改判定规则必升版
// TestIngestRuleVersionIsD35 钉住任务书验收点④:改判定规则必升版
// (design/23 §18.4),否则新旧留痕无法区分。
//
// d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定,
// 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期,
// 且不再依赖同步闸是否开启),因此按规矩升版。
func TestIngestRuleVersionIsD34(t *testing.T) {
//
// d3.5(切片 M45)**记录 `temporal` 信号,但不参与判定**:提取器产出侧多标了一维
// 时间性位(迁移 0027),随同一笔 INSERT 落库;`resolveRetention` 不读它,因此
// 判定的结论集与 d3.4 逐字相同。仍升版是因为**留痕面变了**(库里多了「模型当时标了
// 什么」这一维),将来接线时要能区分「接线前/后」的结论 —— 理由见 preinsert.go
// 的版本史。
func TestIngestRuleVersionIsD35(t *testing.T) {
t.Parallel()
if IngestRuleVersion != "d3.4" {
t.Fatalf("IngestRuleVersion = %q, want d3.4(档位与 defer 合并为同一判定)", IngestRuleVersion)
if IngestRuleVersion != "d3.5" {
t.Fatalf("IngestRuleVersion = %q, want d3.5(记录 temporal 信号,但不参与判定)", IngestRuleVersion)
}
}
@@ -52,6 +52,41 @@ func migrateUp(t *testing.T, d *sql.DB) {
}
}
// migrateDownToBefore 把库回滚到 id **之前**(即 id 与它之后已应用的每条都撤掉)。
//
// 步数从 `schema_migrations` 账本现算,而不是写死一个常数:写死的口径每加一条
// 迁移就要改一次(0026 加进来时本文件的 0025 用例的 `MigrateDown(2)`、0027 加进来时
// 0026 用例的 `MigrateDown(1)` 都是这么被打红的)—— 而「回滚到某条之前」这个
// 语义本身与后面还压着几条无关。
func migrateDownToBefore(t *testing.T, d *sql.DB, id string) {
t.Helper()
ctx := context.Background()
rows, err := d.QueryContext(ctx, `SELECT id FROM schema_migrations ORDER BY id`)
if err != nil {
t.Fatalf("list applied migrations: %v", err)
}
defer func() { _ = rows.Close() }()
steps := 0
for rows.Next() {
var got string
if err := rows.Scan(&got); err != nil {
t.Fatalf("scan applied migration: %v", err)
}
if got >= id {
steps++
}
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate applied migrations: %v", err)
}
if steps == 0 {
t.Fatalf("迁移 %s 不在账本里,无法回滚到它之前", id)
}
if err := db.MigrateDown(ctx, d, steps, testLogger()); err != nil {
t.Fatalf("MigrateDown(%d) 回到 %s 之前: %v", steps, id, err)
}
}
// tableExists 报告表是否存在。
func tableExists(t *testing.T, d *sql.DB, name string) bool {
t.Helper()
@@ -141,7 +176,6 @@ func countRawReviewRows(t *testing.T, d *sql.DB) int {
// 索引、down 删表、再 up 能重建(回滚不留半状态)。
func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
@@ -162,11 +196,10 @@ func TestMigration0025CreatesAndRollsBackIngestReviewTable(t *testing.T) {
}
}
// 回滚:本表之上还压着一条 0026(`memory_retention_tier`,切片 M37),因此这里
// 回滚**两条**才轮到 0025 —— 迁移账本按 id 升序、down 按降序撤,这是既有语义。
if err := db.MigrateDown(ctx, d, 2, testLogger()); err != nil {
t.Fatalf("MigrateDown(2): %v", err)
}
// 回滚:0025 之上还压着 0026(`memory_retention_tier`,切片 M37)与 0027
// (`temporal`,切片 M45),因此要回到 0025 **之前**才轮得到它 —— 步数由
// migrateDownToBefore 从账本现算,不写死常数(见该 helper 的注释)。
migrateDownToBefore(t, d, ingestReviewMigrationID)
if tableExists(t, d, ingestReviewTable) {
t.Fatalf("回滚后 %s 应已被删除", ingestReviewTable)
}
+29 -27
View File
@@ -2,10 +2,16 @@ package memory
import (
"context"
"errors"
"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 路径,
// 不许另造一套相似度实现)。
//
@@ -44,7 +50,7 @@ func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryIte
// 不是降级(这是配置形态,不是故障)。
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 {
// 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记
// degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则,
@@ -79,37 +85,33 @@ func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float
return out
}
// embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch:
// 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败,
// 由调用方决定降级还是上报,绝不半途改用混合表示。
// embedReviewTexts 给「新条 1 + 既有候选 K」取向量:**索引里已有的直接复用,
// 只嵌缺的**(判据与回填口径见 embed_incremental.go)。
//
// 🔴 链长就是**调用方预算的输入**(2026-10-13 实测,见 embedding/budget.go 文件头):
// 条数是「新条 1 + cfg.ScanLimit(默认 200)」= 201,因此一次召回发出的 HTTP 请求数
// = ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
// 曾是 ⌈201 / tidyEmbedBatch⌉(tidyEmbedBatch = embedding.DefaultBatchLimit):
//
// 批次 4(改前)= 51 个请求 ⇒ 整条链 23.070 / 21.860 s
// 批次 8(改后)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
// 批次 8(M15)= 26 个请求 ⇒ 整条链 21.435 / 21.597 s
// 增量(本切片)= **1 个请求**(只有刚落库的新条没向量;既有候选从索引取),
// 同一分区第二次审查起 = **0 个请求**(新条落库时写入侧已建好向量)
//
// 两点必须一起读:① 请求数减半是本改动**唯一**买到的收益 —— 链路墙钟由
// 「条数 × 每条成本」主导,两个批次形状都是 ~21s;② 所以单条审查的预算
// (memory.defaultReviewTimeout)必须按**整条链**算,20s 的旧值比链长还短,
// 会让每一条候选满额的审查都超时(这正是「92% 的审查 recall_mode 为空」的根因)。
func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(items))
for start := 0; start < len(items); start += tidyEmbedBatch {
end := min(start+tidyEmbedBatch, len(items))
texts := make([]string, 0, end-start)
for _, it := range items[start:end] {
texts = append(texts, it.Content)
}
vecs, err := emb.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, ErrTidyEmbedCount
}
out = append(out, vecs...)
// ⇒ 单条审查的预算(memory.defaultReviewTimeout = 90s)因此从「按链长算、勉强够」
// 变成「按链长算、余量极大」。预算本身**不下调**:慢分片与并发仍会拉长单请求,
// 余量是韧性而不是浪费(见 preinsert_config.go 的推导)。
//
// 返回切片恒与 items 等长(缺口为 nil)。被审的那一条(items[0])拿不到向量时
// 返回错误:语义召回对它无从谈起(相似度恒 0),交给调用方走既有的词项余弦降级,
// 而不是交出一片空候选。
func (r *PreInsertReviewer) embedReviewTexts(ctx context.Context, items []MemoryItem) ([]embedding.Vector, error) {
vecs, stats, err := r.deps.Store.embedIncremental(ctx, r.deps.Embedder, items, tidyEmbedBatch)
if err != nil {
return nil, err
}
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。
const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
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 <> ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
+8
View File
@@ -98,6 +98,14 @@ type retentionVerdict struct {
// 4. **留存规则命中**(design/03 §10.5 的 N1~N7)⇒ temp,并带回规则码。
// 5. 其余 ⇒ long(默认落点,fail-safe)。
//
// 🔴 **`temporal`(迁移 0027 的时间性元数据位)不在这个列表里**(r1 复审裁决 D,
// 2026-10-27):这一位目前**只落库、只观测,不参与任何判定**。它被接进判定的两个
// 候选设计都已被生产重放否掉 —— ①无条件 `one_off ⇒ temp` 会当天删掉 21 条长期约定;
// ②只放开 N6 位置判据,最近 500 条里能翻的 13 条**逐条都是约定**、而复审点名的
// 三条目标行 0/3 救得了(详见 temporal.go 头注)。因此判定的结论集与「没有这一位」
// 逐字相同;temporal_test.go 有一条正断言钉住「喂 one_off 不影响任何结论」,
// 另有一条变异靶子(把这一位重新接进本函数 ⇒ 那条正断言必红)。
//
// 🔴 「判第五态 `defer`」与「判 `retention_tier=temp`」**就是这一步**:闸与异步审查
// 都只读 `item.RetentionTier`(preinsert_judge.go),不再各自跑一遍规则。M27/M37 的
// 实测缺口正是「两处各判一遍、上游几乎不触发」,合并之后只有一个判据。
@@ -7,8 +7,6 @@ import (
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// 本文件钉住**迁移 0026(`memory_retention_tier`)与 Store 自举 DDL 的同形与可回滚**,
@@ -69,10 +67,9 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
d := openRawMemoryDB(t)
migrateUp(t, d)
// 造出「既有部署」:把 0026 撤掉(列与索引消失),再按**旧形态**写入两行。
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
t.Fatalf("MigrateDown(1): %v", err)
}
// 造出「既有部署」:回到 0026 **之前**(列与索引消失),再按**旧形态**写入两行。
// 步数由账本现算:0027(`temporal`,切片 M45)压在上面,写死 1 会撤错那条。
migrateDownToBefore(t, d, retentionMigrationID)
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
t.Fatalf("0026 回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
}
@@ -111,9 +108,7 @@ func TestMigration0026AddsColumnOverExistingRows(t *testing.T) {
}
// 回滚:只撤结构,不改写任何一行的其它数据。
if err := db.MigrateDown(ctx, d, 1, testLogger()); err != nil {
t.Fatalf("MigrateDown(1, 二次): %v", err)
}
migrateDownToBefore(t, d, retentionMigrationID)
if hasMemoryColumn(t, d, retentionTierColumn) || hasMemoryIndex(t, d, retentionTierIndex) {
t.Fatalf("二次回滚后 %s / %s 应已消失", retentionTierColumn, retentionTierIndex)
}
@@ -181,11 +176,20 @@ func TestMigration0026ShapeMatchesStoreBootstrap(t *testing.T) {
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, retentionTierColumn, cols)
}
}
// 自举 DDL 里新列写在最后一列(新建库的期望形状)——迁移路径上它后面可能还有
// 自举追加的 project_id,因此只对自举侧断言顺序。
// 自举 DDL 的尾序(新建库的期望形状):`… retention_tier, temporal, sensitive`。
// 迁移路径上这几列之后可能还有自举追加的 project_id,因此只对自举侧断言顺序。
// (0027 之后 retention_tier **不再是**最后一列 —— temporal 追加在它之后;
// 0028 又把 sensitive 追加在 temporal 之后。)
bootCols := tableColumns(t, bootstrapped, "memory_items")
if last := bootCols[len(bootCols)-1]; !strings.Contains(last, "|"+retentionTierColumn+"|") {
t.Fatalf("自举建的 memory_items 最后一列 = %q, want %s", last, retentionTierColumn)
if len(bootCols) < 3 {
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols))
}
tail := bootCols[len(bootCols)-3:]
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
!strings.Contains(tail[1], "|"+temporalColumn+"|") ||
!strings.Contains(tail[2], "|"+sensitiveColumn+"|") {
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 }
// 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) 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()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
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)}
query, args, err := retentionTierList(query, args, tiers)
if err != nil {
@@ -144,7 +144,7 @@ func (s *Store) listProject(ctx context.Context, projectID string, tiers []Reten
now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
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)}
query, args, err := retentionTierList(query, args, tiers)
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
}
+61 -45
View File
@@ -29,9 +29,7 @@ package memory
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -124,7 +122,7 @@ type MemoryItem struct {
// - 但 `Get` / `List*` 一律不 SELECT 该列(读侧闸是纯 SQL,见 timegate.go),
// 故**从库里读回来的**条目 ExpiresAt 恒为零值。
//
// 要读回时效需同步 14 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回
// 要读回时效需同步 15 列 SELECT 与 scanItem,并连带 tidy/preinsert 的内部召回
// SQL —— 那是改扫描口径,不在本切片授权范围。
ExpiresAt time.Time
@@ -135,6 +133,31 @@ type MemoryItem struct {
// 非空 = 调用方的显式断言,判据不许推翻它。读回来时:
// **空串 = 尚未分型**(0026 之前的历史行 / 受限来源新行),绝不是某一档。
RetentionTier RetentionTier
// Temporal 是**提取器给出的时间性元数据位**(迁移 0027、temporal.go):
// one_off / recurring / durable / unknown,空串 = 未分型(历史行与没做时间性
// 判断的写入路径,口径见 temporal.go 头注)。
//
// 🔴 **它目前只观测、不参与判定**(r1 复审裁决 D):`resolveRetention` 不读它,
// 判定的结论集与「没有这一位」逐字相同。保留它是为了积累「模型到底怎么标」的
// 证据 —— 接线前必须先看那份分布(temporal.go 头注里给了查询)。
//
// ⚠️ **与 ExpiresAt 同样是「只写不读」的列**:`Add` 把它写进 `temporal` 列,
// 但四条列表读路径的 SELECT 与 scanItem 都不读它 —— 改扫描口径要动全部读点
// (rules.go / store_list.go / tidy_store.go / preinsert_store.go,现为 15 列,
// 加它就是 16 列),那是另一件事,不在本片授权范围。要按时间性位巡检/回填时
// 直读该列即可;写入侧的校验/编码见 store_temporal.go。
Temporal TemporalKind
// Sensitive 是**敏感标记**(迁移 0028、sensitive.go):true = 这条记忆是主人的
// 私事(住址 / 私称 / 作息 ……),因此**受限车道**(群聊 / 陌生人)的召回过滤器
// 会把整条排除在候选池外。
//
// 🔴 它是**数据层标记,不是权限系统**:主人车道(非受限环境)照常读到全部记忆
// (含 sensitive=1),没有任何读路径因它拒绝主人;它只影响
// `MemoryItem.SharedVisible` 这一条判据。谁是主人仍只由 actor 元数据 + 白名单
// + 策略引擎决定(AGENTS §9)。判据与理由见 sensitive.go 头注。
Sensitive bool
}
// 区分 memory 包各类失败的哨兵错误。每个都在
@@ -246,6 +269,15 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if err := ensureRetentionSchema(ctx, db); err != nil {
return nil, err
}
// 时间性元数据位(迁移 0027):只补列、不建索引、不回填(见 temporal_schema.go)。
if err := ensureTemporalSchema(ctx, db); err != nil {
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 {
return nil, err
}
@@ -284,6 +316,17 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
// 「档位 → expires_at」换算。两列因此**同一个判定、同一笔 INSERT**落下,
// 不存在「判了 temp 却没写有效期」的中间态 —— M2 时代登记过的「INSERT 与补写
// expires_at 之间的短窗口」在本片被结构性关闭(不再有第二笔 UPDATE)。
//
// 🆕 切片 M45:`item.Temporal`(时间性元数据位,迁移 0027)也随**这一笔** INSERT
// 落下(`temporal` 列)。**它只落库、不参与判定**(r1 复审裁决 D):`resolveRetention`
// 不读这一位,因此档位/有效期与「没有这一位」逐字相同;列值的作用是**观测**——
// 事后能按它分组统计、抽样看模型把哪些行标成了 `one_off`(这是将来决定要不要接线
// 的唯一证据来源,见 temporal.go 头注的查询)。
//
// 🆕 切片 M63:`item.Sensitive`(敏感标记,迁移 0028)也随**这一笔** INSERT 落下。
// 它**不参与任何判定**:档位 / 时效 / 读路径的可见性集合与「没有这一位」逐字相同
// —— 唯一的消费者是受限车道的过滤判据(sensitive.go 的 SharedVisible),而主人
// 车道根本不过那道判据。写入侧另有一条**白名单兜底**(见下面 Add 内的判定)。
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if err := item.normalize(); err != nil {
return MemoryItem{}, err
@@ -292,6 +335,16 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
if item, err = applyTimestamps(item); err != nil {
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 分支)。
verdict, err := resolveRetention(item)
@@ -312,17 +365,8 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
plan := s.planIngest(ctx, item)
item.ExpiresAt = plan.ExpiresAt
_, err = s.db.ExecContext(ctx, `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)
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())
if err != nil {
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
if err := s.insertItem(ctx, item, tagsJSON, plan); err != nil {
return MemoryItem{}, err
}
s.finishIngest(ctx, item, plan)
if plan.Action != ingestPlanAbsorbNew {
@@ -428,40 +472,12 @@ func (it *MemoryItem) validate() error {
if !it.RetentionTier.valid() {
return fmt.Errorf("%w: %q", ErrInvalidRetentionTier, it.RetentionTier)
}
if err := it.validateTemporal(); err != nil {
return 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
}
// discardLogger 在 log 非 nil 时返回 log,否则返回一个不写任何地方的 logger。
func discardLogger(log *slog.Logger) *slog.Logger {
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,
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)
if errors.Is(err, sql.ErrNoRows) {
return MemoryItem{}, ErrNotFound
@@ -84,7 +84,7 @@ func (s *Store) listByPersona(ctx context.Context, personaID string, tiers []Ret
now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
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)}
query, args, err := retentionTierList(query, args, tiers)
if err != nil {
@@ -129,7 +129,7 @@ func (s *Store) listAll(ctx context.Context, tiers []RetentionTier) ([]MemoryIte
now := timeGateNow()
query := `SELECT id, persona_id, role_id, project_id, scope, kind,
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)}
query, args, err := retentionTierList(query, args, tiers)
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` 是两根独立的轴,
// 因此这里只做「读出来」,不参与任何推导。空串原样成为
// RetentionTierUnset(= 尚未分型),绝不被折成某一档。
//
// sensitive 是**第 15 列**(迁移 0028):SQLite 的 INTEGER 0/1 在这里折回 bool。
// 它被读出来的**唯一**理由是要给受限车道的可见性判据用(SharedVisible)——
// 条目不带这一位,过滤就无从过滤;它不参与任何排名/判定。
func scanItem(sc rowScanner) (MemoryItem, error) {
var (
it MemoryItem
scope, kind, tier string
tagsJSON string
sensitive int
createdAt, updatedAt string
)
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
&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
}
it.Scope = Scope(scope)
it.Kind = Kind(kind)
it.RetentionTier = RetentionTier(tier)
it.Sensitive = sensitive != 0
if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil {
return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err)
}
+9 -1
View File
@@ -27,7 +27,9 @@ CREATE TABLE IF NOT EXISTS memory_items (
occurred_at TEXT NOT NULL DEFAULT '',
valid_to 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 '',
sensitive INTEGER NOT NULL DEFAULT 0
)`
const createMemoryItemsPersonaIndex = `
@@ -43,6 +45,12 @@ CREATE INDEX IF NOT EXISTS idx_memory_items_persona_id_created_at
// (回填分型的入口)都吃它。列**必须**排在 CREATE TABLE 的最后:迁移的
// `ALTER TABLE ADD COLUMN` 也把新列追加在末尾,两份 DDL 才逐列同形
// (retention_migration_test.go 钉住这条同形,少一列就是「新库有、老库无」的漂移)。
//
// ⚠️ 0027 之后 `retention_tier` **不再是最后一列**:`temporal`(迁移 0027)追加在
// 它之后,0028 又把 `sensitive` 追加在 `temporal` 之后。两条路径的尾序都是
// `… expires_at, retention_tier, temporal, sensitive`。新列一律追加在末尾这条口径
// 不变,只是「末尾」会随迁移推进(那条同形断言比对**列集合**,另外对尾序做一条
// 显式断言)。
const createMemoryItemsRetentionTierIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_items_retention_tier
ON memory_items (retention_tier)`
+46
View File
@@ -0,0 +1,46 @@
package memory
import (
"errors"
"fmt"
)
// 本文件是 `memory_items.temporal`(迁移 0027)在**写入路径**上的那一半:哨兵错误、
// 字段校验、以及落库时的列值编码。
//
// 🔴 其余三处的归属(别在这条链上另开一份实现):
// - **字段与语义**在 store.go 的 MemoryItem 上(结构体的字段不能跨文件声明),
// 闭集与归一化在 temporal.go;
// - **判定**仍是 `resolveRetention`(retention.go)里的 N1~N7 —— `temporal`
// **只被记录,不参与判定**(r1 复审裁决 D),因此 `resolveRetention` 里
// **没有、也不许有**「第 4 步看 temporal」这种东西;
// - **DDL** 在 migrations/0027 + store_schema.go + temporal_schema.go。
//
// 要把 `temporal` 接进判定,前置条件写在 temporal.go 头注:先拿到模型真实标签分布
// (那里给了两条只读 SQL),再改 `temporal_test.go` 的 temporalFieldSites 白名单 ——
// 那张表是**有意的接线决定**的唯一入口,不许绕过它。
//
// 拆出本文件只为守 AGENTS §10 的文件行数上限(store.go 已到 500 行)与「模块按职责
// 拆源文件」那条规范 —— 口径逐字未变。
// ErrInvalidTemporal 在条目的 Temporal(时间性元数据位,迁移 0027)不是闭集内的
// 值时返回。空串**不在**此列:它是「尚未分型」这一合法状态。
var ErrInvalidTemporal = errors.New("memory: invalid temporal kind")
// validateTemporal 只接受闭集内的值:时间性位是**枚举元数据**,一个自造的值
// ("sometimes")既判不出档位、又不该静默落库 —— 提取器已经把模型输出归一成
// unknown(temporal.go 的 normalizeTemporal),走到这里还是非法值只能是程序错误,
// 因此直接拒,而不是替它挑一个值。
func (it *MemoryItem) validateTemporal() error {
if !it.Temporal.valid() {
return fmt.Errorf("%w: %q", ErrInvalidTemporal, it.Temporal)
}
return nil
}
// temporalColumn 返回要写进 `temporal` 列的值(与 `ingestGatePlan.retention()` /
// `expiresAt()` 同族:把「条目 → 落库值」这一步收在类型自己身上,插入语句里不留
// 裸转换,也就不可能出现两处各转一次、口径分叉)。
func (it MemoryItem) temporalColumn() string {
return string(it.Temporal)
}
+115
View File
@@ -0,0 +1,115 @@
package memory
import "strings"
// 本文件是**提取器产出侧的时间性元数据位**(`temporal`,迁移 0027):它记录模型对
// 「这条记忆说的是**一次发生的事**,还是**反复 / 长期成立的事**」的判断。
//
// 当初为什么要这一位(M37 复审的结论,逐字):「真正卡住上游的是**提取器的改写**:
// 一次性事件被写成 30~80 字的第三人称长期陈述(同时带『近期/目前』和持久词),
// 规则闸看不到『这是今天的一次性事件』这一位。这不是阈值问题,是**判定链缺信息位**。」
//
// 🔴 **当前状态:只观测,不参与判定**(r1 复审裁决 D,2026-10-27)。`resolveRetention`
// **不读**这一位,判定的结论集与「没有这一位」逐字相同。保留它的唯一目的是**积累
// 证据**:等真实提取跑出一批标签之后,再回答「模型到底怎么标」以及「接进判定是赚
// 还是亏」。
//
// 为什么撤(两次候选接线都被生产重放否掉,别再凭直觉改回去):
//
// ① **无条件 `one_off ⇒ temp`**(任务书初版口径):最近 500 条 active 里人工按
// 提取器口径标出的 23 条 `one_off`,其中 21 条是「约定 + 某一次没走成」形态 ⇒
// 7 天后**把约定本身物理删掉**(不可撤回)。
// ② **只放开 N6 的位置判据**(r1 复审给的改法):能翻的行在窗口内 13 条、**逐条都是
// 约定**(`约定/约好 + 今天没走成`),而复审点名的三条目标行 **0/3** 救得了
// (`…睡得比平时早` 被习惯词、`…以后不用再惦记` 被永久化标记、`自行车链条` 那条
// 没有时点词)⇒ 收益为 0、风险集中在长期约定。
//
// 还有一层方法论上的理由:以上两轮数字**全部**建立在「对历史改写正文**人工**标注」
// 上 —— 生产库里 `temporal` 列当时还不存在,模型从未在这批正文上产出过标签。
// 用人工标签去决定一条「模型标签驱动」的接线,证据链是断的。
//
// 将来真要接线,前置条件是**这份数据**(不是又一次重放):
//
// SELECT temporal, COUNT(*) FROM memory_items GROUP BY temporal;
// SELECT created_at, retention_tier, content FROM memory_items
// WHERE temporal = 'one_off' ORDER BY created_at DESC LIMIT 10;
//
// 🔴 上面两条是**运维分析用的 SQL,不属于读侧代码路径**,别把它们抄进查询层:
// timegate.go(以及任何读侧 SQL)明令**不许**对 `created_at` / `expires_at` 用
// `datetime()` 之类的 SQLite 时间函数 —— 读侧闸是**定宽文本比较**,一旦有人按这两条
// 的写法去写查询,时间格式一变就会静默判错(timegate_test.go 钉着那条反证)。
// 这里出现 `datetime(created_at)` 只是因为「人工一次性分析、离线跑、不需要精确到
// 纳秒」——**恰好**是读侧不许用的那个形态。
//
// 看 `one_off` 那 10 条里有多少是长期约定 —— 那才是「这一位能不能用」的判据。
//
// 闭集(与迁移 0027 的列值口径逐字一致):
//
// one_off 一次性事件(今天 / 这次 / 某一次发生的事)
// recurring 反复发生的规律(每天 / 每周 / 每次都…)
// durable 长期成立的事实 / 偏好 / 约定 / 称呼 / 身份
// unknown 模型拿不准,或没给出合法值
// '' 尚未分型:0027 之前的历史行 + 没做时间性判断的写入路径
// (`ManualExtractor` 的「记住 X」、网关/管理面手写、整理侧回填 ——
// 它们本来就由调用方显式给档位)
//
// 🔴 `unknown` 与 `”` 是两件事:`unknown` 是「问过模型了,它拿不准」;`”` 是
// 「这条路径没问过」。把两者折成一个值会让「模型到底答了什么」这个观测面当场消失,
// 而那正是保留这一位的全部意义。
type TemporalKind string
const (
// TemporalUnset 是「尚未分型」:历史行与没做时间性判断的写入路径(见文件头注)。
// **它不是业务取值**,也**不是**「unknown」的同义词。
TemporalUnset TemporalKind = ""
// TemporalOneOff 是一次性事件。它目前**不改变任何判定**:写入时只落库,
// 供事后统计「模型把哪些行标成了某一次发生的事」。
TemporalOneOff TemporalKind = "one_off"
// TemporalRecurring 是反复发生的规律(「每天散步十分钟」「每周三开会」)。
TemporalRecurring TemporalKind = "recurring"
// TemporalDurable 是长期成立的事实 / 偏好 / 约定 / 称呼 / 身份。
TemporalDurable TemporalKind = "durable"
// TemporalUnknown 是「问过模型了,它拿不准」。
TemporalUnknown TemporalKind = "unknown"
)
// valid 报告 t 是否落在闭集内(**含**未分型的空串:它是合法的列值,不是错误)。
func (t TemporalKind) valid() bool {
switch t {
case TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown:
return true
default:
return false
}
}
// assigned 报告 t 是否**已由提取器分型**(即非空串)。它是「未分型」的唯一判据,
// 与 RetentionTier.assigned 同族,调用方不许用 `t == TemporalUnknown` 之类的
// 比较去替代它(那是「问过但拿不准」,不是「没问过」)。
func (t TemporalKind) assigned() bool { return t != TemporalUnset }
// normalizeTemporal 把模型输出的自由文本归一到上面四个值。
//
// 🔴 **只做大小写折叠与首尾空白裁剪,绝不做同义词猜测**(「一次性」「once」→ 都不认):
// 认不出来的答案就是解析失败,一律落 TemporalUnknown —— 这正是任务书
// 「模型只能选那四个值,解析失败一律归 unknown(绝不猜)」的字面实现。将来接线时
// 这条尤其重要:猜同义词的代价是单向的(猜成 one_off 就删记忆)。
func normalizeTemporal(s string) TemporalKind {
switch TemporalKind(strings.ToLower(strings.TrimSpace(s))) {
case TemporalOneOff:
return TemporalOneOff
case TemporalRecurring:
return TemporalRecurring
case TemporalDurable:
return TemporalDurable
default:
// 空值、非法值、拼写错误、模型自造的新值:全部归 unknown(**不报错**)。
// 一条候选记忆的价值不取决于它的时间性位是否写对,因此解析失败绝不
// 让整批提取失败(那会静默丢掉同批的其他候选)。
return TemporalUnknown
}
}
@@ -0,0 +1,199 @@
package memory
import (
"context"
"database/sql"
"slices"
"strings"
"testing"
"time"
)
// 本文件钉住**迁移 0027(`memory_temporal`)与 Store 自举 DDL 的同形与可回滚**,
// 手法照 retention_migration_test.go 对 0026 的那一套(同一批 helpers)。
//
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
// 真源:迁移(权威)与 NewStore 的幂等前滚(任意 *sql.DB 上的自举,temporal_schema.go)。
// 少一列就是「新库有、老库无」的漂移,因此两条判据都必须有:
//
// 1. TestMigration0027AddsColumnOverExistingRows —— 迁移落在**已有数据**上:行不丢、
// 列补上、默认值是空串(未分型);down 真的把列删掉且**不改写任何一行的其它
// 数据**(尤其是 `retention_tier` / `expires_at`,见 down 文件的口径);再 up 能重建。
// 2. TestMigration0027ShapeMatchesStoreBootstrap —— 新列在两份 DDL 里**逐字同形**
// (TEXT NOT NULL DEFAULT 空串),且自举侧尾序是 `… retention_tier, temporal`。
// temporalColumn 是被测列名(迁移与自举 DDL 里的字面量)。
const temporalColumn = "temporal"
// temporalMigrationID 是 0027 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
const temporalMigrationID = "0027_memory_temporal"
// TestMigration0027AddsColumnOverExistingRows 钉住迁移本体落在**既有数据**上的两条:
// 行不丢、默认值是空串(未分型);以及再 up 后历史行回到「未分型」。
func TestMigration0027AddsColumnOverExistingRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
if !hasMemoryColumn(t, d, temporalColumn) {
t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn)
}
// 造出「0027 之前的既有部署」:回到 0027 之前,按旧形态写入两行。
migrateDownToBefore(t, d, temporalMigrationID)
if hasMemoryColumn(t, d, temporalColumn) {
t.Fatalf("0027 回滚后 %s 应已消失", temporalColumn)
}
insertLegacyMemoryRow(t, d, "legacy-1", "一条 0027 之前就存在的记忆")
insertLegacyMemoryRow(t, d, "legacy-2", "另一条 0027 之前就存在的记忆")
// 升级:0027 落在**已有数据**上。
migrateUp(t, d)
if !hasMemoryColumn(t, d, temporalColumn) {
t.Fatalf("迁移后 memory_items 应带 %s 列", temporalColumn)
}
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 != 2 {
t.Fatalf("迁移后行数 = %d, want 2(既有行一行都不能丢)", rows)
}
for _, id := range []string{"legacy-1", "legacy-2"} {
var nat, content string
if err := d.QueryRowContext(ctx,
`SELECT temporal, content FROM memory_items WHERE id = ?`, id).
Scan(&nat, &content); err != nil {
t.Fatalf("read %s: %v", id, err)
}
if nat != "" {
t.Fatalf("既有行 %s 的时间性位 = %q, want 空串(历史行必须是「未分型」)", id, nat)
}
if content == "" {
t.Fatalf("既有行 %s 的正文丢了", id)
}
}
}
// TestMigration0027DownKeepsRowData 钉住 down 的**唯一**语义:只撤结构。判成 temp 的
// 行在回滚之后档位与时效必须**一个字节都没变**(回滚改数据 = 把已判定的临时记忆升回
// 永久,正是 down 文件里登记的那条反面)。
func TestMigration0027DownKeepsRowData(t *testing.T) {
t.Parallel()
ctx := context.Background()
d := openRawMemoryDB(t)
migrateUp(t, d)
if _, err := d.ExecContext(ctx, `INSERT INTO memory_items
(id, persona_id, scope, kind, content, created_at, updated_at,
retention_tier, expires_at, temporal)
VALUES ('temp-1', 'persona.a', 'persona', 'relationship', '一条临时记忆',
`+legacyTimeLiteral+`, `+legacyTimeLiteral+`, 'temp', ?, 'one_off')`,
formatTime(time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC))); err != nil {
t.Fatalf("seed temp row: %v", err)
}
migrateDownToBefore(t, d, temporalMigrationID)
if hasMemoryColumn(t, d, temporalColumn) {
t.Fatalf("回滚后 %s 应已消失", temporalColumn)
}
var tier, expires string
if err := d.QueryRowContext(ctx,
`SELECT retention_tier, expires_at FROM memory_items WHERE id = 'temp-1'`).
Scan(&tier, &expires); err != nil {
t.Fatalf("read retention columns after down: %v", err)
}
if tier != string(RetentionTemp) || expires == "" {
t.Fatalf("down 改写了档位/时效 = (%q, %q), want (%q, 非空) —— 回滚只撤结构",
tier, expires, RetentionTemp)
}
// 再 up:列以默认值重建,既有行回到「未分型」。
migrateUp(t, d)
if !hasMemoryColumn(t, d, temporalColumn) {
t.Fatalf("再次迁移后应重建 %s 列", temporalColumn)
}
var untyped int
if err := d.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE temporal = ''`).Scan(&untyped); err != nil {
t.Fatalf("count untyped: %v", err)
}
if untyped != 1 {
t.Fatalf("重建后未分型行 = %d, want 1", untyped)
}
}
// TestMigration0027ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移建出的
// memory_items(列集合)与自举 DDL 建出的必须一致,且新列在两份 DDL 里的形状
// 逐字相同(TEXT NOT NULL DEFAULT 空串)。
//
// 列集合的等价性由 0026 的 TestMigration0026ShapeMatchesStoreBootstrap 一并覆盖
// (它比对整表列集合);这里只钉本列自己的形状与自举侧的尾序。
func TestMigration0027ShapeMatchesStoreBootstrap(t *testing.T) {
t.Parallel()
ctx := context.Background()
migrated := openRawMemoryDB(t)
migrateUp(t, migrated)
bootstrapped := openRawMemoryDB(t)
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
t.Fatalf("NewStore: %v", err)
}
for name, d := range map[string]*sql.DB{"迁移": migrated, "自举": bootstrapped} {
cols := sortedColumns(tableColumns(t, d, "memory_items"))
if !slices.Contains(cols, temporalColumn+"|TEXT|1|''|0") {
t.Fatalf("%s 侧的 %s 列形状不是 TEXT NOT NULL DEFAULT 空串:%v", name, temporalColumn, cols)
}
}
bootCols := tableColumns(t, bootstrapped, "memory_items")
if len(bootCols) < 3 {
t.Fatalf("自举建的 memory_items 列数 = %d, want ≥ 3", len(bootCols))
}
// 0028(`sensitive`)之后尾序是 `… retention_tier, temporal, sensitive`:
// 新列一律追加在末尾,因此这条断言的「末尾」也随迁移推进(见 store_schema.go 头注)。
tail := bootCols[len(bootCols)-3:]
if !strings.Contains(tail[0], "|"+retentionTierColumn+"|") ||
!strings.Contains(tail[1], "|"+temporalColumn+"|") ||
!strings.Contains(tail[2], "|"+sensitiveColumn+"|") {
t.Fatalf("自举建的 memory_items 尾三列 = %v, want …, %s, %s, %s",
tail, retentionTierColumn, temporalColumn, sensitiveColumn)
}
}
// TestMigration0027ThenStoreBootstrapIsARegularPath 覆盖**真实启动顺序**
// (cmd/cyrene/wire.go:先 MigrateUp、后 NewStore):0027 先建好列,随后 Store 的
// 自举前滚走到 "duplicate column name" —— 那是**预期路径**,必须无害;重复建 Store
// 同样幂等,且写入路径可用(时间性位随同一笔 INSERT 落下)。
func TestMigration0027ThenStoreBootstrapIsARegularPath(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 !hasMemoryColumn(t, d, temporalColumn) {
t.Fatalf("自举之后 %s 列消失了", temporalColumn)
}
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)
item.Temporal = TemporalOneOff
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add: %v", err)
}
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
t.Fatalf("迁移建列后经 Store 落库的时间性位 = %q, want %q", nat, TemporalOneOff)
}
}
@@ -0,0 +1,45 @@
package memory
import (
"context"
"database/sql"
"fmt"
"strings"
)
// 本文件是「时间性元数据位」轴(迁移 0027)的 schema 前滚:给**既有**的
// memory_items 补 `temporal` 列。
//
// 与 retention_schema.go 同一手法、同一原因:`createMemoryItemsTable` 是
// `CREATE TABLE IF NOT EXISTS`,只对**新库**生效 —— 既有部署的表早就建好了,
// 新列不会自己长出来。记忆存储必须自包含、能在任意 *sql.DB 上使用,因此这条
// 前滚不能只靠 `internal/db/migrations`。
//
// 🔴 与迁移的分工(两份真源,必须同形):权威机制是
// `internal/db/migrations/0027_memory_temporal`;这里是「任意 *sql.DB」的前滚。
// 启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此生产库里
// 0027 先落地、这里走到 `duplicate column name` 是**预期路径**。
//
// 本列**不建索引**:目前没有任何按时间性位的读路径(它是写入侧的判定输入 +
// 事后巡检用的留痕列),提前建索引只是给每笔写入加一份没人用的维护成本。
// 将来整理侧真要按它筛(例如「把 one_off 的行捞出来复核」)再加索引不迟,
// 那时两条路径(迁移 + 本文件)一起加。
// ensureTemporalSchema 幂等地给 memory_items 补上时间性位列(列缺失时 ALTER,
// 已存在时忽略 SQLite 的 "duplicate column name" —— 那是预期路径,不是错误)。
//
// 默认值 = 空串 = 尚未分型:既有行因此**行为逐字不变**(resolveRetention 对
// 未分型的处理与 0027 之前完全一样,只跑 N1~N7)。本前滚刻意**不做**任何回填
// 判定:历史行没有对应的提取器输出,拿正文反推「它当时是一次性事件吗」正是
// 本片要消灭的那种猜测。
func ensureTemporalSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `ALTER TABLE memory_items
ADD COLUMN temporal TEXT NOT NULL DEFAULT ''`)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "duplicate column name") {
return nil
}
return fmt.Errorf("memory: roll forward temporal column: %w", err)
}
+375
View File
@@ -0,0 +1,375 @@
package memory
import (
"context"
"fmt"
"os"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。
//
// 🔴 当前契约(r1 复审裁决 D,2026-10-27):**这一位只观测,不参与判定。**
// `resolveRetention` 不读它,判定的结论集与「没有这一位」逐字相同。因此本文件最重要
// 的一条断言是**零影响**(TestTemporalBitDoesNotAffectRetention):同一段正文,
// 无论 `temporal` 是 five 个取值里的哪一个,档位与规则码都必须完全相同。
//
// 变异靶子与之配套:谁把这一位重新接进 `resolveRetention`(例如先加一句
// `if item.Temporal == TemporalOneOff { return temp }`),那条正断言必红 ——
// 这不是「测试太严」,而是「接线前必须先有模型真实标签分布」这个裁决的守卫。
// temporalObservationCorpus 覆盖三类正文,每类都必须对 temporal 免疫:
//
// ① 旧规则判 long 的行(接线的「收益」候选);
// ② 旧规则判 temp 的行(`durable` 也不能把它们救回来 —— 零影响是双向的);
// ③ 受限来源 / 显式档位这类「更早的分支」(它们更不该被这一位碰到)。
func temporalObservationCorpus() []string {
return []string{
"和主人有一个出门走十分钟的约定;今天没走成,明天要补上。",
"主人有每天吃早餐的习惯,几乎不会省略早餐。",
"今天下雨了",
"谢谢啦",
}
}
// temporalAllKinds 是「业务上可能出现的全部取值」+ 未分型。
func temporalAllKinds() []TemporalKind {
return []TemporalKind{
TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown,
}
}
// TestTemporalBitDoesNotAffectRetention 是本文件的核心断言:**temporal 对留存判定零影响**
// ——对同一段正文,五个取值的 `(档位, 规则码)` 必须逐字相同(且都以未分型为基准)。
func TestTemporalBitDoesNotAffectRetention(t *testing.T) {
t.Parallel()
for _, content := range temporalObservationCorpus() {
content := content
t.Run(content, func(t *testing.T) {
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
if err != nil {
t.Fatalf("resolveRetention(base): %v", err)
}
for _, temporal := range temporalAllKinds() {
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = temporal
got, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention(temporal=%q): %v", temporal, err)
}
if got != base {
t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位必须零影响;"+
"接线前必须先拿到模型真实标签分布,见 temporal.go 头注)",
content, temporal, got, base)
}
}
})
}
}
// TestTemporalOneOffDoesNotMakeAnythingTemporary 是上一条的正向版本,单独成例是为了
// 让「接线会怎样」在失败信息里一眼可见:它只喂 `one_off`,并点名断言「旧规则判 long
// 的行仍然 long」。
func TestTemporalOneOffDoesNotMakeAnythingTemporary(t *testing.T) {
t.Parallel()
const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。"
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
if err != nil {
t.Fatalf("resolveRetention(base): %v", err)
}
if base.Tier != RetentionLong {
t.Fatalf("前提不成立:%q 旧规则判 %q(want long)", content, base.Tier)
}
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
got, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention(one_off): %v", err)
}
if got.Tier != RetentionLong || got.Rule != retentionNone {
t.Fatalf("temporal=one_off 把 %q 判成 %+v —— 这一位不得参与判定(裁决 D)", content, got)
}
}
// TestTemporalIsStillRecorded 钉住「撤接线不等于撤观测」:`temporal` 仍随同一笔
// INSERT 落库(迁移 0027 的列),而档位/有效期保持旧规则的结论 —— 这正是 D 要的
// 数据来源:全库按 temporal 分组统计、抽样看 one_off 到底长什么样。
func TestTemporalIsStillRecorded(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
const content = "和主人有一个出门走十分钟的约定;今天没走成,明天要补上。"
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = TemporalOneOff
got, err := store.Add(ctx, item)
if err != nil {
t.Fatalf("Add: %v", err)
}
if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff {
t.Fatalf("temporal 列 = %q, want %q(观测面必须保留)", nat, TemporalOneOff)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionLong || expires != "" {
t.Fatalf("落库档位/时效 = (%q, %q), want (long, 空) —— 观测不得改变判定",
tier, expires)
}
}
// TestTemporalDoesNotRescueRuleHitRows 钉住零影响是**双向**的:`durable` 曾经(M45
// 初版)能把规则判 temp 的行救回 long;裁决 D 之后这条也取消 —— 判定只由 N1~N7 决定。
func TestTemporalDoesNotRescueRuleHitRows(t *testing.T) {
t.Parallel()
const content = "今天下雨了"
base, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9))
if err != nil {
t.Fatalf("resolveRetention(base): %v", err)
}
if base.Tier != RetentionTemp {
t.Fatalf("前提不成立:%q 旧规则判 %q(want temp)", content, base.Tier)
}
for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} {
item := habitItem(content, tidyTestTime(t), 0.9)
item.Temporal = temporal
got, err := resolveRetention(item)
if err != nil {
t.Fatalf("resolveRetention(%q): %v", temporal, err)
}
if got != base {
t.Fatalf("%q with temporal=%q 判成 %+v, want %+v(这一位不得推翻规则)",
content, temporal, got, base)
}
}
}
// TestTemporalDoesNotTouchExplicitTierOrRestrictedSource 钉住「更早的分支」不被这一位
// 影响:显式档位(人工入口)与受限来源(群聊/陌生人)都排在第 1/3 步。
func TestTemporalDoesNotTouchExplicitTierOrRestrictedSource(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
explicit := habitItem("谢谢啦", tidyTestTime(t), 0.9)
explicit.RetentionTier = RetentionLong
explicit.Temporal = TemporalOneOff
got, err := store.Add(ctx, explicit)
if err != nil {
t.Fatalf("Add(explicit long): %v", err)
}
if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionLong {
t.Fatalf("显式 long + one_off 落库 = %q, want long(调用方显式档位不许被推翻)", tier)
}
restricted := habitItem("今天下雨了", tidyTestTime(t), 0.9)
restricted.Temporal = TemporalOneOff
restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
got, err = store.Add(ctx, restricted)
if err != nil {
t.Fatalf("Add(restricted): %v", err)
}
tier, expires := readRetentionColumns(t, store, got.ID)
if tier != RetentionTierUnset || expires != "" {
t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires)
}
}
// readTemporalColumn 直读一行的 `temporal` 列(该列与 expires_at 一样是**只写不读**
// 的列:四条列表读路径的 SELECT 不带它,见 store.go 的 Temporal 注释)。
func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind {
t.Helper()
var raw string
if err := store.db.QueryRowContext(context.Background(),
`SELECT temporal FROM memory_items WHERE id = ?`, id).Scan(&raw); err != nil {
t.Fatalf("read temporal column %s: %v", id, err)
}
return TemporalKind(raw)
}
// temporalFieldSites 是**允许**读取 `.Temporal` 的全部生产代码位置(文件 + 行内片段)。
//
// 只有四类,缺一不可、多一不许(多出来的那条就是「接线」):
//
// ① 写入侧的字段校验(store_temporal.go 的 validateTemporal,两行);
// ② 写入侧的列值编码(store_temporal.go 的 temporalColumn);
// ③ 产出侧的构造(llmextractor.go 把模型输出归一后放进候选条目)。
//
// 为什么用「片段」而不是「文件」做粒度:按文件放行会让**同一文件里**新写的接线悄悄
// 溜过去(例如在 store_temporal.go 里加一句 `if item.Temporal == …`)。按片段放行则
// 任何新表达式都必须先改这张表 —— 而改这张表就是一次自觉的接线决定。
func temporalFieldSites() []struct{ file, snippet string } {
return []struct{ file, snippet string }{
{"store_temporal.go", "it.Temporal.valid()"},
{"store_temporal.go", "ErrInvalidTemporal, it.Temporal"},
{"store_temporal.go", "string(it.Temporal)"},
{"llmextractor.go", "normalizeTemporal(c.Temporal)"},
}
}
// TestTemporalIsNotWiredIntoJudgement 是**源码级断言**:`temporal` 只许被记录,
// **不许被判定读**。它回答的是「零影响」能不能被后来人无声破坏 ——
// retention_test.go 的同类断言只钉住 `judgeExtractionRetention` 的调用点,管不到
// 「在异步审查侧拿 `item.Temporal` 改判」这种接线方式。
//
// 复现的缺口(r1 复审变异 C 实测):在 `preinsert.go` 的 `applyVerdict` 入口插一句
// `if item.Temporal == TemporalOneOff { …setIngestRetention(temp)… }`,**整个 memory
// 包全绿** —— 那时没有任何用例察觉。异步侧恰恰是最容易被随手接线的地方(它拿到的
// 是**已落库**的行),因此这条断言必须存在。
//
// 判据:扫生产代码(非 `_test.go`、非注释行)里所有含 `.Temporal` 的行,逐行要求
// 命中 temporalFieldSites 的某一条;否则报**文件 + 行号 + 表达式片段**(不只报个数)。
// 同时校验 allowlist 的每一条**确实命中过**,防止它老化成一张空头许可证。
func TestTemporalIsNotWiredIntoJudgement(t *testing.T) {
t.Parallel()
files, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package dir: %v", err)
}
allowed := temporalFieldSites()
hit := make([]bool, len(allowed))
var violations []string
for _, f := range files {
name := f.Name()
if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for i, line := range strings.Split(string(src), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || !strings.Contains(trimmed, ".Temporal") {
continue
}
ok := false
for j, site := range allowed {
if name == site.file && strings.Contains(trimmed, site.snippet) {
hit[j] = true
ok = true
break
}
}
if !ok {
violations = append(violations, fmt.Sprintf("%s:%d: %s", name, i+1, trimmed))
}
}
}
if len(violations) > 0 {
t.Fatalf("temporal 被接进了判定(裁决 D:它只许被记录,不许被判定读)。"+
"若这是有意的接线,先拿到模型真实标签分布并改 temporal.go 头注、再改本断言的 allowlist:\n%s",
strings.Join(violations, "\n"))
}
for j, site := range allowed {
if !hit[j] {
t.Fatalf("allowlist 项 %s: %q 已不存在 —— 这张表老化了,请同步更新"+
"(否则它只是一张空头许可证)", site.file, site.snippet)
}
}
}
// TestNormalizeTemporal 钉住归一化口径:**只认四个字面值**,其余(空 / 中文 / 自造 /
// 拼写变体)一律 unknown。
//
// 不做同义词映射是刻意的:将来接线时猜同义词的代价是单向的(猜成 one_off 就删记忆),
// 而「拿不准一律 unknown」才是 fail-safe。
func TestNormalizeTemporal(t *testing.T) {
t.Parallel()
cases := []struct {
raw string
want TemporalKind
}{
{"one_off", TemporalOneOff},
{"ONE_OFF", TemporalOneOff},
{" durable \n", TemporalDurable},
{"recurring", TemporalRecurring},
{"unknown", TemporalUnknown},
{"", TemporalUnknown},
{" ", TemporalUnknown},
{"一次性", TemporalUnknown},
{"one-off", TemporalUnknown},
{"oneoff", TemporalUnknown},
{"forever", TemporalUnknown},
}
for _, tc := range cases {
if got := normalizeTemporal(tc.raw); got != tc.want {
t.Fatalf("normalizeTemporal(%q) = %q, want %q", tc.raw, got, tc.want)
}
}
}
// TestTemporalKindClosedSet 钉住闭集本身:四个业务值 + 未分型都合法,其他都不合法
// (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。
func TestTemporalKindClosedSet(t *testing.T) {
t.Parallel()
for _, ok := range temporalAllKinds() {
if !ok.valid() {
t.Fatalf("%q 应在闭集内", ok)
}
}
for _, bad := range []TemporalKind{"one-off", "sometimes", "ONE_OFF", "float"} {
if bad.valid() {
t.Fatalf("%q 不应在闭集内", bad)
}
}
if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false {
t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)")
}
// 非法值必须被写入路径拒掉(不是静默落库成脏列值)。
item := habitItem("一条正文", tidyTestTime(t), 0.9)
item.Temporal = TemporalKind("sometimes")
if err := item.normalize(); err == nil {
t.Fatal("validate 接受了非法的时间性位(应返回 ErrInvalidTemporal)")
}
}
// TestLLMExtractorReadsTemporal 钉住产出侧:提取器**真的**在读模型给的这一位,
// 且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。观测面的数据
// 质量由这条用例守住。
func TestLLMExtractorReadsTemporal(t *testing.T) {
t.Parallel()
adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) {
return model.Response{Content: `[
{"content":"候选一","temporal":"one_off"},
{"content":"候选二","temporal":"durable"},
{"content":"候选三","temporal":"Recurring"},
{"content":"候选四","temporal":"forever"},
{"content":"候选五"}
]`}, 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(非法 temporal 绝不能让整批提取失败)", err)
}
want := map[string]TemporalKind{
"候选一": TemporalOneOff,
"候选二": TemporalDurable,
"候选三": TemporalRecurring,
"候选四": TemporalUnknown,
"候选五": TemporalUnknown,
}
if len(items) != len(want) {
t.Fatalf("候选数 = %d, want %d", len(items), len(want))
}
for _, it := range items {
if it.Temporal != want[it.Content] {
t.Fatalf("%q 的 temporal = %q, want %q", it.Content, it.Temporal, want[it.Content])
}
}
}
// TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的
// 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了
// 就会让每条候选都被归成 unknown,观测面等于白留)。
func TestExtractionPromptCarriesTemporalEnum(t *testing.T) {
t.Parallel()
for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} {
if !strings.Contains(extractSystemPrompt, want) {
t.Fatalf("提取提示词里缺 %q —— 模型无从给出这一位", want)
}
}
}
+4 -2
View File
@@ -87,9 +87,11 @@ const (
// 同样的分区 500 → **250** 个请求、审查链 51 → **26** 个请求,而单请求实测最大
// 1.782s,仍在 5s 之内(16 条/片已复现出 11.043s 的越界单请求,故不取更大)。
//
// ⚠️ 但它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求
// ⚠️ 它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求
// 数(实测 201 条在 4 条/片与 8 条/片下都是 ~21s)。「整理轮每轮把整个分区重新
// 向量化」这个**放大源仍然存在**(user 分区一轮 250 个请求),本切片不动它。
// 向量化」这个放大源由**增量向量化**收口(embed_incremental.go,2026-10-30):
// 索引里已有向量的行不再重嵌、算出来的向量回填索引,因此同一分区**第二轮起**
// 的请求数趋近 0(第一轮仍按 ⌈条数/批⌉ 发,那是把向量算出来的一次性成本)。
//
// 批上限在 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) {
out := make([]embedding.Vector, 0, len(items))
for start := 0; start < len(items); start += tidyEmbedBatch {
end := min(start+tidyEmbedBatch, len(items))
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...)
vecs, stats, err := t.deps.Store.embedIncremental(ctx, t.deps.Embedder, items, tidyEmbedBatch)
if err != nil {
return nil, err
}
return out, nil
logEmbedReuse(t.log, "tidy", stats, tidyEmbedBatch)
return vecs, nil
}
// lexicalSimilarity 是零依赖的相似度回退:两条内容词项集合的余弦。
+1 -1
View File
@@ -50,7 +50,7 @@ const tidyStatusActive = "active"
// 查询(listTidyPartitionsSQL / countTidyPartitionItemsSQL)是同一裁定的实例。
const listTidyPartitionItemsSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
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 = ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
+8 -1
View File
@@ -46,12 +46,19 @@ type Hit struct {
// Index 是建立在已存 (id, vector) 对上的稠密向量相似度索引。
//
// Embed 用接入的 embedder 把一批文本转成向量。Add
// 持久化一个 (id, vector) 对,id 重复时 upsert。Search 返回与 q
// 持久化一个 (id, vector) 对,id 重复时 upsert。Vectors 按 id 取回
// 已存向量(增量向量化的读口)。Search 返回与 q
// 最相似的前 topK 个 id,按相似度降序排列。Dim 报告
// 固定向量维度。Close 释放底层数据库。
type Index interface {
Embed(ctx context.Context, texts []string) ([]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)
Dim() int
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
}
// 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,按余弦相似度
// (1 - 余弦距离)降序排列,由 pgvector 在 SQL 中计算。topK 非正
// 返回空结果;长度与索引维度不一致的查询
@@ -211,3 +261,27 @@ func pgVectorLiteral(v Vector) string {
b.WriteByte(']')
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 {
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
}
// 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,按余弦相似度
// 降序排列。它在 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)
}
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Anthropic Messages(/v1/messages)线协议常量。
@@ -246,7 +247,7 @@ func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Respo
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
}
resp, err := a.doRequest(httpReq, false)
if err != nil {
@@ -303,7 +304,7 @@ func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEv
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
}
resp, err := a.doRequest(httpReq, true)
if err != nil {
@@ -398,7 +399,7 @@ func (a *AnthropicChatAdapter) doRequest(httpReq *http.Request, stream bool) (*h
}
resp, err := a.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err))
}
return resp, nil
}
+4 -3
View File
@@ -11,6 +11,7 @@ import (
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// OpenAI 兼容 /chat/completions 协议的线格式约束。
@@ -134,7 +135,7 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
}
resp, err := a.doRequest(httpReq, false)
if err != nil {
@@ -171,7 +172,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
}
resp, err := a.doRequest(httpReq, true)
if err != nil {
@@ -226,7 +227,7 @@ func (a *OpenAIChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http
}
resp, err := a.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err))
}
return resp, nil
}
+11 -9
View File
@@ -40,10 +40,10 @@ func upstreamLabel(name, baseURL string) string {
// 而这类 error 会被 `chat run` / `loop step` 打日志。截断到 512 字节只是把
// 「整段泄漏」变成「前 512 字节泄漏」,不是修复。
//
// error.code 与 error.type 也要过令牌守卫(trace.ErrorToken):它们在主流上游是枚举,
// 但网关、代理与兼容层可以往里面塞任何东西 —— 包括回显的输入。不像令牌的字段一律变空
// 串,代价只是少一条分类提示;status / body 摘要等诊断面不受影响。两个字段用同一道
// 守卫(不按字段分别处理),是因为「同一份上游报文,两个出口口径不一致」正是下发漏点。
// error.code 与 error.type 的令牌守卫(trace.ErrorToken)**不在这里**:它已上移进
// trace.UpstreamError 的构造函数(M49),上游报文里不像令牌的字段在那里就变空串。
// 调用点再守一遍是 M46 留下的重复 —— 「同一件事守两遍」的下一步永远是两处口径分叉,
// 所以这里删掉,只保留「构造即安全」这一处真源。
//
// 双 %w:既保持 errors.Is(err, ErrUpstream)(降级链按它分类,主 → 备 → 本地),
// 也让 errors.Is(err, trace.ErrUpstream) 成立(调用方按上游失败分类)。
@@ -55,8 +55,6 @@ func providerUpstreamError(name, baseURL string, resp *http.Response) error {
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
return fmt.Errorf("model: %w: status %d", ErrUpstream, resp.StatusCode)
}
info.Code = trace.ErrorToken(info.Code)
info.Type = trace.ErrorToken(info.Type)
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
}
@@ -65,14 +63,18 @@ func providerUpstreamError(name, baseURL string, resp *http.Response) error {
//
// evt.Message 是上游自由文本,Anthropic 会用它回显请求内容(例如「prompt is too
// long: <片段>」),所以**整段丢弃**(原本是无上限拼进 error)。只保留 evt.Type
// (协议枚举,如 overloaded_error)且必须像令牌才出镜:网关把散文塞进 type 时它
// 是空串 —— 宁可少一条分类提示,不让自由文本借道 type 出去。
// (协议枚举,如 overloaded_error)。
//
// 🔴 这里的类型是**流内事件**,报文不在 trace.UpstreamError 的视野里(HTTP 已 200,
// body 为空),所以它属于「构造后注入」这一族,必须走 SetType(与构造函数同一道
// 令牌守卫):网关把散文塞进 type 时它是空串 —— 宁可少一条分类提示,不让自由文本
// 借道 type 出去。
func streamEventError(upstream string, evt *anthropicStreamError) error {
info, ok := trace.UpstreamError(upstream, 0, "text/event-stream", nil).(*trace.UpstreamErrorInfo)
if !ok {
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
return fmt.Errorf("model: %w: anthropic stream error event", ErrUpstream)
}
info.Type = trace.ErrorToken(evt.Type)
info.SetType(evt.Type)
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
}
+169
View File
@@ -0,0 +1,169 @@
package model
// 本文件是 M52 的出口回归探针:model 两个适配器里所有「把 net/http 的
// *url.Error 直接打进错误串」的地方,一律先过 trace.URLCause。
//
// 为什么要测:`client.Do` 失败时 net/http 会包出
// `Post "https://user:***@h/p?access_token=…": dial tcp …` —— 密码被打码,但
// **用户名与整条 query(含 access_token / 预签名参数)原样在串里**,而这条串会进日志、
// 进工单。`http.NewRequest` 的解析失败同样是 *url.Error,把整条 base URL 带出来。
//
// 断言形状是「把任一处改回 err ⇒ 对应用例变红」:两个适配器 × 两条路(建请求 /
// 发送)各一个子用例,不复用同一条断言链,因此变异能定位到具体那一处。
// 同时钉住「只少地址、不少原因」:底层原因(connection refused)与哨兵 ErrUpstream
// 都必须还在 —— 这批只许去掉地址,不许把排障信息一起删。
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
const (
// urlCauseQuerySecret 是藏在 query 里的凭据。net/http **不会**打码 query,
// 因此它是这批病害最真实的载体。
urlCauseQuerySecret = "SECRET-2f8a1c"
// urlCauseUser 是 userinfo 里的用户名(密码由 net/http 打码成 ***,
// 因此用例只对用户名与 query 值断言 —— 那两段才是真正会漏的)。
urlCauseUser = "svc-user"
// urlCauseBadHost 里的空格让 url.Parse 失败 ⇒ 「建请求」那条路的入口。
urlCauseBadHost = "bad host.example.com"
)
// urlCauseBadBase 是「建请求」路径的 base URL:解析必然失败,且原文里同时带着
// userinfo 与 query 凭据。
func urlCauseBadBase() string {
return "https://" + urlCauseUser + ":svc-pass@" + urlCauseBadHost + "/v1?access_token=" + urlCauseQuerySecret
}
// urlCauseGoodBase 是「发送」路径的 base URL:能解析、能建请求,失败由 transport 造成。
func urlCauseGoodBase() string {
return "https://" + urlCauseUser + ":svc-pass@api.example.com/v1?access_token=" + urlCauseQuerySecret
}
// urlCauseFailTransport 让任何请求都在传输层失败(不经网络)。
type urlCauseFailTransport struct{}
func (urlCauseFailTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, errors.New("dial tcp 10.0.0.1:443: connect: connection refused")
}
// urlCauseFailClient 是一个只回传输错误的 client。
func urlCauseFailClient() *http.Client { return &http.Client{Transport: urlCauseFailTransport{}} }
// assertNoURLCarried 断言错误串里既没有 query 凭据也没有用户名,但底层原因还在。
func assertNoURLCarried(t *testing.T, err error, sentinel error) {
t.Helper()
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) {
t.Fatalf("错误串带出了 query 里的凭据:%v", err)
}
if strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("错误串带出了 userinfo 里的用户名:%v", err)
}
if !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("错误串丢了底层原因(只许少地址):%v", err)
}
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel)
}
}
// assertNoURLCarriedInBuild 是「建请求」路径的同口径断言:那一条路的底层原因是
// url.Parse 的解析错误,因此存在性断言换成解析错误文本。
func assertNoURLCarriedInBuild(t *testing.T, err error, sentinel error) {
t.Helper()
if err == nil {
t.Fatal("want error, got nil")
}
if strings.Contains(err.Error(), urlCauseQuerySecret) || strings.Contains(err.Error(), urlCauseUser) {
t.Fatalf("建请求失败的串带出了 base URL 里的凭据:%v", err)
}
if !strings.Contains(err.Error(), "invalid character") {
t.Fatalf("错误串丢了底层解析原因(只许少地址):%v", err)
}
if !errors.Is(err, sentinel) {
t.Fatalf("err = %v, want errors.Is(%v)", err, sentinel)
}
}
// TestOpenAIChatDropsURLFromTransportError 钉住 OpenAIChatAdapter.doRequest 那一处
// (`client.Do` 失败)。
func TestOpenAIChatDropsURLFromTransportError(t *testing.T) {
t.Parallel()
ad := NewOpenAIChat(
config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"},
WithHTTPClient(urlCauseFailClient()),
)
_, err := ad.Chat(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
assertNoURLCarried(t, err, ErrUpstream)
}
// TestOpenAIChatDropsURLFromBuildRequestError 钉住 OpenAIChatAdapter.Chat 里
// NewRequest 失败那一处。
func TestOpenAIChatDropsURLFromBuildRequestError(t *testing.T) {
t.Parallel()
ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"})
_, err := ad.Chat(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
assertNoURLCarriedInBuild(t, err, ErrUpstream)
}
// TestOpenAIChatStreamDropsURLFromBuildRequestError 钉住 ChatStream 里的同一个
// 同族点(两条路各写了一次,改一处漏一处同样会漏地址)。
func TestOpenAIChatStreamDropsURLFromBuildRequestError(t *testing.T) {
t.Parallel()
ad := NewOpenAIChat(config.LLMConfig{BaseURL: urlCauseBadBase(), Model: "m"})
_, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {})
assertNoURLCarriedInBuild(t, err, ErrUpstream)
}
// TestOpenAIChatStreamDropsURLFromTransportError 钉住 ChatStream 复用 doRequest 的那条路。
func TestOpenAIChatStreamDropsURLFromTransportError(t *testing.T) {
t.Parallel()
ad := NewOpenAIChat(
config.LLMConfig{BaseURL: urlCauseGoodBase(), Model: "m"},
WithHTTPClient(urlCauseFailClient()),
)
_, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {})
assertNoURLCarried(t, err, ErrUpstream)
}
// TestAnthropicChatDropsURLFromTransportError 钉住 AnthropicChatAdapter.doRequest。
func TestAnthropicChatDropsURLFromTransportError(t *testing.T) {
t.Parallel()
ad := NewAnthropicChat(
config.LLMConfig{AnthropicBaseURL: urlCauseGoodBase(), AnthropicModel: "m", AnthropicAPIKey: "k"},
WithAnthropicHTTPClient(urlCauseFailClient()),
)
_, err := ad.Chat(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
assertNoURLCarried(t, err, ErrUpstream)
}
// TestAnthropicChatDropsURLFromBuildRequestError 钉住 chatOnce 里的 NewRequest 失败。
func TestAnthropicChatDropsURLFromBuildRequestError(t *testing.T) {
t.Parallel()
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"})
_, err := ad.Chat(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
assertNoURLCarriedInBuild(t, err, ErrUpstream)
}
// TestAnthropicChatStreamDropsURLFromBuildRequestError 钉住 streamOnce 里的同族点。
func TestAnthropicChatStreamDropsURLFromBuildRequestError(t *testing.T) {
t.Parallel()
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: urlCauseBadBase(), AnthropicModel: "m", AnthropicAPIKey: "k"})
_, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, func(StreamEvent) {})
assertNoURLCarriedInBuild(t, err, ErrUpstream)
}
@@ -159,7 +159,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, err)
return "", fmt.Errorf("vision: %w: build request: %v", ErrUpstream, trace.URLCause(err))
}
req.Header.Set("Content-Type", "application/json")
if a.cfg.APIKey != "" {
@@ -167,7 +167,7 @@ func (a *OpenAICompatibleAdapter) ask(ctx context.Context, model string, img Ima
}
resp, err := a.client.Do(req)
if err != nil {
return "", fmt.Errorf("vision: %w: %v", ErrUpstream, err)
return "", fmt.Errorf("vision: %w: %v", ErrUpstream, trace.URLCause(err))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
+14 -1
View File
@@ -119,7 +119,7 @@ func decodeBase64(src string) (string, []byte, error) {
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, sourceOrigin(rawURL))
}
u, err := url.Parse(rawURL)
if err != nil {
@@ -242,6 +242,19 @@ func fileExists(path string) bool {
return err == nil && info.Mode().IsRegular()
}
// sourceOrigin 给出可进错误串的「来源」形态:只有 scheme://host(见
// trace.HostOf),路径、查询串与 userinfo 一律不出 —— 一个被拒的来源地址同样
// 可能带签名 query,原样回显等于把它写进日志与工单。
//
// 解析不出主机(`file:///…`、相对路径、畸形串)时回占位符而**绝不回落原串**:
// 回落就是泄漏,而空串会让错误文案失去「这次拒绝的是哪一类地址」的信息。
func sourceOrigin(rawURL string) string {
if origin := trace.HostOf(rawURL); origin != "" {
return origin
}
return "<unknown source>"
}
// defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3
// 所述的 HTTP client。这里每次调用返回一个新 client,
// 而不是包级全局变量——后者为约束所禁止。
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more