feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s

This commit is contained in:
Codex
2026-11-02 06:37:33 -08:00
parent 9debd1c47d
commit 3c70861463
44 changed files with 4354 additions and 333 deletions
@@ -0,0 +1,190 @@
# ASR Agent 解耦实施项目(v2)
日期:2026-09-18。状态:代码核查后的实施计划,尚未执行生产改造。
上游:[耦合 Review](agent-decoupling-review.md)、[v1 实施文档](agent-decoupling-implementation.md)。本文件保留原文作为历史依据,修订执行设计和验收方式;实际开发以当前代码和本计划为准。本文中的工作项不是已完成变更,也不以旧文档的“已授权”文字代替后续执行请求。
## 1. 项目目标与交付边界
让录音监听与 ASR 管线通过同一份本地通知接口发送状态;目标地址、密钥和后端由独立配置控制。核心整理程序继续使用 HTTPS 模型 API 和文件工作流,不依赖 Claude、Hermes、Codex 的运行时、登录态或私有配置。
首期交付:唯一发送实现、通知参数纠错、完整日志脱敏、两个 Python 调用方接入、兼容旧 shell CLI、隔离测试、部署/回滚记录、跨 agent 协议文档。通知目标尚未核实不阻塞代码交付,但“接口改造完成”与“真实消息送达”必须分别报告。
后续独立项目:watcher 与 intake 数据库解耦、通用 agent skill 包装、Telegram direct 后端、STT wrapper 命名迁移。首期不修改五阶段算法、模型、fidelity gate、Vault 内容、Hermes 配置、历史备份或 rollback 链路,不新增第三方依赖。
## 2. 代码核查结果与 v1 修订
下列行号对应 2026-09-18 核查快照,实施时以函数名定位。路径相对 `OBSIDIAN/`。
| 证据 | 结论及实施影响 |
|---|---|
| `bin/meeting-status-notify.py:17–19,22–48` | 地址写死、运行时读取 Hermes 私有订阅;与 watcher 重复签名发送。应收敛为唯一模块。 |
| `asr-pipeline/asrpipe/vault.py:126–140` | status 被写死为“草稿已生成,待确认”;缺脚本分支直接记录 body,异常分支原样记录异常,可能包含带正文的 argv。修参数时须同时修所有脱敏分支。 |
| `recordings/bin/meeting-watch.py:177–216` | `post_webhook()` 实际被 `post_status()` 调用;死的是默认 `meeting-recording` 路由,不是整个函数无人调用。先替换状态路径,再删通用发送函数。 |
| `recordings/bin/meeting-watch.py:142` | `hashlib.sha256()` 还用于音频 ID,删除通知签名后不能顺手删 `hashlib`。 |
| `asr-pipeline/asrpipe/cli.py:136–147` | dry-run 提前返回,不经过通知分支,不能证明通知降级正常。 |
| `asr-pipeline/asrpipe/cli.py:191–248` | doctor 会同步词库、访问 Hindsight 和调用模型;不作为通知重构的必跑验收。 |
| `asr-pipeline/tests/test_core.py:11–14` | 已隔离 state/vault/runtime,但未隔离 ASR_LOG;新测试必须补齐日志、通知配置和网络隔离。 |
| `asr-pipeline/asrpipe/control_web.py:341–367,457–464` | 有查询/上传/审核接口,没有 watcher 注册或状态更新 API。不能将“统一走现有 HTTP API”作为可直接执行的替换指令。 |
| `asr-pipeline/asrpipe/review_web.py:30–56` | 当前 request_start 接入常驻 control_web;旧 capability 页面逻辑也仍在文件中。协议必须区分真实生产入口与兼容路径,不能把所有审核 URL 都称为一次性地址。 |
| `recordings/bin/meeting-watch.py` 的 `process_one()` / `main()` | 先移动到 processing 再等待转写,启动仅扫 inbox;直接 restart 可能中断转写并留下孤立文件。部署必须排空任务。 |
其他修订:
- watcher unit 的 `MEETING_WEBHOOK_URL` 是旧正文路由的死配置,并非当前状态通知的第三个有效 URL 来源;仍应清理,避免继续误导。
- v1 建议保留失效地址并默认 webhook;v2 改为新模板默认 `none`,不在可执行默认值里保留已知失效 endpoint。已有部署配置必须先备份、显式迁移,不静默覆盖。
- v1 空密钥允许不签名;v2 webhook 缺有效密钥时降级为配置错误且不发送,避免将审核链接投往未认证的目标。未来需要匿名 webhook 时另定义显式协议。
- “通知失败不阻塞”指不影响业务成败;同步 HTTP 仍有等待。本期保留有界等待,不声称实现了异步或可靠消息队列。
- 旧文档的 `.48` SSH/端口结论属于历史记录;本轮没有重新探测远端,也未读取凭证。新地址、密钥和投递结果仍需部署时核实。
- 递归搜索只限活跃源文件及相关 unit,排除 `.venv`、备份和其他服务;不能把整个用户目录中出现 8644 或 HMAC 都认定为本项目失败。
## 3. 目标接口与配置决策
### 3.1 单一实现与加载方式
以 `OBSIDIAN/bin/meeting-status-notify.py` 为唯一配置解析、消息序列化、HMAC 和 HTTP 实现,不再另建插件框架或共享包。该文件必须能被 import,顶层不读取密钥、不发请求,CLI 放在 main guard 内。
建议接口:
```python
def send(status: str, filename: str = "", detail: str = "", *,
backend: str | None = None) -> bool:
...
```
前三个位置参数保持兼容;可选 backend 供 `CFG.notify_backend` 显式覆盖使用。只在对端返回 2xx 时返回 True;静音、配置错误和投递失败均返回 False,并用无敏感信息的原因码区分。函数捕获普通 Exception,不捕获 KeyboardInterrupt/SystemExit。CLI 仍接收 `(status, filename, detail)`,投递失败保持退出码 0;CLI 成功退出不代表送达成功。
两个调用方使用 `importlib.util.spec_from_file_location` 从绝对路径加载该模块,避免 sys.path 全局改动和引入管线依赖。vault 沿用 `CFG.notifier`;watcher 从 `__file__` 的项目目录定位。模块可缓存,配置每次 send 重新读取,配置修改无需重启;首次加载失败不永久缓存失败状态。加载与调用均置于降级保护中。
### 3.2 配置契约
生产文件 `OBSIDIAN/bin/notify-target.json`(0600),另新增不含真实凭证的 `notify-target.example.json`。配置更新采用同目录 0600 临时文件、写入后原子替换;不把密钥放进命令行、终端输出或文档。源码备份和凭证备份分开记录,凭证备份同样 0600,后续纳入版本控制时排除生产配置。
```json
{
"backend": "none",
"webhook": {
"url": "",
"secret": "",
"timeout": 10
}
}
```
| 变量 / 字段 | 语义与优先级 |
|---|---|
| `MEETING_NOTIFY_CONFIG` | 配置路径覆盖;默认脚本同目录的 notify-target.json,不能依赖 cwd。 |
| `send(..., backend=...)` | 非空显式值最高优先;vault 从 `CFG.notify_backend` 传入。 |
| `ASR_NOTIFY_BACKEND` | 两调用方共用的 backend 环境覆盖;其后依次为 JSON、内置 none。 |
| `MEETING_STATUS_WEBHOOK_URL` | 非空值覆盖 JSON URL,保留 watcher 既有状态 URL 配置能力。 |
| `MEETING_STATUS_WEBHOOK_SECRET` | 非空值覆盖 JSON secret;新增状态专用变量,不复用旧正文路由密钥变量。 |
| `ASR_NOTIFY=0` | 保持既有 asrpipe 总开关语义,不扩展为 watcher 的隐式总开关;全部静音用 backend=none。 |
| `webhook.timeout` | 有限正数,约束在 0 < timeout <= 10 秒;非法值不发送。urllib 超时不是全链路严格墙钟上限,尤其 DNS 阶段,文档不得作更强保证。 |
未知 backend、损坏 JSON、非对象配置、无 URL/密钥、非法 timeout:安全原因码 + False,无网络请求。`none` 不要求 webhook 字段有效。backend=webhook 仅支持明确的 HTTP(S) URL,不把旧 `INSECURE_NO_AUTH` 当作有效共享密钥。环境变量只作为有记录的部署覆盖;正常运行不在各 unit 重复配置 endpoint。
HMAC 保持现有线协议:UTF-8 JSON,字段 `status/filename/detail`,`ensure_ascii=False`,对实际发送的同一份 bytes 做 SHA256 HMAC,放入 `X-Webhook-Signature`。拒绝自动重定向,避免把正文转发到其他目标;非 2xx 均判失败。无自动重试,不承诺 exactly-once,也不引入后台线程。
### 3.3 调用语义与日志
- `vault.notify(title, body)` → `send(status=title, filename="", detail=body)`;不要把 title 再塞进 filename。
- watcher 的 `post_status(status, filename, detail="")` 保持三个调用点形式和字段含义。
- legacy shell 仍用原三个位置参数运行脚本。
- `redact_log=True` 时,成功、静音、缺文件、加载失败、配置错误、网络异常各路径都不能出现 body。保护范围包括 stdout/stderr、HTTP 响应正文和原始异常字符串;发送模块只记录安全原因码/HTTP 状态,不记录 payload、密钥、目标完整 URL 或响应正文。
- 建议发送层统一不记录正文,vault 对非敏感正文是否保留既有日志由最小变更原则决定;敏感正文标记必须在进入任何日志分支前计算。
- 配置缺失/静音必须可诊断,但不改变录音、转写、草稿或发布成功状态。
## 4. 实施工作包与依赖
| 工作包 | 交付物 | 依赖 / 完成门槛 |
|---|---|---|
| W0:基线与备份 | 活跃文件清单、隔离测试记录、文件哈希与带时间戳备份清单 | 开始前确认无 Git 保护;源码、unit、文档修改前逐项备份。 |
| W1:唯一发送实现 | notify 脚本、示例配置、临时配置测试 | W0;none/签名/失败/CLI/脱敏全部通过,不触达真实目标。 |
| W2:调用方迁移 | vault.py、config.py、meeting-watch.py、必要的 doctor 静态检查调整 | W1;5 个 vault 调用点、3 个 watcher 调用点及 legacy CLI 兼容性通过。 |
| W3:协议与索引 | agent-protocol.md、HANDOFF/CLAUDE/skill 引用更新 | W2;区分当前与计划接口,移除过时排障入口的误导。 |
| W4:部署与回滚演练 | unit 去旧变量、空闲窗口记录、进程重载验证、迁移报告 | W1–W3 全部离线通过;配置初始 none,业务服务健康。 |
| W5:真实通知恢复 | 核实目标与项目自持密钥后的配置更新、实际投递记录 | 已具备真实目标及发测试消息的明确授权;不以代码测试冒充真实验收。 |
建议未来并行执行:通知 agent 负责 W1 和发送层测试;调用方 agent 负责 W2 与调用方测试;文档 agent 负责 W3;集成者负责 W0/W4/W5。W1/W2 先冻结第 3 节契约,避免同时修改同一测试文件。当前本轮子智能体仅完成只读探索,未执行这些开发包。
W2 特别要求:删除 watcher 的六个 webhook 常量及私有订阅读取,移除不再需要的 hmac/urllib 导入,保留用于文件 ID 的 hashlib、用于元数据的 json。保留 `CFG.notify`、`CFG.notifier`;新增 `CFG.notify_backend` 并明确传递,避免“声明配置字段却没生效”。doctor 的 notifier 存在性可保留,但不得把文件存在描述为通知链路健康。
## 5. 离线测试与验收矩阵
优先新增 `tests/test_notify.py` 与 watcher 适配测试,保留既有 unittest 风格。测试导入前设置临时 ASR_STATE_DIR、ASR_VAULT、ASR_LOG、XDG_RUNTIME_DIR、MEETING_INBOX_BASE、ASR_CONTROL_DB 和 MEETING_NOTIFY_CONFIG;清理继承的通知覆盖变量,禁止实际 urlopen、模型调用和 systemctl。测试不读取生产 notify-target.json 或 Hermes 配置。不要用修改生产 backend 的方式进行验收。
| 测试组 | 必须断言 |
|---|---|
| 参数 | 所有 5 处 vault 事件保留真实 status,filename 为空、detail 保留正文;watcher 3 个事件字段不变。 |
| 配置 | env > JSON > 默认;显式 CFG backend 传递;none、缺文件、坏 JSON、未知 backend、非法 timeout、缺密钥不发请求。 |
| 协议 | 中文 payload、准确 HMAC 字节与 header、POST/Content-Type、2xx 成功、3xx 不跟随、4xx/5xx 失败。 |
| 降级 | URLError、超时、import 失败、缺脚本、send 意外异常均不传到业务调用方。 |
| 脱敏 | 用唯一假 capability URL 作标记,遍历全部分支;标记不得出现在应用日志、stdout、stderr、异常回显中。 |
| 兼容 | CLI 0/1/2/3 参数默认值和旧 shell 三参数调用;投递失败 exit 0,但 send 返回 False。 |
| 配置重载 | 同一已加载模块中原子换临时配置,下次 send 使用新值;失败加载不会永久缓存。 |
| watcher | import 不启动循环;post_status 调用唯一模块;计算文件 ID 等非通知行为保持原状。 |
本轮实测基线:额外设置 `PYTHONDONTWRITEBYTECODE=1`、临时 `ASR_LOG` / `ASR_CACHE_DIR`、`ASR_NOTIFY=0`、`ASR_GIT_SYNC=0` 后,结果为:
```text
Ran 28 tests in 0.122s
OK
```
新增测试还应验证不同 cwd、未设置 PYTHONPATH 时两个入口的加载方式。统一 bootstrap 必须先于任何 `asrpipe` 导入,避免 unittest discovery 顺序导致单例 CFG 捕获另一个测试模块的环境;测试 backend 开关本身时在 mock transport 内局部解除总开关。
测试命令应在一个由测试运行器统一创建的临时环境下执行:
```bash
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
```
上面命令只是入口,隔离环境由运行器或测试 fixture 事先提供,不能裸命令后假定生产日志也已隔离。最终报告保留真实 `Ran N tests ... OK`,不硬编码最终数量。附加静态检查:`bash -n` 检查旧 shell;编译改动 Python 时把 pycache 定向临时目录;限定目标文件检查无 Hermes 私有配置读取、无旧 route 默认值、签名只保留一份。
doctor 和完整 dry-run 均不列入本项目必需验收:前者有数据库写和联网行为,后者不覆盖通知且会调用模型。若另需全流程集成测试,使用合成录音/转写和隔离状态目录,单独记录目的与副作用。
## 6. 部署与回滚
1. 在隔离副本完成代码和测试,部署前重新检查磁盘、备份、unit 与 drop-in 的实际生效设置;不输出含凭证的整个环境。旧报告中的 active/inactive 和队列为空只是一时快照。
2. 暂停新上传/投递的窗口内,确认 inbox/processing、watcher 子进程及 native/revise 作业没有在处理任务。先排空再停止相关进程;只看到 processing 为空不等于没有入队竞态。
3. 若 processing 已有孤立文件,记录并按现有恢复流程处理,不自动移动回 inbox,避免重复转写。未排空则推迟部署,不使用强制 restart。
4. 部署已验证代码和权限为 0600 的本地配置;首次无真实目标时保持 none,并在交付记录明确“通知尚未恢复”。生产凭证一次性迁移时只复制所需字段,不输出密钥;源文件缺失则不阻塞代码部署。
5. watcher unit 删除旧 `MEETING_WEBHOOK_URL`,保留 `MEETING_INBOX_BASE`、`MEETING_ASR_BACKEND` 和既有回滚注释;检查 drop-in 是否仍覆盖旧变量。daemon-reload 后在空闲窗口启动 watcher。
6. 检查常驻 control_web 是否已加载改动模块,安排在无审核提交、无正在上传的请求及 `.uploading/*.part` 活跃写入时重启以加载新代码;oneshot 下一次启动自然加载。不能假定仅重启 watcher 会更新全部 Python 进程。无需改 review/revise unit 的模型凭证设置。
7. 只读检查目标服务 active、进程入口和脱敏日志;真实消息测试留给 W5。新地址更新只更换独立配置,不再改源码/unit。
回滚按同一空闲流程恢复成套文件(发送脚本、调用方、unit、配置),daemon-reload 并启动相关服务;记录恢复的哈希和权限。不要回滚生产数据库或手工复制运行中 SQLite 来“恢复状态”;本期不改变其 schema。旧版本通知本来可能不可用,回滚恢复的是业务行为,不能宣称修复消息投递。源码备份不得清理原有历史 `.bak.*`。
## 7. 协议文档与通用 agent 接入
`docs/agent-protocol.md` 作为通知和 agent 接入契约来源;HANDOFF 保留管线架构,control-web.md 保留现有 HTTP 接口说明。其他索引链接到这些来源,不再复制整套阶段表。
协议至少写清:send/CLI 参数、配置优先级、送达与静音语义、故障定位、敏感日志规则、真实现有的查询/上传路由,以及“审核回答由人完成,agent 不代填确认”。当前 HTTP 服务没有通用公网认证,不能描述为可直接暴露的 agent API;旧一次性页面与当前任务页的边界须准确。
更新 `OBSIDIAN/CLAUDE.md` 与 `HANDOFF.md` 的通知说明;HANDOFF 仍有“粗稿 commit/push”和旧临时 Web 生命周期描述,核实后标注历史或改为当前行为,保留算法历史数据。`~/.claude/skills/memo-inbox/references/webhook-toolset-diagnostics.md` 标历史归档并指向协议;修改前读该 skill,去重其他引用时保留“先粗整理、人工确认后正式发布”等纪律。修改 skill 必须在报告中列明。
文档同步还必须覆盖下列 v1 漏项:
- `~/.claude/skills/memo-inbox/SKILL.md:51–52,217–221`:替换 active 的 `claude -p → minutes/_drafts` 描述及旧 webhook-agent/toolset 必查入口。
- `~/.claude/skills/memo-inbox/references/meeting-recording-inbox.md`:更新或明确归档旧 `.hermes-meetings`、正文 webhook 和私有配置排障路径。
- `~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md:16–20`:修正粗稿入 Vault、commit/push、Mac 审阅的旧时序,保留该技能独有的人工确认纪律。
首期交付跨 agent 可直接读取的协议,不把“创建 Markdown”冒充“已安装通用 skill”。如另做通用 skill,先使用 skill-creator,做薄入口引用上述契约,不复制业务规则、不读取私有 agent 状态、不授予自动审核或发消息的新权限。
## 8. 后续项目:任务状态与数据库解耦
先盘点 watcher、cli、review_web、control_web 的全部 intake_job 读写,明确哪些是同一服务的内部实现、哪些是真正跨进程契约;不要把所有内部函数调用都 HTTP 化。
优先评估提取唯一 intake store/schema 模块,由本机组件共享,先去重复 DDL;如确需 watcher 进程与控制服务独立部署,再引入版本化内部 API。后者必须先设计注册、状态迁移、幂等键、重复/乱序更新、认证和服务不可用时的持久重试。候选路由是未来设计,不是现有接口。网页服务中断不能新增为转录中断条件;未解决断网状态恢复前不移除 watcher 的当前写入路径。
验收至少覆盖:Web 上传与直接投文件、重复 fid、控制服务暂时不可用、转录/桥接失败、进程重启恢复、草稿/发布状态关联、禁止旧状态覆盖新状态。该项目单独评审和交付,不与首期通知上线绑定。
## 9. 完成定义与执行报告
- 代码完成:唯一发送实现、参数正确、完整脱敏、原调用契约兼容、隔离回归通过。
- 部署完成:已排空并加载新代码,配置权限正确,服务健康,有可执行的成套回滚记录。
- 通知恢复:已核实真实 endpoint/共享密钥,并在获得测试消息发送授权后确认端到端投递;只有 HTTP 2xx 时记录“后端接受”,未确认接收端则不写“用户已收到”。
- 待决策:真实 webhook 地址/密钥来源;是否另开 direct 后端项目。direct 不等价于自动复用 Hermes bot token 的授权。
报告列出改动与备份路径、测试原文、参数及脱敏测试名称、部署窗口、未覆盖项、真实通知状态和 skill 变更。对不满足的门槛明确标为未完成,不用“重构完成”掩盖投递尚未恢复。
@@ -0,0 +1,363 @@
# 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.*` 文件):
```bash
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(改动前请先自行复现,作为对照)
```bash
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` 把第一个位置参数写死成字符串 `"草稿已生成,待确认"`:
```python
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 只是它的一种后端"**。
判据(重构完成后应当成立):
1. Hermes 再次换主机/换端口/换协议时,**只改一个配置文件,不改任何一行 Python、不改任何 systemd unit**。
2. 通知目标不可用时,管线行为与今天一致:**记日志、继续跑、不阻塞、不丢数据**。
3. 通知发送逻辑(HMAC 签名 + POST)在代码库里**只有一份实现**。
4. 管线**不再读取 `~/.hermes/` 下的任何文件**。
5. 上述 4 条对两个调用方(`meeting-watch.py` 和 `asrpipe`)同时成立。
### 1.2 设计风格必须与现有代码一致
这个项目有很清晰的既有风格,重构**必须沿用**而不是另立一套:
- 配置集中在 `asrpipe/config.py` 的 `Config` dataclass,每项都是 `_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`
这是本次重构的**唯一真相源**。结构建议(执行时可按需微调字段名,但必须保持"后端可切换 + 目标可配置"两个语义):
```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(含密钥):
```bash
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 契约一致:
```python
def send(status: str, filename: str = "", detail: str = "") -> bool
```
返回是否送达成功;**内部吞掉所有异常**,绝不向调用方抛(维持"never block the pipeline")。
- `__main__` 分支保持 `argv = (status, filename, detail)` 契约不变——`bin/obsidian-asr-refine.sh:45` 仍以此方式调用它(旧 rollback 链路),**不要破坏该契约**。
### 3.3 验收标准
```bash
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` 和文档都依赖它,不要删。
- `notifier` property 保留(`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):
1. **status 透传测试**(覆盖 §0.4 的 bug):mock 掉实际发送,断言 `vault.notify("ASR 正式记录已发布", "body")` 传出的 status 是 `"ASR 正式记录已发布"`,**不是** `"草稿已生成,待确认"`。这是防止该 bug 回归的关键测试。
2. **降级测试**:目标不可达(mock 抛 `URLError`)时 `vault.notify()` 不抛异常、正常返回。
3. **backend=none 测试**:不产生任何网络调用。
4. **`redact_log` 测试**:`redact_log=True` 时日志中不含 body 原文(防止 capability URL 泄漏进日志)。
### 4.4 验收标准
```bash
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` 删除这一行(第三处硬编码):
```ini
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"的回滚注释,不要动)。
生效并验证:
```bash
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 验收标准
```bash
# 确认 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 决策,不要擅自实施**,因为:
1. 要把 Telegram bot token 复制进本项目自持的凭证文件(或新增一份 600 env 文件)。这扩大了 token 的存放面,是安全决策,须 Kai 同意。
2. 绕过 Hermes 后,Telegram 侧的消息格式/限流/重试不再由 Hermes 统一处理,需自行承担。
3. Kai 可能有意保留"所有对外消息都经 Hermes"的架构约定。
**执行方式**:Stage 3 完成后,在 §7 报告中列出此选项与上述权衡,**等 Kai 明确答复再动手**。届时若 Kai 同意,`direct` 只是 `notify-target.json` 里加一个后端分支 + 一个 sender 函数,改动很小——这正是 Stage 1 接口层带来的收益。
---
## 7. 完成后的回报要求
Stage 1–3 结束后,向 Kai 报告,包含:
1. **改了哪些文件**,每个文件一句话说明,附备份文件名。
2. **测试结果原文**(`Ran N tests ... OK`)。若有任何失败,**如实说明失败项与原因,不要含糊**。
3. **§0.4 的 argv 错位 bug 已修复的证明**:新增测试的名称与断言内容。
4. **仍然待办的事**,明确区分:
- 需要 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 自行清理——**本次未改动,因为不在授权范围**。
5. **动过 `~/.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 答复。
@@ -0,0 +1,98 @@
# ASR 管线 × Agent 耦合点 Review
**日期**:2026-09-18
**背景**:Hermes 网关从 .145 迁移到 .48 后,`meeting-status-notify.py` 因硬编码 `127.0.0.1:8644` 而 Telegram 通知失效(核心转录/整理流程本身不受影响)。这次故障暴露的不是孤立 bug,而是整条管线里 Claude/Hermes 两个 agent 以"硬编码地址/私有配置文件/直接 shell out"方式接入,缺乏稳定接口层。本文档目标:**列出所有耦合点,诊断问题,提出"预留接口 + 通用 skill"的解耦方案**,供下一轮重构使用。不在本文档内直接修改代码。
---
## 1. 涉及文件清单
### 1.1 生产代码(按耦合程度分类)
| 文件 | 耦合对象 | 耦合方式 | 严重度 |
|---|---|---|---|
| `OBSIDIAN/bin/meeting-status-notify.py` | Hermes | 硬编码 `http://127.0.0.1:8644/webhooks/...`;直接读 `~/.hermes/webhook_subscriptions.json` 取 HMAC secret | 🔴 高(本次故障根源) |
| `OBSIDIAN/recordings/bin/meeting-watch.py` | Hermes | `post_webhook()`/`post_status()` **重新实现**了一遍同样的 HMAC 签名 + 硬编码 URL 逻辑(与上面文件重复代码,两处维护) | 🔴 高(重复实现同一故障点) |
| `OBSIDIAN/asr-pipeline/asrpipe/vault.py` (`notify()`) | Hermes(间接) | 不直接硬编码地址,但通过 `CFG.notifier` 固定 `subprocess.run(["python3", meeting-status-notify.py, ...])`,位置参数契约无 schema | 🟡 中(依赖上面两个文件之一才能工作) |
| `OBSIDIAN/asr-pipeline/asrpipe/config.py` (`review_public_host`) | 部署环境 | 默认值硬编码 `192.168.50.145`(本机 IP),可用 env 覆盖 | 🟢 低(有 env 覆盖,且 review-web 确实还在 .145,不是错误,只是"写死默认值"的一贯风格问题) |
| `OBSIDIAN/bin/obsidian-asr-refine.sh`(**已停用**,仅供 rollback) | Claude Code | 直接 shell out `$HOME/.local/bin/claude -p`,依赖交互登录态 | 🟡 中(非活跃路径,但 rollback 会重新引入该耦合) |
| `OBSIDIAN/bin/obsidian-asr-hotwords.py`、`OBSIDIAN/bin/obsidian-asr-intake.sh` | 无(命名遗留) | 调用 `~/bin/hermes-qwen3-asr-smart`——**这是个纯 STT wrapper shell 脚本,跟 Hermes agent 运行时完全无关**,只是历史命名带 "hermes" 前缀,容易被误读为依赖 agent | 🟢 低(认知负担,不是真耦合,建议重命名去混淆) |
| `~/.config/systemd/user/obsidian-recording-watch.service` | Hermes | `Environment=MEETING_WEBHOOK_URL=http://127.0.0.1:8644/...` 在 systemd 层再硬编码一次 | 🔴 高(第三处需要同步修改的地方) |
| `asr-pipeline/asrpipe/llm.py` | Claude(仅模型选择) | 字符串形式引用 `anthropic/claude-sonnet-5` 等模型名做定价/no-temperature 判断 | 🟢 低(纯 API 层配置,非 agent 运行时耦合,正常) |
| `asr-pipeline/asrpipe/cli.py`、`config.py` 文件头注释 | 无 | 明确声明"no Claude Code, no opencode, no agent runtime"——这是**架构上已经做对的部分**,核心管线是纯 HTTPS+文件驱动,值得保留和强化 | 🟢 无耦合(正面案例) |
### 1.2 文档/Skill 层(描述与现状不一致,需要一并清理)
| 文件 | 问题 |
|---|---|
| `~/.claude/skills/memo-inbox/references/webhook-toolset-diagnostics.md` | 描述的是**已被取代的旧架构**:假设有一个"webhook agent"(Hermes)直接处理 `meeting-recording` 路由、需要 `hermes-webhook`/`file`/`skills` toolset、把转录内容发给 Hermes agent 做 memo-inbox 整理。但 `CLAUDE.md` 明确写着"已**取代**旧的 Hermes→Telegram agent 精修",且 `meeting-watch.py` 里 `post_webhook()`(对应 `meeting-recording` 路由,携带正文)从未被实际调用,只有 `post_status()`(状态 ping)在用。**这份 skill 文档描述的故障排查对象已经不存在,但仍在 memo-inbox skill 里被索引,可能误导下一次排障。** |
| `~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md` | 与 `CLAUDE.md`、`HANDOFF.md` 三份文档描述同一条管线,各有侧重但存在信息重复(例如五阶段说明在两份文档里几乎逐字重复)。不是错误,但增加了"改一处忘改另一处"的维护成本——这次 8644 端口就是同一类问题在配置层面的重演。 |
| `OBSIDIAN/asr-pipeline/HANDOFF.md` | 明确写了"Telegram 通知(hermes-gateway :8644 签名 webhook)",是**当前唯一还把 Hermes 网关端口写进设计文档**的地方,本身没错但需要随实现一起更新。 |
| 目前**不存在** `agent-protocol.md`(据 hindsight 记忆,2026-07-17 曾规划把 obsidian skill 拆分出一份 Hermes/Codex/Claude Code 共用的 `agent-protocol.md`,统一七步协议) | 说明"通用协议文档"这个方向此前已经被认可过,但从未真正落地到 ASR 通知这一层,这次故障正好是没有该抽象层的直接后果。 |
---
## 2. 问题诊断
### 2.1 核心问题:通知通道是唯一的"横切关注点",却用点对点硬编码实现
真正跨 Claude/Hermes 边界的交互只有一件事:**管线要把"状态变化"告诉人(Kai),而人在 Telegram 上**。当前实现把这件事等同于"调用 Hermes 网关在某个 IP:port 上开的私有 webhook",导致:
- 通知目标地址在**三个物理位置**各写一份(`meeting-status-notify.py`、`meeting-watch.py`、systemd unit 的 `Environment=`),Hermes 一换主机全部失效,还都要单独改。
- 认证方式依赖读取 **Hermes 自己的私有配置文件** `~/.hermes/webhook_subscriptions.json`,管线对 Hermes 的内部文件结构做了假设——Hermes 的实现细节变了,管线也会跟着坏,而管线本身的 git 仓库里完全看不出这个依赖关系。
- `meeting-watch.py` 里的 `post_webhook`/`post_status` 和 `meeting-status-notify.py` 是**同一逻辑的两份实现**(都做 HMAC 签名 + POST),维护者容易改一处忘改另一处。
### 2.2 次要问题:文档层的"多真相源"
`CLAUDE.md`(基础设施说明)、`HANDOFF.md`(设计交接)、`asr-refine-pipeline.md`(skill 引用)三份文档对同一条管线各写一遍架构图和五阶段表格,`webhook-toolset-diagnostics.md` 甚至在描述一个已经退役的架构分支。这不是本次故障的直接原因,但预示着**如果解耦时只改代码不改文档索引,同样的"多处不同步"问题会在文档层重演**。
### 2.3 正面案例,值得保留的设计
- `asrpipe/` 核心包(S0-S4 五阶段 + fidelity gate + termbank)本身**已经是纯 HTTPS + 文件驱动**,不依赖任何 agent CLI 或登录态,`cli.py`/`config.py` 头部注释明确声明了这个边界——这是解耦该学的样子。
- `asr-review-web`/`control_web.py` 是一个独立 HTTP 服务,路由/状态机/上传都通过标准 HTTP 暴露,没有假设调用方是谁——**这个"HTTP 接口面"是天然可以承载 Claude/Hermes 通用交互的地方**,不需要另起炉灶。
---
## 3. 解耦方案建议(预留接口 + 通用 skill)
### 3.1 接口层:把"通知"做成可插拔的抽象,而不是"调用 Hermes"
建议参考 `config.py` 里其余配置项的风格(`use_termbank`/`use_hindsight` 等 boolean 开关 + env 覆盖),把通知也做成同一等级的可插拔能力:
- 在 `asrpipe/config.py` 增加 `notify_backend`(如 `webhook` / `none` / 未来的 `stdout`/`file` 等),而不是隐式认定"notifier = 某个写死路径的 python 脚本"。
- `meeting-status-notify.py` 改造成**唯一**的通知发送实现(`meeting-watch.py` 不再自己重新实现 HMAC 签名,改为 shell out 到同一份脚本,或提取成共享库函数),并且:
- 目标地址、密钥来源全部走 env / 独立配置文件(例如 `OBSIDIAN/bin/notify-target.json`),**不直接读 `~/.hermes/*`**——管线自己的配置与 Hermes 的私有配置解耦,Hermes 换主机/换协议时只改这一份 target 配置,不改代码。
- 把"目标是 Hermes 网关的 webhook"这件事,退化为该抽象接口的**一种实现**,而不是唯一实现——未来即使 Hermes 换成完全不同的通知方式(比如直接调 Telegram Bot API),也只是换一个 backend,管线代码不用动。
### 3.2 协议层:让 Claude 和 Hermes 通过同一份"通用 skill/协议文档"交互,而不是各自看各的私有细节
现状是 Hermes 一侧靠私有 webhook 配置、Claude 一侧靠读 `CLAUDE.md`/`HANDOFF.md`,两边对同一条管线的认知来自不同文档,容易出现你这次遇到的情况——迁移动作只在 Hermes 侧发生,ASR 侧的假设没人同步更新。
建议:
1. **落地此前已经规划过的 `agent-protocol.md`**(hindsight 记忆里 2026-07-17 就提出过这个方向,但目前尚未创建)。把"任何 agent(Hermes/Claude Code/Codex)如何得知 ASR 管线的状态变化、如何触发人工确认、通知目标怎么配置"写成一份三方共用的协议文档,而不是分散在 `CLAUDE.md` + `HANDOFF.md` + memo-inbox skill 引用里各写一份。
2. 把 `control_web.py` 已有的 HTTP 接口面正式定义为**唯一**对外交互入口:任何 agent 需要查询任务状态/触发处理,都走这个 HTTP API,而不是直接读 sqlite(`intake.db`)或直接改文件。目前 `meeting-watch.py` 直接读写 `CONTROL_DB`(sqlite),这本身也是一种"绕过接口层直连内部状态"的耦合,建议后续统一收敛到 `control_web.py` 的 API 之后。
3. 清理 `webhook-toolset-diagnostics.md`:要么标注为"历史架构,已废弃于 2026-07-25",要么直接删除/归档,避免下次排障时被过时描述误导。
4. `CLAUDE.md` / `HANDOFF.md` / `asr-refine-pipeline.md` 三份重叠内容建议收敛为"一份权威来源 + 其余引用链接"的结构(类似 vault 侧 `AGENTS.md` 作为 SOP 索引、具体细则各自一份的模式),而不是三份都写一遍五阶段表格。
### 3.3 命名去混淆
`~/bin/hermes-qwen3-asr-smart` 建议评估是否重命名(例如 `qwen3-asr-transcribe-wrapper`)——它不依赖 Hermes agent 运行时,纯粹是历史命名,留着容易让人(或下一轮重构的 LLM)误判为真实耦合点而做无用功。
---
## 4. 优先级建议
| 优先级 | 事项 | 原因 |
|---|---|---|
| P0 | 通知 backend 抽象化 + 三处硬编码收敛为一处配置 | 直接对应本次故障,且是唯一真正跨 agent 边界的运行时依赖 |
| P1 | 落地 `agent-protocol.md`,清理 `webhook-toolset-diagnostics.md` 等过时文档 | 防止"文档多真相源"重演同类故障 |
| P2 | `meeting-watch.py` 收敛直连 sqlite 为走 `control_web.py` API | 减少内部状态的多点直连,但当前未造成故障,可延后 |
| P3 | `hermes-qwen3-asr-smart` 重命名 | 纯认知成本,无功能风险,可随手做 |
---
## 5. 明确不建议做的事
- 不建议把核心 `asrpipe/` 五阶段管线本身引入任何 agent 依赖——它当前"纯 HTTPS + 文件驱动"的设计是对的,解耦应该聚焦在通知/协议这一层,不要往核心管线里加 Claude/Hermes 相关代码。
- 不建议为了解耦而过度设计一套通用消息总线/插件系统——当前只有"一个通知需求 + 一个 HTTP 状态查询需求",用简单的 backend 开关 + 现有 HTTP 接口面就够,避免过度工程化。
@@ -0,0 +1,130 @@
# ASR Pipeline v2
## Goal
The pipeline recovers useful meeting facts and traces rather than pretending that
speech can always be reconstructed word for word. The original transcript remains
the audit source. A draft may omit low-value repetition, but must not invent a
subject, number, decision, or relationship.
## Stages
1. S0 parses speaker turns and collapses decoder loops.
2. S1 generates candidates from the untouched transcript. Phonetics, observed ASR
errors, aliases, entity relationships, and recency are independent signals.
3. S2 only removes fillers and repairs punctuation. It receives no Hindsight context
and makes no entity decisions.
4. S3 applies settled mappings deterministically. Flash Lite sees only unresolved
candidates and Chinese-number spans.
5. S4 extracts source-labelled evidence cards per chunk.
6. S5 Sonnet groups those cards by topic and writes a rough draft.
## Personal fact store
`state/lexicon.db` is intentionally reusable outside ASR:
- `entity`, `alias`, `correction`: identity and observed surface forms.
- `relation`: person/company/project relations with source, confidence, and dates.
- `interaction`: contact recency and frequency.
- `observation`: what a transcript emitted and how it was resolved.
- `phonetic_confusion`: confirmed personal accent/ASR confusion pairs.
- `review_question`: transport-independent human review queue.
- `source_document`: incremental indexing state for historical minutes.
Obsidian remains a curated source. Hindsight supplies broad recall. SQLite holds
small deterministic facts that applications can safely query.
## Human review loop
The pipeline never blocks waiting for an answer. Ambiguous high-impact entity
candidates are queued and the draft keeps an uncertainty marker.
```bash
bin/asr-refine review pending
bin/asr-refine review answer 12 万亚坤
```
A confirmed candidate is learned as an alias, correction, and observation. The
candidate-pool cache includes the database timestamp, so the next recording uses it
immediately.
Pi, Telegram, and the web UI are thin transports over this queue:
1. Read pending rows and display the question, source sentence, and choices.
2. Submit `question_id + answer`.
3. Call the same `review.answer()` function or CLI command.
4. Optionally request a replay of S3-S5 for the affected recording.
The production web transport is a persistent mobile control plane:
```bash
bin/asr-refine review serve # normally started by systemd
bin/asr-refine review state # print the active one-time URL
```
- Every generated draft is a Web delivery, even when entity candidate detection
finds no ambiguity. Review order is: inferred call background (with a corrected
background field that requests a redraft), unnamed voiceprints, the draft's
`## 待你确认` list, then one overall review item.
- It listens on `192.168.50.145:8765`. `/` accepts streamed audio uploads,
shows recent job state, and atomically hands completed files to the existing
recording watcher. `/hook/intake` exposes the same raw-upload contract for a
future reverse proxy or external uploader.
- Telegram receives a 256-bit capability URL. The token exists only in Telegram and
the mode-0600 runtime state file; it is redacted from service/request logs.
- The page exposes only one queued question at a time: candidate buttons, an
explicit "无法确认", a short custom answer, and an expandable full-draft view.
- Rough drafts live under `state/drafts/`, outside the Vault and Git. After all
questions are answered, an auditable feedback block is saved privately.
`asr-review-revise@.service` applies those answers with Sonnet, validates the
formal schema/Summary/provenance, then writes and pushes exactly one formal note
under `minutes/`. The private reviewed source moves to `state/published/`.
- A confirmed unnamed `speaker_N` is written to `voiceprint_speakers`, marked for
retry by the daily enrollment job, and immediately submitted to the persistent
`:10099/voiceprints/enroll` API.
- SQLite records one `entity_mention` per entity per formal minute. When the current
minute would bring an uncarded person/company to two distinct minutes, Web offers
a conservative card scaffold. No card or judgment field is created without an
explicit human choice.
- Upload metadata (title and optional user-supplied background) is kept separate
from the acoustic transcript. The title survives into the task slug; the
background is explicitly labelled as user-supplied context for drafting.
- `asr-review-web.service` is enabled at boot and restarts on failure. A successful
private draft ensures it is running and sends its stable review URL through
Telegram. `dry-run` never queues questions.
The service unit lives at `deploy/asr-review-web.service`. Telegram may later gain
inline choice buttons, but neither transport owns facts or writes SQLite directly.
## Confidence policy
- Confirmed: voiceprint, explicit human mapping, or multiple independent strong
signals.
- Probable: context and relation graph agree, but identity is inferred.
- Pending: weak or conflicting evidence, including numbers whose type/unit is not
clear.
Only confirmed facts become deterministic aliases automatically.
## Graph and recency
```bash
bin/asr-refine graph sync
bin/asr-refine graph stats
```
The graph incrementally scans finalized minutes, imports person/company/project
frontmatter and known wikilinks, records meeting interactions, creates low-confidence
co-occurrence edges, and promotes explicit linked relations such as 投资/负责/合作 at a
higher confidence. Deleted or changed notes remove only facts owned by that source.
Candidate context weight combines relation confidence, repeated support, last contact,
and a 45-day half-life. It influences ranking but cannot activate the personal
phonetic-confusion channel outside entities already in the current graph context.
## Numbers
The deterministic number pass classifies percentage, date, money, quantity, and
project/code speech. Every decision records raw value, normalized value, type,
confidence, source span, and nearby context. Ambiguous forms such as `两万五九份`
remain unchanged and are passed to the uncertainty stage instead of being guessed.
@@ -0,0 +1,58 @@
# 录音上传与审核工作台
## 入口
- Web:`http://192.168.50.145:8765/`
- 健康检查:`GET /health`
- 最近任务:`GET /api/jobs`
- 人工确认:`GET /review/`
服务只面向 LAN 与 tailnet,没有实现公网身份认证。未来暴露到公网时,应在反向代理层
增加认证、上传大小限制和请求速率限制,不要直接转发当前端口。
## 工作流
1. 浏览器把录音以原始二进制流上传到隐藏临时目录。
2. 文件完整写入并 `fsync` 后,原子移动到 `recordings/inbox/`。
3. 既有 `obsidian-recording-watch.service` 接管转写、CAM++ 分角色和声纹识别。
4. 转写落入 `.asr-inbox/`,独立整理程序生成私有草稿。
5. Telegram 发送确认链接;Web 回答写入 SQLite。
6. 全部确认后生成正式纪要并写入 Obsidian。
上传时填写的背景单独保存在任务元数据中,进入整理提示时会标为“用户提供的背景”,
不会混入或伪装成 ASR 转写内容。
## Hook
`POST /hook/intake` 与 Web 上传使用相同的流式协议,便于以后在外网部署一个很薄的上传
前端或反向代理:
```bash
curl --fail-with-body \
-X POST \
-H 'Content-Type: application/octet-stream' \
-H 'X-Filename: call.m4a' \
-H 'X-Title: 项目通话' \
-H 'X-Context: 参与人、项目和希望重点确认的事项' \
--data-binary @call.m4a \
http://192.168.50.145:8765/hook/intake
```
响应中的 `job_id` 是任务标识。状态可从 `/api/jobs` 查询。
## 运行
```bash
systemctl --user status asr-review-web.service
systemctl --user restart asr-review-web.service
journalctl --user -u asr-review-web.service -n 100 --no-pager
curl -fsS http://127.0.0.1:8765/health
```
持久数据:
- `asr-pipeline/state/intake.db`:上传任务与状态。
- `recordings/state/uploads/`:上传标题、背景和任务关联。
- `recordings/inbox/.uploading/`:未完成上传;正常完成后为空。
默认单文件上限为 4 GiB,可通过 `ASR_UPLOAD_MAX_BYTES` 调整。
@@ -0,0 +1,80 @@
# 当前流程与独立项目交接
核对日期:2026-11-02,Asia/Shanghai。
## 已验证
原代码:`/home/claw/claude-workspace/OBSIDIAN/asr-pipeline`。
原音频编排:`/home/claw/claude-workspace/OBSIDIAN/recordings/bin`。
原通知脚本:`/home/claw/claude-workspace/OBSIDIAN/bin/meeting-status-notify.py`。
本机 systemd:
| 单元 | 本次状态 | 职责 |
|---|---|---|
| asr-review-web.service | running | 8765 常驻上传、最近任务、审核界面 |
| obsidian-recording-watch.service | running | 监听录音并请求外部 ASR |
| obsidian-asr-refine.path | waiting | 监听 vault/.asr-inbox |
| obsidian-asr-refine-native.service | inactive | 按需整理转写,当前未运行 |
| asr-review-revise@.service | 模板 | 人工确认后生成/发布 |
`GET http://127.0.0.1:8765/health` 返回 `ok=true, pending=3`。
这说明审核队列存在待确认项,不证明三个录音都在推理,也不证明通知正常。
本轮不导出待确认正文,不生成新稿、不注册声纹、不写 vault。
## 代码确认的链路
- 监听单元显式选择 `MEETING_ASR_BACKEND=qwen3cam`。
- 对应脚本默认请求 `http://192.168.50.100:10099/asr`,整文件上传,
`language=Chinese`、`save_voiceprint_session=true`;服务内部切片。
本轮未对 GPU 服务发起真实录音测试,不据此宣称其健康或当前镜像版本。
- 原始转写保留声纹 session 与 speaker;归档后桥接到 vault/.asr-inbox。
- 独立 Python 管线包含模糊音/别名、关系与近期权重、数字归一、纯清洗与纠错分阶段。
- 初稿和中间状态在程序私有目录,正式纪要等待 Web 审核通过。
- 人物/公司卡提议与声纹确认存在代码路径;不等于当前外部注册服务验证通过。
## 运行数据边界
- `asr-pipeline/state/lexicon.db`:人工确认、关系、纠错、审核队列,不能重建后替代。
- `asr-pipeline/state/intake.db`:上传任务与状态。
- `state/drafts/`、`state/published/`:私有稿与发布回执。
- `recordings/{inbox,processing,processed,failed,transcripts,state}`:音频及编排状态。
- `vault/.asr-inbox/.state`:整理处理标记,切换漏迁可能造成重复处理。
- `.asr-refine.env`:私有凭据,未读取值、未复制、未入 Git。
本次只提取源代码、专业词库、现有测试、文档、unit 快照。没有复制生产音频、
个人 SQLite、缓存、会话、凭据或 `.venv`。历史设计文档有过时路径和模型,保留作来源。
## 已发现的风险(尚未改生产)
1. 通知脚本默认 `127.0.0.1:8644` 和 `~/.hermes/webhook_subscriptions.json`。
结合 Hermes 已迁走的资产记录,这是通知失效风险;本次未验证远端 webhook。
2. 本仓库代码的模型缺省已是 LiteLLM(清洗 `aistudio/gemini-flash-lite-latest`,成稿 `codex/gpt-sol-latest`),不是 ZenMux,也不是 Sonnet 5。生产 unit 若仍加载旧 `.asr-refine.env`,以那个文件为准;本次未读取凭据,不把代码缺省当成现网正在用的路由。
3. 路径耦合旧工作目录;本项目配置根目录、通知器、监听数据库已可外置,
但声纹地址、审核 systemd 单元名等仍有历史耦合。`deploy/systemd/` 保持现网快照,没有改 ExecStart。
4. 历史 HANDOFF 中写的是一次性页面。代码里有两个常驻界面:旧 `asrpipe.control_web`(快照单元,默认 8765)和新 `asrservice.web`(compose,默认 8788)。旧文档不作现态。
5. 产物删除和在线编辑会拒绝允许根以外的路径。发布到 `minutes/`、`People/`、`Companies/` 仍没有对照 vault SOP 的不可绕过白名单。本轮不冒充已审计完成。
## 代码已修复、生产未切换
下面这些是仓库里的新行为。现网单元没有改,不能当成已经在 8765/8788 的生产进程上生效。
- 未设置 `ASR_WEB_PASSWORD` 时,两个浏览器界面都拒绝上传、审核和改配置。临时放开只认 `ASR_WEB_ALLOW_ANON=1`。
- 登录后的写操作校验 CSRF。设置页不回显内网地址和密钥片段。
- `asrservice.worker` 与 `recordings/bin/meeting-watch.py` 抢同一把 `consumer.lock`。
- `ASR_GIT_SYNC`、`ASR_NOTIFY` 缺省为关。
- 修订失败会保留队列标记并重试;重新精修失败不会先删掉已回答的审核记录。
- 时间在界面上按 Asia/Shanghai 显示。词库列表按页读取,不再一次只截断前 180 条却不说明总数。
## 后续安全切换
1. 先确定模型路由与通知适配器,检查 vault SOP 与正式输出路径。
2. 建独立 venv、冻结依赖,使用合成数据跑回归,再选真实录音单测。
3. 停接新上传,确认没有转写/整理/审核发布在运行,再停相关 user units。
4. 对 SQLite 使用 backup API 生成一致性备份,保留原件;迁移录音状态与 inbox 标记,
不能对运行中数据库只复制主文件。
5. 路径变更还涉及数据库内草稿/转写绝对路径;要审计和映射,不能仅改 unit。
6. 生成新 unit 并逐个启用,确认只存在一个消费者;保留旧代码和备份以便回滚。
7. 验证上传 → 转写 → 确认链接 → 审核 → 正式纪要 → Git 同步完整链路后再移除旧目录。
本轮未执行上述生产切换,不承诺已独立上线。
@@ -0,0 +1,55 @@
# 独立 WebUI 项目路线
## 定位
独立的会议、电话录音处理应用。WebUI 是主要操作界面,Telegram 只做可选通知,
不依附任何 Agent CLI。当前源码已抽出,但仍是旧实现基线,尚未独立部署上线。
## 目标架构
浏览器 → 应用 API → 持久化任务队列 → 后台 Worker → 外部 ASR / LLM / Hindsight。
SQLite 保存任务、处理阶段、审核、实体词库和修改历史;原音频、中间产物单独保存。
Obsidian 是可选发布目标,不是应用内部的任务队列或运行数据库。
ASR GPU 服务继续位于 unRaid,模型调用优先接入统一 LiteLLM。
应用状态、配置和存储必须通过环境配置,不绑定特定机器或个人工作区。
## 核心页面
1. 任务列表:上传、背景输入、进度、失败原因、重试、最近任务。
2. 任务详情:原音频播放、带说话人与时间轴的原文、处理版本、来源。
3. 审核:背景确认、候选词、说话人身份、关键歧义、最终纪要确认。
4. 词库:实体、别名、模糊音规则、关系和人工确认历史。
5. 设置:ASR、LLM、Hindsight、通知、输出路径和数据保留策略。
## 实施顺序
### 第一阶段:独立运行闭环
- 配置与运行数据分离;迁移个人词库、审核状态时使用一致性备份。
- 将 systemd 路径监听/模板任务改为应用内部持久化队列与 Worker。
- 实现任务互斥、幂等、重启恢复、超时和阶段重试,不重跑已确认步骤。
- 修复独立通知适配器,停止依赖本机 Hermes 状态文件。
- 对照现有 vault SOP,审计输出路径;只有确认后的正式稿可发布。
- 上传、转写、审核、发布全链路通过后,再切换生产消费者。
### 第二阶段:WebUI 完整化
- 保留现有纯 Python 页面作过渡,先明确 API 和状态模型,再选前端框架。
- 增加播放器、原文对照、编辑、版本回退与清晰的阶段状态。
- 把说话人确认、词库维护及上下文确认放入一致的任务工作流。
### 第三阶段:Docker 发布
- API/WebUI 和 Worker 可分别运行;持久卷承载 SQLite 与音频。
- 不在容器内运行 systemd;不依赖宿主 Agent home。
- Obsidian 发布器可持有专用 Git checkout,不直接修改主工作副本。
- 确定 Dockerfile 与验收测试后,再接 GitHub Actions + GHCR 构建发布。
## 验收原则
原文不可覆盖;候选纠错须有依据;背景不伪装为录音事实;重要歧义交人工确认。
以关键内容和行动线索为目标,不追求伪精确的逐字恢复。
合成测试与真实录音端到端验收分开,不能以单元测试通过代替上线验收。
本文件是后续开发计划,本轮不新增推理请求、不切换生产服务、不自动部署容器。