24 KiB
ASR 管线 Agent 解耦 — 重构实施文档
版本:v1(2026-09-18)
上游 review:docs/agent-decoupling-review.md
执行者:子 agent(Claude Code / Codex / 其他具备文件与 shell 能力的 agent)
授权边界:本文档 Stage 1–4 为已授权范围。超出范围的改动(尤其是 §8 禁止项)必须先回报 Kai 并取得确认。
0. 给执行 agent 的前置须知(先读完本节再动手)
0.1 这套系统正在生产运行,且无 git 保护
/home/claw/claude-workspace 不是 git 仓库(已验证:git rev-parse 返回 fatal: not a git repository)。这意味着:
- 没有
git checkout可以回滚。任何改动前必须手动备份。 - 备份约定沿用本项目既有风格(目录中已有 7 个
*.bak.*文件):cp -a <file> <file>.bak.$(date +%Y%m%d-%H%M%S) - 磁盘余量充足(43G available),备份成本可忽略。
同时注意:~/obsidian-vault 是独立 git 仓库,管线会向它 commit+push。本次重构不应触碰 vault 内容,只改 OBSIDIAN/ 下的基础设施代码与 systemd unit。
0.2 当前 baseline(改动前请先自行复现,作为对照)
cd /home/claw/claude-workspace/OBSIDIAN/asr-pipeline
PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
# 期望:Ran 28 tests ... OK
注意:venv 里没有 pytest,必须用 unittest。这 28 个测试全部离线(test_core.py 在 import 前就把 ASR_STATE_DIR/ASR_VAULT 重定向到 tempdir),可以安全反复跑。
任何 stage 结束时这 28 个测试必须仍然全绿,且新增功能需补充测试(见各 stage 的验收标准)。
0.3 当前运行状态(改动前实测,供对照)
| 单元 | 状态 | 说明 |
|---|---|---|
obsidian-recording-watch.service |
active | 音频监听 + 转录 + 桥接 |
obsidian-asr-refine.path |
active | 监听 .asr-inbox/ |
asr-review-web.service |
active | Web 审核/上传 UI,.145:8765 |
obsidian-asr-refine.service |
inactive | 旧 claude -p 链路,保留供 rollback |
127.0.0.1:8644 已确认无监听(ss -tlnp | grep 8644 为空)。.48 ICMP 可达,但 8644 等端口从 .145 探测为 closed/filtered,且 SSH 到 .48 被拒(publickey)——执行 agent 无法自行登录 .48 查看其配置。这是本次重构的关键约束,见 §1.3。
0.4 上游 review 的一处补充(执行时必须一并修掉)
在准备本实施文档时复核代码,发现 review 未记录的一个真实 bug,与本次解耦同属一处代码,应合并修复:
asrpipe/vault.py:135 把第一个位置参数写死成字符串 "草稿已生成,待确认":
r = subprocess.run(["python3", str(n), "草稿已生成,待确认", title, body], ...)
而 meeting-status-notify.py 的 argv 契约是 (status, filename, detail)。于是所有 5 处 vault.notify() 调用的语义都被错位丢弃:
| 调用点 | 调用方本意(第 1 参数) | 实际发出的 status |
|---|---|---|
revise.py:273 |
"ASR 正式记录已发布" | 草稿已生成,待确认 ❌ |
cli.py:109 |
"ASR Web 审核启动失败" | 草稿已生成,待确认 ❌ |
cli.py:160 |
"ASR Web 审核启动失败" | 草稿已生成,待确认 ❌ |
review_web.py:49 |
"ASR 快速确认" | 草稿已生成,待确认 ❌ |
review_web.py:271 |
"ASR 快速确认" | 草稿已生成,待确认 ❌ |
即:"发布成功"和"启动失败"在 Telegram 上都显示为"草稿已生成,待确认"。调用方传的 title 被挤到 filename 位、body 被挤到 detail 位。这个 bug 目前被 8644 不通掩盖着(消息根本没发出去),一旦按本文档恢复通知,它会立刻变成可见的错误告警。Stage 2 必须连带修掉。
1. 重构目标与设计约束
1.1 目标
把"管线向人(Kai)发通知"这件事,从**"调用 Hermes 网关某个 IP:port 上的私有 webhook",改造成"调用一个稳定的本地通知接口,Hermes 只是它的一种后端"**。
判据(重构完成后应当成立):
- Hermes 再次换主机/换端口/换协议时,只改一个配置文件,不改任何一行 Python、不改任何 systemd unit。
- 通知目标不可用时,管线行为与今天一致:记日志、继续跑、不阻塞、不丢数据。
- 通知发送逻辑(HMAC 签名 + POST)在代码库里只有一份实现。
- 管线不再读取
~/.hermes/下的任何文件。 - 上述 4 条对两个调用方(
meeting-watch.py和asrpipe)同时成立。
1.2 设计风格必须与现有代码一致
这个项目有很清晰的既有风格,重构必须沿用而不是另立一套:
- 配置集中在
asrpipe/config.py的Configdataclass,每项都是_env("ASR_XXX", default)形式,env 可覆盖(见该文件已有的use_termbank/use_hindsight/gate_enabled等)。 - 失败降级优先于抛异常:通知失败只
log(..., level="WARN"),绝不中断管线(vault.py:139已是此风格,meeting-status-notify.py的 docstring 也明确写了 "Silent no-op (exit 0) ... never block the pipeline")。 - 注释说明"为什么"而非"是什么",且保留实测数据(参考
config.py里 clean_model 的选型注释、llm.py的 temperature 注释)。中文注释与英文注释在本项目中混用,按所改文件的既有语言为准。 - 凭证走 600 权限的独立 env 文件,不进代码、不进 git(
bin/.asr-refine.env是既有先例)。
1.3 关键约束:.48 的新地址当前未知
执行 agent 无法 SSH 到 .48,因此无法自行确定 Hermes webhook 的新地址。
这不阻塞重构——恰恰相反,这正是要做接口层的理由。处理方式:
- Stage 1–3 的全部工作不需要知道
.48的地址,只需要把地址变成"一个配置文件里的一个字段"。 - 配置文件的初始值填
.145的旧地址(即当前的http://127.0.0.1:8644/...),并在文件里注释清楚"此地址在 2026-09-15 Hermes 迁移至 .48 后已失效,待 Kai 提供新地址后更新此处"。 - 重构完成后,由 Kai 填入
.48的真实地址,或按 Stage 4 的direct后端绕过 Hermes。 - 禁止猜测端口后写进配置当作"已修复"。宁可保留失效地址 + 明确注释,也不要写一个看似能跑实际错误的值。
2. 实施总览
| Stage | 内容 | 依赖 | 是否需要 Kai 输入 |
|---|---|---|---|
| 1 | 建立通知配置层 + 单一发送实现 | — | 否 |
| 2 | 两个调用方收敛到该实现 + 修 argv 错位 bug | 1 | 否 |
| 3 | 清理 systemd 硬编码 + 文档同步 | 2 | 否 |
| 4 | (可选)direct 后端,彻底不依赖 Hermes |
3 | 是(需 token 授权决定) |
Stage 1–3 应一镜到底连续执行,中间不要停下来问;每个 stage 结束跑一次测试即可。Stage 4 涉及凭证使用决策,必须停下来问 Kai。
3. Stage 1 — 建立通知配置层与单一发送实现
3.1 新增配置文件 OBSIDIAN/bin/notify-target.json
这是本次重构的唯一真相源。结构建议(执行时可按需微调字段名,但必须保持"后端可切换 + 目标可配置"两个语义):
{
"backend": "webhook",
"_comment": "backend: webhook | none | direct(见 Stage 4)。切换后端不需要改代码。",
"webhook": {
"url": "http://127.0.0.1:8644/webhooks/meeting-recording-status",
"_url_comment": "⚠️ 此地址在 2026-09-15 Hermes 由 .145 迁移到 .48 后已失效。待 Kai 提供 .48 上的新地址后更新此字段。保留失效值而非猜测端口,是为了让故障显式可见。",
"secret": "",
"_secret_comment": "HMAC-SHA256 共享密钥。留空则不签名(对端若校验会拒绝)。历史上此值取自 ~/.hermes/webhook_subscriptions.json 的 meeting-recording-status.secret;现在改为本项目自持,不再读 Hermes 私有配置。",
"timeout": 10
}
}
权限必须是 600(含密钥):
chmod 600 /home/claw/claude-workspace/OBSIDIAN/bin/notify-target.json
迁移密钥:从 ~/.hermes/webhook_subscriptions.json 的 meeting-recording-status.secret 复制一次到上面的 secret 字段。这是一次性拷贝,之后管线不再读 Hermes 的文件。(该文件当前结构已确认为 {route: {secret, deliver, deliver_extra:{chat_id}, ...}}。)
3.2 改造 OBSIDIAN/bin/meeting-status-notify.py 为唯一实现
保持它现有的双重身份——既是 CLI(供 shell 脚本调用),也要能被 Python import(供 asrpipe 直接调用,省掉一次 subprocess)。要求:
- 读
notify-target.json决定后端与目标;删除对~/.hermes/webhook_subscriptions.json的读取(SUBSCRIPTIONS常量与secret()函数)。 - 保留 env 覆盖能力,优先级
env > notify-target.json > 内置兜底。env 变量名沿用既有前缀风格(如MEETING_STATUS_WEBHOOK_URL已在meeting-watch.py中使用,继续复用同名,不要另起新名)。 backend: "none"时直接记日志返回,不发网络请求——这让"临时静音"不需要改代码,也让测试可以完全离线。- 暴露一个可 import 的函数,签名保持与 CLI 契约一致:
返回是否送达成功;内部吞掉所有异常,绝不向调用方抛(维持"never block the pipeline")。
def send(status: str, filename: str = "", detail: str = "") -> bool __main__分支保持argv = (status, filename, detail)契约不变——bin/obsidian-asr-refine.sh:45仍以此方式调用它(旧 rollback 链路),不要破坏该契约。
3.3 验收标准
cd /home/claw/claude-workspace/OBSIDIAN
# 1. backend=none 时静默成功、不发网络请求
python3 - <<'EOF'
import json, pathlib
p = pathlib.Path("bin/notify-target.json")
d = json.loads(p.read_text()); d["backend"] = "none"
p.write_text(json.dumps(d, ensure_ascii=False, indent=2))
EOF
python3 bin/meeting-status-notify.py "测试" "f.mp3" "detail" # 期望:退出码 0,日志说明 backend=none
# 2. backend=webhook 且目标不通时,仍然退出码 0(不阻塞)
# (把 backend 改回 webhook 后重跑,期望打印类似 "skipped: Connection refused",退出码仍为 0)
echo "exit=$?"
# 3. 确认不再引用 Hermes 私有配置
grep -rn "webhook_subscriptions\|\.hermes" bin/meeting-status-notify.py
# 期望:无输出(注释里提及历史来源可以,但不能有真实读取)
# 4. 旧链路 CLI 契约未破坏
bash -n bin/obsidian-asr-refine.sh && echo "refine.sh syntax ok"
4. Stage 2 — 两个调用方收敛 + 修 argv 错位
4.1 asrpipe 侧
asrpipe/config.py:
- 新增
notify_backend字段,风格与既有项一致:notify_backend: str = _env("ASR_NOTIFY_BACKEND", "")(空 = 交由notify-target.json决定;显式设置则覆盖)。 - 保留现有
notify: bool(ASR_NOTIFY=0的总开关)——这是既有行为,doctor和文档都依赖它,不要删。 notifierproperty 保留(cli.py:207的 doctor 检查在用),但其含义从"要 subprocess 调用的脚本"变为"通知实现模块的位置"。若改动了 doctor 的检查语义,需同步更新cli.py的chk("notifier", ...)。
asrpipe/vault.py 的 notify():
- 修掉 argv 错位 bug:不再把
"草稿已生成,待确认"写死为 status。调用方传入的第一个参数(语义上就是 status/事件名)必须原样传递。 - 建议直接 import
meeting-status-notify.py暴露的send(),取代subprocess.run(["python3", ...])——省一次进程启动,也让类型契约显式。若因文件名带连字符不便 import,可用importlib.util.spec_from_file_location(路径来自CFG.notifier),或把共享逻辑提取为OBSIDIAN/bin/notify_lib.py再由两边 import。三种都可接受,选一种并在注释里说明理由。 - 保留
redact_log参数行为(review_web.py:271传了redact_log=True,因为 body 含一次性 capability URL,绝不能进日志)。这是安全相关行为,不要动。 - 保留"notifier 不存在时只记日志"的降级分支。
5 处调用点(revise.py:273、cli.py:109、cli.py:160、review_web.py:49、review_web.py:271):修好 notify() 后语义即自动正确,调用点本身不需要改。改完后请逐一确认它们发出的 status 与调用方本意一致(对照 §0.4 的表格)。
4.2 meeting-watch.py 侧
- 删除自建的 HMAC 签名与 POST 逻辑:
webhook_secret()(L177)、post_webhook()(L187)、post_status()(L205)。 post_status改为薄封装,转调 Stage 1 的单一实现;3 个调用点(L272 "开始转录"、L286 "转录完成,正在生成粗整理"、L304 "处理失败")的调用形式保持不变。- 删除
WEBHOOK_URL/WEBHOOK_ROUTE/WEBHOOK_SECRET/STATUS_WEBHOOK_URL/STATUS_WEBHOOK_ROUTE/SUBSCRIPTIONS这些常量(L32–L37)。
关于 meeting-recording 路由(携带正文那条):post_webhook() 在当前代码里只被 post_status() 调用过,即 meeting-recording 路由从未真正被 POST(已 grep 确认)。它是旧 Hermes agent 精修架构的残留,CLAUDE.md 明确记载该架构"已取代"。删除这条死路径,不要为它保留接口。(~/.hermes/webhook_subscriptions.json 里那条带 prompt/skills: [memo-inbox] 的 meeting-recording 订阅同属旧架构遗留,但那是 Hermes 侧的配置,不在本次授权改动范围,只在 §7 报告里提一句即可。)
另外注意 meeting-recording-status 订阅带 deliver_only: True——它只做消息投递、不唤起 agent。这印证了"状态通知本质上只是一条消息,不需要 agent 参与",也是 Stage 4 direct 后端在语义上完全等价的依据。
4.3 补充测试(必须新增)
在 asr-pipeline/tests/test_core.py 中新增(沿用该文件既有的 unittest + mock 风格,注意它已在 import 前把 state/vault 重定向到 tempdir):
- status 透传测试(覆盖 §0.4 的 bug):mock 掉实际发送,断言
vault.notify("ASR 正式记录已发布", "body")传出的 status 是"ASR 正式记录已发布",不是"草稿已生成,待确认"。这是防止该 bug 回归的关键测试。 - 降级测试:目标不可达(mock 抛
URLError)时vault.notify()不抛异常、正常返回。 - backend=none 测试:不产生任何网络调用。
redact_log测试:redact_log=True时日志中不含 body 原文(防止 capability URL 泄漏进日志)。
4.4 验收标准
cd /home/claw/claude-workspace/OBSIDIAN/asr-pipeline
PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
# 期望:28 + 新增用例,全绿
# 单一实现确认:HMAC 签名逻辑只应出现在一个文件里
cd /home/claw/claude-workspace/OBSIDIAN
grep -rn "hmac.new" --include="*.py" . | grep -v -E "__pycache__|\.venv|\.bak\."
# 期望:只有 1 处(notify 实现所在文件)
# 管线不再读 Hermes 私有配置
grep -rn "webhook_subscriptions" --include="*.py" --include="*.sh" . | grep -v -E "__pycache__|\.venv|\.bak\."
# 期望:无真实读取(注释提及历史来源可接受)
# doctor 仍通过(会联网测两个模型,属正常)
asr-pipeline/bin/asr-refine doctor
5. Stage 3 — systemd 与文档同步
5.1 systemd
~/.config/systemd/user/obsidian-recording-watch.service 删除这一行(第三处硬编码):
Environment=MEETING_WEBHOOK_URL=http://127.0.0.1:8644/webhooks/meeting-recording
理由:该 URL 对应的 meeting-recording 路由已在 Stage 2 删除;状态通知目标现由 notify-target.json 统一提供,不应再由 unit 注入。
保留 MEETING_INBOX_BASE 与 MEETING_ASR_BACKEND=qwen3cam——这两个是真实生效的部署配置,与本次解耦无关(后者还带着"ASR backend trial"的回滚注释,不要动)。
生效并验证:
systemctl --user daemon-reload
systemctl --user restart obsidian-recording-watch.service
systemctl --user is-active obsidian-recording-watch.service # 期望 active
journalctl --user -u obsidian-recording-watch.service -n 30 --no-pager
asr-review-web.service 与 asr-review-revise@.service 不需要改(它们通过 asrpipe 间接受益;后者的 EnvironmentFile=.asr-refine.env 只管模型凭证)。
5.2 文档
按"一份权威来源 + 其余引用"的结构收敛(与 vault 侧 AGENTS.md 作索引的模式一致):
| 文件 | 动作 |
|---|---|
asr-pipeline/docs/agent-protocol.md |
新建。本次解耦的权威文档:通知接口契约(status/filename/detail 三段语义)、notify-target.json 字段含义、后端切换方式、"Hermes 只是一种后端"的定位、任何 agent 想接入时该读什么。Kai 早在 2026-07-17 就规划过这份共用协议文档但从未落地——这次补上。 |
asr-pipeline/HANDOFF.md |
第 71 行 "Telegram 通知(hermes-gateway :8644 签名 webhook)" 改为指向通知接口层,不再把 Hermes 端口写进设计文档;链接到 agent-protocol.md。 |
OBSIDIAN/CLAUDE.md |
管线段落中的 meeting-status-notify.py 描述更新为"可切换后端的通知接口";补一行指向 agent-protocol.md。保持该文件"只留 Claude + 本服务器专属基础设施"的既有定位,不要把协议细节搬进来。 |
~/.claude/skills/memo-inbox/references/webhook-toolset-diagnostics.md |
标注废弃。它描述的"webhook agent 直接处理 meeting-recording 路由 + 需要 hermes-webhook/file/skills toolset"架构已于 2026-07-25 被取代,该路由在 Stage 2 已删除。建议在文件顶部加醒目废弃声明 + 指向 agent-protocol.md,而不是直接删除(保留历史排障脉络)。注意:这是 skill 目录,改动会影响 Claude 的行为,改完在 §7 里明确告知 Kai。 |
~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md |
五阶段表格等与 HANDOFF.md 逐字重复的内容改为链接引用;保留 skill 独有的纪律性内容("第一轮只出粗整理,不直接写正式纪要"、S3 fidelity gate 存在的理由与实测数据、模型选型不得随意改动的警告)——这些是 Kai 的工作纪律,不是可去重的冗余。 |
5.3 验收标准
# 确认 8644 不再出现在任何活跃代码/配置里(文档中作为历史说明可保留)
cd /home/claw/claude-workspace/OBSIDIAN
grep -rn "8644" --include="*.py" --include="*.sh" . ~/.config/systemd/user/*.service 2>/dev/null \
| grep -v -E "__pycache__|\.venv|\.bak\."
# 期望:仅 notify-target.json 的待更新地址 + 注释
# 端到端(不触碰 vault、不 commit)
asr-pipeline/bin/asr-refine dry-run <任一 .asr-inbox/*.txt>
# 期望:五阶段跑通,产物落 asr-pipeline/dry-run/<slug>/,通知步骤降级但不报错
6. Stage 4 —(可选,需 Kai 决策)direct 后端
Stage 1–3 完成后,通知链路仍然经过 Hermes:管线 → Hermes 网关 webhook → Telegram。只要 Hermes 不在线,Kai 就收不到通知。
可选的彻底解耦:增加 backend: "direct",直接调 Telegram Bot API(https://api.telegram.org/bot<token>/sendMessage),不经过 Hermes。
技术上已确认可行:
~/.hermes/.env中存在TELEGRAM_BOT_TOKEN;chat_id(1093241065)在webhook_subscriptions.json的deliver_extra里已确认。meeting-recording-status订阅标记为deliver_only: True,即 Hermes 对这条消息只做投递、不唤起 agent。因此direct后端在语义上与现状完全等价,不损失任何 agent 能力。
但这需要 Kai 决策,不要擅自实施,因为:
- 要把 Telegram bot token 复制进本项目自持的凭证文件(或新增一份 600 env 文件)。这扩大了 token 的存放面,是安全决策,须 Kai 同意。
- 绕过 Hermes 后,Telegram 侧的消息格式/限流/重试不再由 Hermes 统一处理,需自行承担。
- Kai 可能有意保留"所有对外消息都经 Hermes"的架构约定。
执行方式:Stage 3 完成后,在 §7 报告中列出此选项与上述权衡,等 Kai 明确答复再动手。届时若 Kai 同意,direct 只是 notify-target.json 里加一个后端分支 + 一个 sender 函数,改动很小——这正是 Stage 1 接口层带来的收益。
7. 完成后的回报要求
Stage 1–3 结束后,向 Kai 报告,包含:
- 改了哪些文件,每个文件一句话说明,附备份文件名。
- 测试结果原文(
Ran N tests ... OK)。若有任何失败,如实说明失败项与原因,不要含糊。 - §0.4 的 argv 错位 bug 已修复的证明:新增测试的名称与断言内容。
- 仍然待办的事,明确区分:
- 需要 Kai 提供
.48上 Hermes webhook 新地址,填入notify-target.json的webhook.url(在此之前通知仍不通,但已降级为"改一个配置字段")。 - Stage 4
direct后端的决策请求(附 §6 的三点权衡)。 - Hermes 侧
~/.hermes/webhook_subscriptions.json中meeting-recording那条旧订阅(带prompt/skills: [memo-inbox])属旧架构遗留、已无调用方,建议 Kai 自行清理——本次未改动,因为不在授权范围。
- 需要 Kai 提供
- 动过
~/.claude/skills/的明确告知(若执行了 §5.2 的 skill 文档改动)——这会影响 Claude 后续行为,Kai 需要知道。
8. 明确禁止(越界前必须先问)
- ❌ 不要猜
.48的端口后填进配置当作"已修复"。宁可留失效地址 + 注释。SSH 到.48已确认被拒,无法自行核实。 - ❌ 不要往
asrpipe/核心五阶段(segment/flagger/refine/fidelity/revise)里加任何 agent 相关代码。cli.py与config.py头部的 "no Claude Code, no opencode, no agent runtime" 声明是这套系统最值得保留的架构资产。 - ❌ 不要改模型选型(
clean_model/draft_model)。config.py与HANDOFF.md都记录了实测依据(flash-lite 闸门失败 19/87 vs haiku 78/87,且 haiku 有 69 次过度压缩)。与本次解耦无关。 - ❌ 不要关闭或削弱 S3 fidelity gate。它防的是静默信息丢失(无闸门时英文术语丢失达 22%、数字 11%)。
- ❌ 不要删除 rollback 链路(
bin/obsidian-asr-refine.sh+obsidian-asr-refine.service)。它们当前 inactive 但被obsidian-asr-refine.path的注释和HANDOFF.md记录为回滚路径。只需保证refine.sh:45调用meeting-status-notify.py的 CLI 契约不被破坏。 - ❌ 不要动
~/obsidian-vault/的内容,也不要在本次重构中产生 vault commit。本次只改OBSIDIAN/基础设施与 systemd unit。 - ❌ 不要清理那 7 个
*.bak.*文件。它们是 2026-07-26 重构的历史留存,清理属独立决策,需 Kai 单独批准。 - ❌ 不要引入新的第三方依赖。
asr-pipeline/.venv目前只有 pypinyin / rapidfuzz / numpy,通知功能用标准库urllib.request+hmac+json足够(现有实现已证明)。 - ❌ 不要为解耦搭建通用消息总线/插件框架。当前只有一个通知需求,
notify-target.json里一个backend字段 + 几个分支就够。过度工程化会让下一个维护者更难读。
9. 执行顺序速查
0. 读 §0 全部 → 跑 baseline 测试(28 OK)→ 备份将改动的文件
1. Stage 1:notify-target.json + meeting-status-notify.py 单一实现 → 验收 §3.3
2. Stage 2:config.py / vault.py(修 argv bug)/ meeting-watch.py + 4 个新测试 → 验收 §4.4
3. Stage 3:systemd 去硬编码 + agent-protocol.md + 4 份文档同步 → 验收 §5.3
4. 回报 Kai(§7),等 .48 地址与 Stage 4 决策
Stage 1–3 连续执行,不要中途停下来确认;Stage 4 必须等 Kai 答复。