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
@@ -35,6 +35,9 @@ HINDSIGHT_BANK=personal-context
ASR_GIT_SYNC=0
# Web & Notifications
# ASR_WEB_PASSWORD is required. An empty value closes the browser UI.
# ASR_WEB_ALLOW_ANON=1 opens it with no password; use only on a process that is not reachable.
# ASR_WEB_COOKIE_SECURE=1 when the site is served over HTTPS.
ASR_PUBLIC_URL=http://192.168.50.49:8788
ASR_REVIEW_PUBLIC_HOST=192.168.50.145
ASR_REVIEW_PORT=8788
+17 -1
View File
@@ -1,4 +1,20 @@
.venv/
.env
seed/*.db
.env.*
!.env.example
__pycache__/
*.pyc
*.egg-info/
build/
dist/
state/
cache/
logs/
dry-run/
*.db
*.db-*
*.sqlite*
*.bak*
recordings/*
!recordings/bin/
recordings/bin/__pycache__/
+22
View File
@@ -0,0 +1,22 @@
# Meeting ASR
这是独立的会议、电话录音转录与纪要项目,不是 Agent 插件。先读 README.md 和
docs/current-state.md。历史 HANDOFF.md 只作设计背景,不能当成现态。
## 边界
- ASR GPU 推理由 unRaid 外部服务提供;本项目负责上传、编排、纠错、审核与发布。
- 不依赖 Claude/Codex/Hermes CLI。Hermes 仅是现有通知适配器,可独立替换。
- 原音频、转写、SQLite、审核答案和凭据属于私有运行数据,不得提交到 Git。
- 人工确认的别名和声纹身份不可猜测覆盖;更换 SQLite 前必须做一致性备份。
- 原文是证据,Hindsight 是背景;不能把记忆中的事实伪装成录音中说过的话。
- 纪要以关键内容、结论、线索和行动项为目标,不追求逐字还原,不补造金额或身份。
- 候选词从原始转写提取,去口语化与纠错分开;含糊项保留,交人工确认。
- 只有审核通过的正式纪要可进入 Obsidian。发布遵循 vault 自己的 AGENTS.md/SOP。
- 当前写入边界尚未重新审计;不得宣称已实现不可绕过的目录白名单。
- 拆分后的项目尚未替换生产服务。禁止启动第二个监听器消费相同录音目录。
## 验证
使用独立临时目录运行 `python -m unittest discover -s tests -v`;不得拿生产词库做测试。
测试期间关闭通知、Git 写入和外部推理。模型更新、真实录音测试和服务切换分别验收。
+300
View File
@@ -0,0 +1,300 @@
# ASR 后处理管线 — 项目方案与交接状态
最后更新:2026-07-26(会话结束时移交)
---
## 0. 一句话现状
独立 Python 程序已上线并接管 systemd,**召回问题已定位并大幅修复(1/13 → 12/13)**,
**结构归拢已生效**;但 Kai 指出的更根本的 6 项设计缺陷(S2 职责过载、背景重复、
数字处理、音通道过窄、缺关系图谱、纪要目标定位)**只完成方案设计,未实现**。
下一个接手的模型应从「第 4 节 待实施方案」开始。
---
## 1. 项目路径
| 用途 | 路径 |
|---|---|
| **生产程序(主)** | `/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/` |
| 入口 | `asr-pipeline/bin/asr-refine`(自带 `.venv`,依赖仅 pypinyin / rapidfuzz / numpy) |
| 代码包 | `asr-pipeline/asrpipe/*.py` |
| 词库(SQLite) | `asr-pipeline/state/lexicon.db` |
| 术语库 | `asr-pipeline/termbank/terms.json`(27.7k) + `allowlist.txt`(9.5k) |
| 缓存(可删) | `asr-pipeline/cache/` |
| dry-run 产物 | `asr-pipeline/dry-run/<slug>/{cleaned.txt,draft.md,report.json}` |
| **评测实验室** | `/home/claw/claude-workspace/OBSIDIAN/asr-lab/`(12 篇 fixture + 11 篇人工定稿 + `REPORT.md`) |
| fixture | `asr-lab/fixtures/<name>/{raw.txt,final.md,draft_baseline.md}` |
| 转写落地(触发点) | `~/obsidian-vault/.asr-inbox/<slug>.txt`(gitignored) |
| 转写持久归档 | `OBSIDIAN/recordings/transcripts/<fid>-<datetime>.txt` |
| 私有粗稿 | `asr-pipeline/state/drafts/`(不进 Vault/Git) |
| 正式输出 | Web 全部确认后直接写 `~/obsidian-vault/minutes/` |
| 术语表(**只读**) | `~/obsidian-vault/minutes/_glossary.md` |
| 人物/公司卡(**只读**) | `~/obsidian-vault/People/*.md`、`Companies/*.md` |
| 日志 | `OBSIDIAN/logs/asr-pipeline.log` |
| 凭证(600) | `OBSIDIAN/bin/.asr-refine.env`(`ANTHROPIC_AUTH_TOKEN=` ZenMux key) |
| systemd 触发 | `~/.config/systemd/user/obsidian-asr-refine.path` → `obsidian-asr-refine-native.service` |
| 旧链路(保留可回退) | `OBSIDIAN/bin/obsidian-asr-refine.sh` + `obsidian-asr-refine.service` |
| skill 文档 | `~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md` |
### 常用命令
```bash
P=~/claude-workspace/OBSIDIAN/asr-pipeline/bin/asr-refine
$P doctor # 路径/凭证/词库/Hindsight/两个模型 全检
$P dry-run <file.txt> # 全流程,不写 vault、不 commit
$P run-all # 处理 .asr-inbox 中所有未处理转写
$P lexicon stats|sync|show|learn <别名> <规范名> --note "…"
$P build-termbank # 重建术语库(联网)
```
回退到旧 `claude -p` 链路:
```bash
sed -i 's/^Unit=.*/Unit=obsidian-asr-refine.service/' ~/.config/systemd/user/obsidian-asr-refine.path
systemctl --user daemon-reload && systemctl --user restart obsidian-asr-refine.path
```
---
## 2. 当前架构(已实现)
```
① 音频 → recordings/inbox/ obsidian-recording-watch.service
② meeting-watch.py → asr-transcribe.sh MEATING_ASR_BACKEND=qwen3cam
= :10099 Qwen3-ASR-1.7B(vLLM) + FSMN-VAD + ForcedAligner-0.6B + CAM++ + 声纹(11人)
输出 【谭凯】…(声纹已命名,身份可信)/【speaker_N】…(未命名)
※ FunASR 不是独立后端,只是该服务内部 VAD/hotword 组件
③ 桥接为 ~/obsidian-vault/.asr-inbox/<date>-<HHMMSS>录音.txt(原子 mv)
④ path unit → obsidian-asr-refine-native.service → asr-refine run-all
⑤ 五阶段(见下)→ state/drafts/<slug>.md
⑥ git commit+push → 更新 _待确认.md → Telegram 通知(hermes-gateway :8644 签名 webhook)
⑦ Kai 在一次性 Web 页复核 → Sonnet 应用确认 → 直接发布正式 minutes/
```
### 五阶段
| 阶段 | 模块 | 内容 | 成本 |
|---|---|---|---|
| S0 | `segment.py` | 退化噪声折叠 + 说话人感知分片(1800/200 overlap) | 免费 |
| S1c | `refine.keyword_recall` | LLM 抽专有名词 → Hindsight recall | 1 次便宜调用 |
| S1 | `flagger.py` | 候选生成:音近 + 别名 + 变体 + 关系支持 | 免费(4~13s) |
| S2 | `refine.clean_chunks` | 去口语化 **+ 候选裁决**(并发 6) | N 次便宜调用 |
| S3 | `fidelity.py` | 确定性保真闸门 → 失败则严格重试 → 再失败保留原文 | 免费 |
| S4 | `refine.write_draft` | 一次强模型调用,结构化 + 出处标注 | 1 次调用 |
模型(实测选型,勿随意改):
- 清洗 `google/gemini-3.5-flash-lite`。对比 `claude-haiku-4.5`:闸门失败 19/87 vs 78/87,
haiku 有 69 次是**过度压缩**(当摘要器,删掉 AUTOIMMUNE/ADCS/CD),且慢 2×、贵 2.9×。
- 成稿 `anthropic/claude-sonnet-5`。
- 端点 `https://zenmux.ai/api/v1/chat/completions`;并发 12 触发 429、6 稳定;
`temperature` 被 sonnet-5/opus-5 拒(客户端已自愈并记忆);HTTP 402 是瞬时的(按 max_tokens 预留额度)。
---
## 3. 本轮已完成(可直接使用)
### 3.1 诊断结论(重要,别重复走弯路)
Kai 反馈"纠错太次",实测复现 `2026-07-21-160435录音`:
```
8 个规范实体全部 in_play=True,池子里也都有,13 个口语面形只有 1 个产出候选
```
**不是召回/推断能力问题,是匹配器把答案扔了。** 三条硬规则造成:
1. `if span == cand.form: return` — 精确别名被当"已正确"丢弃。`万亚坤` 卡片写着 `aliases:["老万"]`,`迈威生物` 写着 `["迈威"]`,全被吃掉。
2. `abs(len(span)-len(cand.form)) > 1` — 简称永远匹配不上全名(麦威↔迈威生物、洛奇↔洛启生物、保山↔宝山基金)。
3. 只做等长整串 `seq_score`,无子串/称谓形态(光辉↔李光辉、张泰海↔张成海)。
另有第 4 条:`in_play` 里的名字若池子里没有 Cand(宝山基金无公司卡,仅靠纪要**文件名**进 in_play)则永不可匹配;且 in_play 被文件名/日期污染 25/70。
### 3.2 已实现的修复
**`lexicon.py`(新)** — SQLite 词库。设计铁律:**Obsidian 只读**,不回写 `_glossary.md`、不改卡片;
学到的别名/纠错单独沉淀(类比声纹库)。选 SQLite 而非 Hindsight 的理由:别名解析要确定性精确查表,
Hindsight 是 LLM 抽取 + 语义召回(1.4s/次,概率性),两者互补 —— lexicon 定身份,Hindsight 供语境。
```
entity(canonical, kind, company, role, related, card) 251 条(95 有归属,70 有关系)
alias(alias, canonical, kind, origin, confirmed, hits, note) 210 条(82 卡片 + 128 学习/术语表)
correction(surface, canonical, n, confirmed, note) 158 条
meta(vault_sig, synced_at)
```
- 从卡片 frontmatter 拉 `aliases/company/role/related_people`
- 导入 `_glossary.md` 153 条,**保留一对多语境映射**(`许博→许俊彦/许恒皓`、`徐总→徐劲前/邹洵`)
- 已录入 Kai 本轮 12 条确认(老尚→单永强、麦威→迈威生物、张泰海→张成海 等)
- 坑已修:`_fm` 用 `[ \t]*` 而非 `\s*`(`\s` 吃换行,空 `role:` 会把下一行 `status: active` 当值)
**`flagger.py` 重构** — 变体展开 + 多通道匹配:
- `variants()`:机构简称(org_head)、去姓称名、称谓(老X/小X/X总/X博/X哥/X姐/X老师/X董,`博=博士` 有 glossary 三例佐证)
- 每条候选带 `rule`(推导方式)+ `owner`(任职)+ `support`(`X(本次在谈)` / `X(近期相关)`)
- `RULE_MIN` 分规则阈值;`anchor` 结构锁定:称谓字必须**字面相同**,只有名字音节可漂
(否则 0.82 阈值等于"一个音节对上就行",产生 36 条垃圾:想谈→谭凯、理解→李光辉、就动→刘冀)
- `one-off`:等长、n-1 字相同、**首字必须相同**、且只对 canonical(不对别名 —— 否则 `林老板` 会让 我老板/跟老板/那老板 全指向邹洵,18 条垃圾)
- `consistency`:文档内一致性传播,目标已确立后其他写法放宽到 0.80,但 `<0.90 时首字必须相同`
- in_play 实体化(补 Cand)+ 去文件名/日期污染 + 去重(`迈威` 既是别名又被材料化 → 两个竞争候选)
- 一对多别名**全部**加入(原来只取 `targets[0]`,把 `徐总→邹洵` 丢了)
- 平局时优先保留 alias 出处(`万金→万津` 两条都 1.0,丢了 alias 就降级成猜测)
- `settled` 判定 + `render_for_prompt` A/B 分组:A=已确认唯一映射(必须替换),B=待判断
- 性能:rapidfuzz 预筛一致性通道,最慢 24.5s → 12.9s
**`prompts.py`**:JUDGE_RULES 说明各 rule 含义与证据强度;DRAFT_PROMPT 加**按议题归拢**要求
(结论→支撑事实→待办,单节 ≤7 bullet,禁止平铺)+ 反过度回避条款(能确定就写全名,别写"身份不确定")。
**`refine.py`**:S1c 移到 S1 **之前**,keyword_recall 的实体名回灌 `extra_in_play`
(原来只进背景文本,从不进候选空间 —— 无说话人标签的转写因此几乎没候选)。
**`cli.py`**:新增 `lexicon sync|stats|learn|show`;`doctor` 增加 lexicon 检查。
### 3.3 效果实测
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 07-21 目标面形召回 | 1/13 | **12/13**(仅 `趟鬼` 未召回,Kai 本人也未给答案) |
| flags 总数 / 密度 | 10 | 34(12 篇 fixture 合计 400,10.5/万字) |
| S1 耗时(最慢) | 4.7s | 12.9s |
| 草稿结构 | 平铺 bullet | 5 议题节,结论→事实→待办 |
| 保真 | — | num/latin/term = 1.00,gate_fail 0 |
`老万` 现在带交叉证据:`万亚坤(人名·已确认别名,洛启生物(本次在谈)、实控人)`。
---
## 4. 待实施方案(Kai 2026-07-26 给出的 8 点,**核心工作在这里**)
### 4.1 已量化的三条症状
| Kai 判断 | 实测证据 |
|---|---|
| 去口语化 input 太复杂,flash-lite 兜不住 | S2 单片提示词 4380 字,**正文仅占 37%**;候选块 1566 字(36%)比规则本身大 3 倍;指令:正文 = 1.7:1 |
| 召回事实重复 | context 7 条长行中 **3 对高重叠**,一对 Jaccard=**1.00**;2081 字先砍到 678 字,**砍之前没去重**,预算全买重复 |
| 数字处理 | 07-21 篇中文数字串 15 个:`两千/万十/十两/两两/一十亿`;另一篇 91 个。不只中→阿拉伯,还有**口吃混入数字**和**量级歧义**("两千"=2000 还是 2千万,草稿自己在问) |
### 4.2 【P1】S2 拆成两步(Kai 第 8 点)— 最高优先
现在 S2 一次干四件事:去口语 + 候选裁决 + 消化背景 + 满足闸门。**这是一个已复现 bug 的根因**:
严格重试提示词写「专业术语必须**原样出现**」,把已确认的实体替换又还原了。
证据:单片隔离测试 A 组替换成功(张泰海→张成海、老尚→单永强),但完整 run(`retried=2`)
`cleaned.txt` 里 张泰海/老尚/老善/麦威/金山 全部未替换。
```
S2a 去口语化 仅 CLEAN_RULES + 正文(无候选、无背景)→ 保真闸门只管这一步
S2b 实体/数字归一 A 组由代码确定性替换;B 组一次专注 LLM 裁决(带关系事实,允许弃权)
```
- 闸门只校验 S2a。S2b **有意改写 token**,用闸门校它本身矛盾 → 改成白名单校验:
S2b 输出与输入的差异必须能被映射表解释,否则拒绝(比闸门更严且不冲突)。
- flash-lite 吐 token 快,两次调用不影响总时延(Kai 明确认可)。
### 4.3 【P1】A 组代码化确定性替换
`settled` 的映射不应依赖 LLM 服从指令。边界(**必须实现,否则是净负**):
- 仅当目标实体本次 `in_play` 时才替换,否则降级 B 组
(`金山→宝山基金` 是特定语境确认,全局替换危险:金山也可能是地名/金山办公)
- 排除 `common_word`(`光辉`/`公司` 这类)
- 避免嵌套误替(`白帆`→`白帆生物` 不能产生 `白帆生物生物`,用负向 lookahead)
### 4.4 【P1】背景去重压缩(Kai 第 7 点)
- 去重放在**截断之前**(Jaccard > 0.45 合并同义句)
- 散文式记忆压成紧凑表,例:
`刘冀|张科领弋 基金投资人|活跃|最近 2026-06-26|连接 基金/LP/白帆CDMO/桂林三金`
### 4.5 【P2】关系图谱 + 紧密度衰减(Kai 第 3、4 点)
```sql
relation(a, b, kind, weight, first_seen, last_seen, n)
kind: 任职 | 同事 | 项目参与 | 共同出现 | 出资
```
来源优先级:卡片 `company`/`related_people` → 纪要 `people`/`companies` 共现(带日期)
→ Hindsight 实体 → (可选)LLM 抽 typed 关系。建议先做确定性来源,评估够不够再上 LLM。
`in_play` 从「90 天硬截断 + 计数」改为**指数衰减** `w = Σ exp(-Δdays/λ)`,λ≈120 天,
直接进候选排序,替掉现在粗糙的 `boost=0/1`。
### 4.6 【P2】音通道放宽 + 字通道独立(Kai 第 1 点)
**关键认知修正**:`张泰海→张成海` 中 tai/cheng 音距很远,**不是靠音捞回来的**,
是靠"等长 + 同首字 + 只差一字"的字形锚定。所以要两条独立通道并行取较高者,
而不是把一切压进一个音近分数:
- **音通道放宽**:现有折叠 zh/z、ch/c、sh/s、n/l、f/h、ng、en/eng、in/ing。
需补:送气丢失(b/p、d/t、g/k)、尖团(j/z、q/c、x/s)、r/l、u/ü、ai/ei、ao/ou;
声调默认不计权(口音下不可靠)。注意 char→pinyin 回还本身会引入误差。
- **字通道**:首字锚定 + 尾字锚定 + n-1 字相同,**任意长度**(现在只做 ≥3 字且首字相同)。
- 建议用加权评分替掉 6 个互相独立的 `RULE_MIN` 阈值(那套是打补丁堆出来的,规则一多互相漏),
结构性 gate(称谓字字面相同)保留。
### 4.7 【P2】数字归一(Kai 第 5 点)
- 无歧义中文数字 → 阿拉伯(`八千万`→8000万、`二零二一`→2021)
- 清理口吃残留(`一一百八`、`两五千`、`万十`、`两两`)
- **量级歧义不猜**:写成 `两千[?万/千万]` 并进「待你确认」
- **闸门必须认识中文数字**,否则转换会被判成数字丢失
### 4.8 【P1】纪要目标重新定位(Kai 第 6 点)
要改掉我此前的默认倾向。DRAFT_PROMPT 以**梳理关键内容与线索**为目标,明确写:
转写原文已保留在 `transcript:`,遗漏可事后按 Kai 指示补救;宁可写"此处录音模糊、金额量级不明",
不要为凑完整而编。**闸门继续留在 S2a**(防的是清洗阶段静默删信息,是另一回事),
但成稿阶段不再以逐字对齐为目标。
Kai 原话要点:「哪怕人类回听也有很大概率不能完全还原,所以撰写主要是总结梳理关键内容和线索,
不要追求逐字还原」。
### 4.9 【P0】必须承认的边界(不要再花时间攻)
- `徐总`:三个候选(徐劲前/邹洵/许俊彦)全与桂林三金相关,**结构性歧义**,
正确输出是 `徐总(疑似 [[徐劲前]] 或 [[邹洵]])`,不是硬选。
- `趟鬼`:零信息。
- `"张泰海、张博"` 是同一人说两次还是两个人 —— 音频不可恢复。
- Kai 明确说过:「有些模糊问题,你是永远无法解决的」。目标是
**不含糊的直接落定 + 含糊的带证据出候选集**,不是让管线替他决定。
---
## 5. 建议实施顺序
1. **第一段(当天可回归)**:4.2 S2 拆分 + 4.3 A 组代码化 + 4.4 背景去重 + 4.8 成稿定位
—— 这四条直接解释了 Kai 看到的全部症状,改动最小、收益最确定。
2. **第二段**:4.7 数字归一(含闸门认中文数字)。
3. **第三段**:4.5 关系图谱 + 衰减权重(工程量最大)、4.6 音/字双通道重构。
### 回归验证要求(不可省)
- `asr-lab/fixtures` 12 篇 + 11 篇人工定稿卡回归。
- **过度纠错率必须保持 0**(历史横评实测为 0),否则确定性替换是净负。
- 保真三项(num/latin/term recall)在 S2a 上保持 1.00。
- 07-21 录音的 13 个面形召回不低于 12/13。
- 命令:`dry-run` 看 `cleaned.txt` 与 `draft.md` 的差异,判断问题在 S2 还是 S4。
---
## 6. 未提交 / 遗留
- `asr-pipeline/` 与 `asr-lab/` 所在的 `claude-workspace` **不是 git 仓库**,无版本控制,改动前建议手工备份。
- `state/lexicon.db` 含 Kai 12 条人工确认,**删库会丢**(`lexicon sync` 只能重建卡片+术语表部分,
`origin='learned'` 的丢失后无法自动恢复)。备份它。
- `dry-run/` 下有 3 份历史产物,可删。
- skill 文档 `asr-refine-pipeline.md` 已更新到「独立程序 + qwen3cam + S0–S4」,
但**尚未反映本轮 lexicon 与 S2 拆分方案**,实施后需同步。
- 已知未修 bug:见 4.2(严格重试还原已确认替换)。
# 2026-07-26 Codex 接手更新
当前实现已开始迁移到 v2 分阶段架构:纯去口语化与实体/数字规范化分离,确认映射由程序
确定性执行,Hindsight recall 在进入 prompt 前按实体压缩去重,并增加可复用的 SQLite
关系、互动、观察、模糊音和人工确认队列表。详细设计见 `docs/architecture-v2.md`。
四项核心能力已经接入生产判断链:历史纪要增量关系图、45 天半衰期的近期/频率权重、
由确认纠错学习的定向音节混淆,以及带类型/置信度/原值记录的确定性数字规范化。个人
模糊音只对当前关系语境中的实体生效,不影响全局术语库。
移动交付层已加入:每份生产草稿(不论实体候选是否触发)都会把 `## 待你确认` 转成
逐项 Web 问题。顺序为通话背景确认(可填正确背景请求重跑草稿)、缺失声纹注册、内容
不确定项、草稿整体审核;通过既有 Hermes 签名 webhook 把一次性页面地址发到 Telegram。
页面可展开完整私有粗稿。全部回答后将审计记录留在 state,由独立 oneshot 调用 Sonnet,
通过正式 schema/Summary/来源校验后只把正式稿写到 `minutes/` 并 push。确认的匿名
speaker 会立即写入 frontmatter 并调用 10099 声纹注册,失败则留给每日任务重试。
SQLite 以不同正式会议为单位统计实体;未建卡人物/公司达到 2 场时在 Web 提议建保守
存根,只有 Kai 同意才创建。页面完成后 10 秒退出,打开后空闲 45 分钟退出,最长运行
2 小时;服务不随开机常驻。`dry-run` 已禁止写入人工确认队列。
+6 -5
View File
@@ -10,11 +10,11 @@
- 任务详情页内置支持 HTTP Range 的音频播放器、失败一键切换模型重试、草稿与纪要 Markdown 在线编辑。
- `/review/` 提供低置信度词语、关系确认与说话人身份确认(本地 `qwen3cam` 自动注册声纹至 `:10099`,线上多说话人模型直接更新草稿与词库而不依赖 `:10099`)。
- `/lexicon` 提供热词、同音纠错、人物关系管理与 Obsidian 词库一键同步。
- `/settings` 展示全部 ASR 引擎、LiteLLM 网关、Obsidian 与 Hindsight 实时就绪状态。
- 支持可选 `ASR_WEB_PASSWORD` Cookie 鉴权(外网 VPS 部署时自动保护 `/ui/*` 与 `/review/*`)。
- `/settings` 展示引擎是否就绪。网关地址和 API 密钥只显示「已配置 / 未配置」,页面不回显内网地址或密钥片段。
- 浏览器界面默认关闭。必须设置 `ASR_WEB_PASSWORD` 后才能上传、审核和改配置。未设置口令时页面只提示需要配置该变量,不会整站可写。`ASR_WEB_ALLOW_ANON=1` 仅用于跑在本机且不可被其他主机访问的调试进程。状态变更还要带 CSRF 令牌。`/api/v1/*` 继续使用 `ASR_AGENT_TOKEN`。
2. **后台消费与修订队列(`asrservice.worker`)**
- 监控 `.asr-inbox/`(Web 上传或目录投递)并认领 `intake.db` 任务。
- 监控 `ASR_DATA_DIR/inbox`(容器内 `/data/inbox`)并认领 `intake.db` 任务。旧的 `meeting-watch.py` 仍监听 `recordings/inbox` 并桥接到 vault `.asr-inbox`。两者共用 `ASR_STATE_DIR/consumer.lock`,第二个进程直接退出,避免同一盘录音被消费两次。
- 调用统一多引擎层 `asrpipe.providers.transcribe_audio()` 生成带结构化注释头的原始转写。
- 执行 `asr-refine draft`:结合 SQLite 词库、Obsidian Vault(`People/`、`Companies/`、`Projects/`、`minutes/`、`_glossary.md`)与 Hindsight(`personal-context`)进行分段去口语化、保真检查与会议纪要生成。
- 监控 `state/revision-queue/` 处理 Web 审核提交的修订重跑请求,确认通过后发布正式纪要至 Obsidian 并可选执行 Git 自动提交推送。
@@ -51,5 +51,6 @@ ASR_NOTIFY=0 ASR_GIT_SYNC=0 .venv/bin/python -m unittest discover -s tests -v
1. 复制 `.env.example` 为 `.env`,按环境配置:
- **内网 VM(`tools-pve`)**:`MEETING_ASR_BACKEND=qwen3cam`(同时可配 `SONIOX_API_KEY` 等以便在 Web UI 按需切换)、`LITELLM_BASE_URL=http://192.168.50.49:4000/v1`、`HINDSIGHT_URL=http://192.168.50.47:8888`。
- **外网 VPS**:`MEETING_ASR_BACKEND=soniox`(或 `gemini` / `openai` / `grok`)、`LITELLM_BASE_URL=https://api.opfo.xyz/v1`、设置 `ASR_WEB_PASSWORD` 开启 Web 登录保护。
2. 使用 `compose.yaml` 启动 `asr-web`(默认 `:8788`)与 `asr-worker`。
- **外网 VPS**:`MEETING_ASR_BACKEND=soniox`(或 `gemini` / `openai` / `grok`)、`LITELLM_BASE_URL=https://api.opfo.xyz/v1`,并且必须设置 `ASR_WEB_PASSWORD`。代码不会按部署位置自动加锁。HTTPS 站点再设 `ASR_WEB_COOKIE_SECURE=1`。
2. `ASR_GIT_SYNC` 与 `ASR_NOTIFY` 缺省为关。要推送 vault 或发通知时显式设为 `1`,并配置 `ASR_NOTIFY_BACKEND`。
3. 使用 `compose.yaml` 启动 `asr-web`(默认 `:8788`)与 `asr-worker`。`deploy/systemd/` 是现网单元快照,路径仍指向旧工作目录,不要原样安装到本仓库。
@@ -153,7 +153,7 @@ def bootstrap_default_settings() -> None:
"hindsight_bank": _env("HINDSIGHT_BANK", "personal-context"),
"use_hindsight": "1" if _env("ASR_HINDSIGHT", "1") != "0" else "0",
"use_termbank": "1" if _env("ASR_TERMBANK", "1") != "0" else "0",
"git_sync": "1" if _env("ASR_GIT_SYNC", "0") != "0" else "0",
"git_sync": "0" if _env("ASR_GIT_SYNC", "0") == "0" else "1",
}
con = sqlite3.connect(db_path, timeout=15)
try:
@@ -320,7 +320,7 @@ class Config:
)
git_sync = _SettingAttr(
"git_sync",
lambda: _env("ASR_GIT_SYNC", "1"),
lambda: _env("ASR_GIT_SYNC", "0"),
lambda s: str(s).lower() not in ("0", "false", "no", "off"),
)
@@ -399,7 +399,7 @@ class Config:
# ---- behaviour ------------------------------------------------------
notify: bool = field(
default_factory=lambda: _env("ASR_NOTIFY", "1") != "0")
default_factory=lambda: _env("ASR_NOTIFY", "0") != "0")
cache_llm: bool = field(
default_factory=lambda: _env("ASR_CACHE", "0") != "0")
@@ -2,6 +2,8 @@
"""Persistent mobile control plane for recording intake and human review."""
from __future__ import annotations
import hashlib
import hmac
import html
import json
import os
@@ -14,7 +16,7 @@ from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from . import review
from . import review, webauth
from .config import CFG
from .log import log
@@ -166,6 +168,7 @@ def _shell(title: str, body: str, script: str = "") -> bytes:
return f"""<!doctype html>
<html lang="zh-CN"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf" content="{html.escape(webauth.csrf_token(), quote=True)}">
<meta name="color-scheme" content="light"><meta http-equiv="Cache-Control" content="no-store">
<title>{html.escape(title)}</title>
<style>
@@ -236,6 +239,7 @@ document.querySelector('#upload').addEventListener('submit',e=>{
const x=new XMLHttpRequest(), b=document.querySelector('#send'), p=document.querySelector('#progress'), out=document.querySelector('#result');
b.disabled=true;p.hidden=false;out.textContent='正在上传…';
x.open('POST','/api/upload');x.setRequestHeader('Content-Type','application/octet-stream');
x.setRequestHeader('X-CSRF-Token', document.querySelector('meta[name=csrf]')?.content || '');
x.setRequestHeader('X-Filename',encodeURIComponent(f.name));
x.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
x.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
@@ -293,6 +297,7 @@ def _review_page(action: str, job: dict | None = None) -> bytes:
body = f"""<section class="panel"><div class="meta">{html.escape(title)} · 剩余 {len(rows)} 个 · 问题 #{row['id']}</div>
<h1>{html.escape(row['question'])}</h1>{source_html}{draft_html}
<form method="post" action="{html.escape(action, quote=True)}">
<input type="hidden" name="csrf" value="{html.escape(webauth.csrf_token(), quote=True)}">
<input type="hidden" name="id" value="{row['id']}">{buttons}
<div class="custom"><input name="custom" maxlength="120" autocomplete="off"
placeholder="{html.escape(placeholder, quote=True)}"><button type="submit">提交</button></div>
@@ -342,12 +347,43 @@ class Handler(BaseHTTPRequestHandler):
return True, _job(match.group(1))
return False, None
def _browser_ok(self) -> bool:
if webauth.allow_anon():
return True
if not webauth.password():
return False
return webauth.cookie_matches(self.headers.get("Cookie", ""))
def _login_page(self, err: str = "") -> bytes:
if not webauth.password() and not webauth.allow_anon():
err = err or "未配置 ASR_WEB_PASSWORD,控制面已关闭。"
token = html.escape(webauth.csrf_token(), quote=True)
alert = f"<p>{html.escape(err)}</p>" if err else ""
body = (
f'<section class="panel"><h1>访问验证</h1>{alert}'
f'<form method="post" action="/login">'
f'<input type="hidden" name="csrf" value="{token}">'
f'<label>访问口令</label><input name="password" type="password" required>'
f'<div class="actions"><button type="submit">登录</button></div></form></section>'
)
return _shell("登录", body)
def do_GET(self):
path = urllib.parse.urlsplit(self.path).path
if path == "/":
return self._send(_home())
if path == "/health":
return self._json({"ok": True, "pending": len(review.pending(100))})
if path in {"/login", "/logout"}:
if path == "/logout":
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/login")
self.send_header("Set-Cookie", "asr_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")
self.end_headers()
return
return self._send(self._login_page())
if not self._browser_ok():
return self._send(self._login_page(), HTTPStatus.UNAUTHORIZED)
if path == "/":
return self._send(_home())
if path == "/api/jobs":
jobs = _jobs()
for j in jobs:
@@ -372,9 +408,9 @@ class Handler(BaseHTTPRequestHandler):
def _upload(self):
try:
length = int(self.headers.get("Content-Length", "0"))
length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError:
length = 0
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
if length <= 0 or length > MAX_UPLOAD:
return self._json({"error": "invalid upload size"},
HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
@@ -408,7 +444,7 @@ class Handler(BaseHTTPRequestHandler):
while remaining:
data = self.rfile.read(min(CHUNK, remaining))
if not data:
raise EOFError("upload ended early")
raise EOFError("incomplete upload")
f.write(data)
remaining -= len(data)
f.flush()
@@ -426,16 +462,28 @@ class Handler(BaseHTTPRequestHandler):
update_job(job_id, "queued", "录音已落盘,等待 watcher 接收")
return self._json({"ok": True, "job_id": job_id,
"stored_name": stored}, HTTPStatus.CREATED)
except EOFError:
part.unlink(missing_ok=True)
update_job(job_id, "failed", "上传未完成")
return self._json({"error": "incomplete upload"}, HTTPStatus.BAD_REQUEST)
except Exception as e:
part.unlink(missing_ok=True)
update_job(job_id, "failed", f"上传失败:{e}")
return self._json({"error": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR)
update_job(job_id, "failed", "上传失败")
log(f"upload failed: {e}")
return self._json({"error": "upload failed"}, HTTPStatus.INTERNAL_SERVER_ERROR)
def _answer(self):
try:
length = min(int(self.headers.get("Content-Length", "0")), 4096)
try:
length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError as exc:
raise ValueError("bad content-length") from exc
if length < 0 or length > 4096:
raise ValueError("bad content-length")
form = urllib.parse.parse_qs(
self.rfile.read(length).decode("utf-8"))
if not webauth.csrf_matches((form.get("csrf") or [""])[0]):
raise ValueError("csrf")
qid = int(form.get("id", ["0"])[0])
answer = (form.get("custom", [""])[0].strip()
or form.get("answer", [""])[0].strip())
@@ -460,7 +508,31 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
path = urllib.parse.urlsplit(self.path).path
if path == "/login":
try:
length = int(self.headers.get("Content-Length", "0") or 0)
except ValueError:
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
if length < 0:
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
length = min(length, 4096)
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", "ignore"))
pw = form.get("password", [""])[0]
if webauth.password() and hmac.compare_digest(pw, webauth.password()):
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/")
self.send_header(
"Set-Cookie",
f"asr_session={webauth.session_token()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400",
)
self.end_headers()
return
return self._send(self._login_page("口令错误或未配置 ASR_WEB_PASSWORD"), HTTPStatus.UNAUTHORIZED)
if not self._browser_ok():
return self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED)
if path in ("/api/upload", "/hook/intake"):
if not webauth.csrf_matches(self.headers.get("X-CSRF-Token", "")):
return self._json({"error": "csrf"}, HTTPStatus.FORBIDDEN)
return self._upload()
if self._review_job()[0]:
return self._answer()
@@ -44,14 +44,16 @@ from pathlib import Path
from .config import CFG
LAB = CFG.root
BANK = CFG.termbank_dir
CACHE = CFG.cache_dir / "flagger"
VAULT = CFG.vault
def _vault() -> Path:
return CFG.vault
HS_API = CFG.hindsight_api
HS_BANK = CFG.hindsight_bank
HS_TIMEOUT = CFG.hindsight_timeout
def _bank() -> Path:
return CFG.termbank_dir
def _cache() -> Path:
return CFG.cache_dir / "flagger"
HAN = re.compile(r"[\u4e00-\u9fff]+")
HAN_FULL = re.compile(r"^[\u4e00-\u9fff]+$")
@@ -264,16 +266,16 @@ def _dir_sig(paths) -> str:
def load_pool(use_termbank=True) -> dict[str, Cand]:
"""Vault entities (cards + glossary) ∪ optional domain term bank."""
CACHE.mkdir(parents=True, exist_ok=True)
_cache().mkdir(parents=True, exist_ok=True)
if CFG.use_lexicon:
try:
from . import lexicon
lexicon.sync_from_vault()
except Exception:
pass
sig = _dir_sig([VAULT / "People", VAULT / "Companies", VAULT / "minutes",
sig = _dir_sig([_vault() / "People", _vault() / "Companies", _vault() / "minutes",
CFG.lexicon_db])
cpath = CACHE / f"pool6-{sig}-{int(use_termbank)}.json"
cpath = _cache() / f"pool6-{sig}-{int(use_termbank)}.json"
if cpath.exists():
d = json.loads(cpath.read_text())
return {k: Cand(**v) for k, v in d.items()}
@@ -331,7 +333,7 @@ def load_pool(use_termbank=True) -> dict[str, Cand]:
ents = {}
if not ents: # lexicon disabled/broken
for sub, kind in (("People", "人名"), ("Companies", "公司")):
d = VAULT / sub
d = _vault() / sub
if not d.exists():
continue
for f in d.glob("*.md"):
@@ -346,7 +348,7 @@ def load_pool(use_termbank=True) -> dict[str, Cand]:
add(form, c.canonical, c.kind, "variant", rule, c.owner, c.role, anchor)
if use_termbank:
tb = BANK / "terms.json"
tb = _bank() / "terms.json"
if tb.exists():
for term, e in json.loads(tb.read_text()).items():
# Only the on-domain, distinctive slices are worth phonetic search;
@@ -361,7 +363,7 @@ def load_pool(use_termbank=True) -> dict[str, Cand]:
def load_allowlist() -> set[str]:
p = BANK / "allowlist.txt"
p = _bank() / "allowlist.txt"
return set(p.read_text().split()) if p.exists() else set()
@@ -388,13 +390,13 @@ def _minutes_entities(text: str) -> set[str]:
def _corpus() -> list[tuple[str, set[str], str]]:
CACHE.mkdir(parents=True, exist_ok=True)
sig = _dir_sig([VAULT / "minutes"])
cpath = CACHE / f"corpus-{sig}.json"
_cache().mkdir(parents=True, exist_ok=True)
sig = _dir_sig([_vault() / "minutes"])
cpath = _cache() / f"corpus-{sig}.json"
if cpath.exists():
return [(a, set(b), c) for a, b, c in json.loads(cpath.read_text())]
out = []
for f in sorted((VAULT / "minutes").glob("*.md")):
for f in sorted((_vault() / "minutes").glob("*.md")):
txt = f.read_text(errors="ignore")
m = re.search(r"^date:\s*(\d{4}-\d{2}-\d{2})", txt, re.M)
out.append((f.name, _minutes_entities(txt), m.group(1) if m else ""))
@@ -414,11 +416,11 @@ def hindsight_recall(seed: list[str], extra_query: str = "") -> tuple[set[str],
return set(), []
query = (" ".join(sorted(seed)) + " " + extra_query).strip() or "会议 相关人物 公司"
body = json.dumps({"query": query, "max_tokens": 1500}).encode()
url = f"{HS_API}/v1/default/banks/{HS_BANK}/memories/recall"
url = f"{CFG.hindsight_api}/v1/default/banks/{CFG.hindsight_bank}/memories/recall"
try:
req = urllib.request.Request(url, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=HS_TIMEOUT) as r:
with urllib.request.urlopen(req, timeout=CFG.hindsight_timeout) as r:
d = json.loads(r.read())
except Exception:
return set(), []
@@ -157,7 +157,8 @@ class Entity:
def connect() -> sqlite3.Connection:
CFG.state_dir.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(CFG.lexicon_db)
con = sqlite3.connect(CFG.lexicon_db, timeout=30)
con.execute("PRAGMA journal_mode=WAL")
con.row_factory = sqlite3.Row
con.executescript(SCHEMA)
# One-time compatibility migration for the 158 corrections learned before the
@@ -527,7 +528,7 @@ def stats() -> dict:
# ------------------------------------------------------------------ Web UI CRUD
def list_aliases(q: str = "", origin_filter: str = "", limit: int = 200) -> list[dict]:
def list_aliases(q: str = "", origin_filter: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
con = connect()
sql = "SELECT * FROM alias WHERE 1=1"
args: list = []
@@ -539,13 +540,30 @@ def list_aliases(q: str = "", origin_filter: str = "", limit: int = 200) -> list
sql += " AND origin <> 'card'"
elif origin_filter == "card":
sql += " AND origin = 'card'"
sql += " ORDER BY confirmed DESC, created_at DESC, alias ASC LIMIT ?"
args.append(limit)
sql += " ORDER BY confirmed DESC, created_at DESC, alias ASC LIMIT ? OFFSET ?"
args.extend((max(1, min(limit, 500)), max(0, offset)))
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
con.close()
return rows
def count_aliases(q: str = "", origin_filter: str = "") -> int:
con = connect()
sql = "SELECT count(*) FROM alias WHERE 1=1"
args: list = []
if q.strip():
pat = f"%{q.strip()}%"
sql += " AND (alias LIKE ? OR canonical LIKE ? OR note LIKE ?)"
args.extend([pat, pat, pat])
if origin_filter == "learned":
sql += " AND origin <> 'card'"
elif origin_filter == "card":
sql += " AND origin = 'card'"
n = int(con.execute(sql, args).fetchone()[0])
con.close()
return n
def update_alias(
old_alias: str,
old_canonical: str,
@@ -590,7 +608,7 @@ def delete_alias(alias: str, canonical: str) -> bool:
return deleted
def list_corrections(q: str = "", limit: int = 200) -> list[dict]:
def list_corrections(q: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
con = connect()
sql = "SELECT * FROM correction WHERE 1=1"
args: list = []
@@ -598,13 +616,26 @@ def list_corrections(q: str = "", limit: int = 200) -> list[dict]:
pat = f"%{q.strip()}%"
sql += " AND (surface LIKE ? OR canonical LIKE ? OR note LIKE ?)"
args.extend([pat, pat, pat])
sql += " ORDER BY confirmed DESC, n DESC, last_seen DESC LIMIT ?"
args.append(limit)
sql += " ORDER BY confirmed DESC, n DESC, last_seen DESC LIMIT ? OFFSET ?"
args.extend((max(1, min(limit, 500)), max(0, offset)))
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
con.close()
return rows
def count_corrections(q: str = "") -> int:
con = connect()
sql = "SELECT count(*) FROM correction WHERE 1=1"
args: list = []
if q.strip():
pat = f"%{q.strip()}%"
sql += " AND (surface LIKE ? OR canonical LIKE ? OR note LIKE ?)"
args.extend([pat, pat, pat])
n = int(con.execute(sql, args).fetchone()[0])
con.close()
return n
def update_correction(
old_surface: str,
old_canonical: str,
@@ -651,7 +682,7 @@ def delete_correction(surface: str, canonical: str) -> bool:
return deleted
def list_entities(q: str = "", kind: str = "", limit: int = 200) -> list[dict]:
def list_entities(q: str = "", kind: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
con = connect()
sql = "SELECT * FROM entity WHERE 1=1"
args: list = []
@@ -662,13 +693,29 @@ def list_entities(q: str = "", kind: str = "", limit: int = 200) -> list[dict]:
if kind.strip():
sql += " AND kind=?"
args.append(kind.strip())
sql += " ORDER BY (CASE WHEN card='' THEN 0 ELSE 1 END), canonical ASC LIMIT ?"
args.append(limit)
sql += " ORDER BY (CASE WHEN card='' THEN 0 ELSE 1 END), canonical ASC LIMIT ? OFFSET ?"
args.extend((max(1, min(limit, 500)), max(0, offset)))
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
con.close()
return rows
def count_entities(q: str = "", kind: str = "") -> int:
con = connect()
sql = "SELECT count(*) FROM entity WHERE 1=1"
args: list = []
if q.strip():
pat = f"%{q.strip()}%"
sql += " AND (canonical LIKE ? OR company LIKE ? OR role LIKE ? OR related LIKE ?)"
args.extend([pat, pat, pat, pat])
if kind.strip():
sql += " AND kind=?"
args.append(kind.strip())
n = int(con.execute(sql, args).fetchone()[0])
con.close()
return n
def upsert_entity(
canonical: str,
kind: str = "人名",
@@ -723,7 +770,7 @@ def delete_entity(canonical: str) -> bool:
return deleted
def list_relations(q: str = "", limit: int = 200) -> list[dict]:
def list_relations(q: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
con = connect()
sql = "SELECT * FROM relation WHERE 1=1"
args: list = []
@@ -731,13 +778,26 @@ def list_relations(q: str = "", limit: int = 200) -> list[dict]:
pat = f"%{q.strip()}%"
sql += " AND (subject LIKE ? OR predicate LIKE ? OR object LIKE ? OR source LIKE ?)"
args.extend([pat, pat, pat, pat])
sql += " ORDER BY active DESC, confidence DESC, last_seen DESC LIMIT ?"
args.append(limit)
sql += " ORDER BY active DESC, confidence DESC, last_seen DESC LIMIT ? OFFSET ?"
args.extend((max(1, min(limit, 500)), max(0, offset)))
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
con.close()
return rows
def count_relations(q: str = "") -> int:
con = connect()
sql = "SELECT count(*) FROM relation WHERE 1=1"
args: list = []
if q.strip():
pat = f"%{q.strip()}%"
sql += " AND (subject LIKE ? OR predicate LIKE ? OR object LIKE ? OR source LIKE ?)"
args.extend([pat, pat, pat, pat])
n = int(con.execute(sql, args).fetchone()[0])
con.close()
return n
def update_relation(
old_subject: str,
old_predicate: str,
@@ -49,10 +49,8 @@ _key: list[str] = []
def _auth() -> str:
with _lock:
if not _key:
_key.append(CFG.api_key())
return _key[0]
# Read on every call so a key saved from /settings is used without a restart.
return CFG.api_key()
@dataclass
@@ -0,0 +1,25 @@
"""One machine-wide lock for processes that consume the recording inbox."""
from __future__ import annotations
import fcntl
import os
from pathlib import Path
def acquire_recording_lock(path: Path):
"""Return an open file holding an exclusive lock, or exit if another consumer has it.
The handle must stay referenced for the life of the process.
"""
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
raise SystemExit(f"another recording consumer holds {path}")
handle.seek(0)
handle.truncate()
handle.write(str(os.getpid()))
handle.flush()
return handle
@@ -93,7 +93,7 @@ KEYWORD_PROMPT = """从下面的会议转写摘选中,提取用于检索背景
# (「谭凯(白帆生物总经理)」、「此项目负责人为 [[冯昆仑]](背景确认)」) even when the
# recording never said them. The facts were often correct, but a meeting note that
# cannot distinguish "said on the call" from "known from notes" is unsafe to finalize.
DRAFT_PROMPT = """你是会议录音整理助手,正在根据**带来源编号的事实卡片**产出「粗整理草稿」。
DRAFT_PROMPT = """你是会议录音整理助手,正在根据下方整理要点写出会议草稿。
严格遵循:
1. 只产出粗整理草稿,**不是**正式纪要。
@@ -133,8 +133,11 @@ DRAFT_PROMPT = """你是会议录音整理助手,正在根据**带来源编号
(例如「徐总(疑似 [[徐劲前]] 或 [[邹洵]])」),而不是笼统写「身份不明」。
13. 判定简称指向谁时使用背景里的任职信息,这属于**辨识**而非越界断言,
不需要加〔背景〕;但如果你要额外陈述这个人的职务、公司归属本身,那就要加〔背景〕。
14. 对匿名说话人,除非事实卡片已经给出有证据的候选,否则只写“身份待确认”;
14. 对匿名说话人,除非整理要点已经给出有证据的候选,否则只写“身份待确认”;
不要从背景中枚举可能的人名让用户猜。
15. 正文和标题不要出现「卡片」「提示词」「系统指令」「粗整理」这些内部说法。
记录里没有的信息写「记录未写明」,不要写「卡片未提供」或「卡片提及」。
标题用业务名称,不要带「草稿」二字。
{speaker_note}
@@ -153,7 +156,7 @@ companies: [有把握的公司…]
transcript: {transcript}
{extra_fm}---
事实卡片:
整理要点:
{body}
只输出 markdown 草稿全文,不要任何额外说明。"""
@@ -409,28 +409,45 @@ def probe_duration_seconds(audio: Path) -> int:
return 0
def _encode_multipart(
def _multipart_framing(
fields: dict[str, str],
file_field: str,
file_path: Path,
) -> tuple[bytes, str]:
) -> tuple[bytes, bytes, str, int]:
"""Build multipart head/tail without reading the audio into memory."""
boundary = f"----ASRBoundary{uuid.uuid4().hex}"
body = bytearray()
safe_name = file_path.name.replace('"', "").replace("\r", "").replace("\n", "") or "audio"
head = bytearray()
for name, val in fields.items():
body.extend(f"--{boundary}\r\n".encode())
body.extend(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
body.extend(str(val).encode("utf-8"))
body.extend(b"\r\n")
mime = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
body.extend(f"--{boundary}\r\n".encode())
body.extend(
head.extend(f"--{boundary}\r\n".encode())
head.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
head.extend(str(val).encode("utf-8"))
head.extend(b"\r\n")
mime = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
head.extend(f"--{boundary}\r\n".encode())
head.extend(
f'Content-Disposition: form-data; name="{file_field}"; '
f'filename="{file_path.name}"\r\n'.encode())
body.extend(f"Content-Type: {mime}\r\n\r\n".encode())
body.extend(file_path.read_bytes())
body.extend(f"\r\n--{boundary}--\r\n".encode())
return bytes(body), f"multipart/form-data; boundary={boundary}"
f'filename="{safe_name}"\r\n'.encode())
head.extend(f"Content-Type: {mime}\r\n\r\n".encode())
tail = f"\r\n--{boundary}--\r\n".encode()
total = len(head) + file_path.stat().st_size + len(tail)
return bytes(head), tail, f"multipart/form-data; boundary={boundary}", total
def _open_http(url: str, timeout: int) -> tuple[http.client.HTTPConnection, str]:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise RuntimeError(f"unsupported ASR URL: {url}")
port = parsed.port or (443 if parsed.scheme == "https" else 80)
if parsed.scheme == "https":
conn: http.client.HTTPConnection = http.client.HTTPSConnection(
parsed.hostname, port, timeout=timeout)
else:
conn = http.client.HTTPConnection(parsed.hostname, port, timeout=timeout)
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
return conn, path
def _http_multipart(
@@ -443,29 +460,48 @@ def _http_multipart(
timeout: int = 900,
retries: int = 3,
) -> dict[str, Any]:
data, content_type = _encode_multipart(fields, file_field, file_path)
req_headers = {"Content-Type": content_type, **(headers or {})}
"""POST audio as multipart, streaming the file in 1 MiB chunks."""
head, tail, content_type, total = _multipart_framing(fields, file_field, file_path)
last_exc: Exception | None = None
raw = ""
for attempt in range(1, max(1, retries) + 1):
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
conn, path = _open_http(url, timeout)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="ignore")
break
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="ignore")[:300]
if exc.code in (502, 503, 504) and attempt < retries:
last_exc = exc
conn.putrequest("POST", path)
conn.putheader("Content-Type", content_type)
conn.putheader("Content-Length", str(total))
for key, value in (headers or {}).items():
conn.putheader(key, value)
conn.endheaders()
conn.send(head)
with file_path.open("rb") as stream:
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
conn.send(chunk)
conn.send(tail)
resp = conn.getresponse()
raw = resp.read().decode("utf-8", errors="ignore")
status = resp.status
if status in (502, 503, 504) and attempt < retries:
last_exc = RuntimeError(f"ASR HTTP {status}")
time.sleep(2 * attempt)
continue
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
if status >= 400:
raise RuntimeError(f"ASR HTTP {status}: {raw[:300]}")
break
except RuntimeError:
raise
except (http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
last_exc = exc
if attempt < retries:
log(f"multipart upload transient error (attempt {attempt}/{retries}): {exc}")
time.sleep(2 * attempt)
continue
raise RuntimeError(f"ASR upload connection error after {retries} attempts: {exc}") from exc
finally:
conn.close()
else:
raise RuntimeError(f"ASR upload failed: {last_exc}")
try:
@@ -337,6 +337,18 @@ def normalize_chunks(chunks, cleaned_parts: list[str], flags: list[dict],
}
def scrub_internal_wording(text: str) -> str:
"""Keep pipeline jargon out of text a person will read."""
for src, dst in (
("卡片未提供", "记录未写明"),
("卡片提及", "录音中提到"),
("事实卡片", "整理要点"),
("粗整理草稿", "整理稿"),
):
text = text.replace(src, dst)
return text
def extract_evidence(parts: list[str], usage: Usage) -> str:
jobs = [prompts.EVIDENCE_PROMPT.format(chunk_id=i, body=body)
for i, body in enumerate(parts)]
@@ -347,7 +359,7 @@ def extract_evidence(parts: list[str], usage: Usage) -> str:
max_tokens=1400, usage=usage)
except PartialFailure as pf:
outs = pf.outputs
return "\n".join(x for x in outs if x.strip())
return scrub_internal_wording("\n".join(x for x in outs if x.strip()))
# ------------------------------------------------------------------ S4
@@ -386,7 +398,7 @@ def write_draft(cleaned: str, meta: dict, context: str, date: str,
speaker_note=prompts.SPEAKER_NOTE.get(backend, ""),
context=ctx, date=date, source_detail=detail,
transcript=transcript_path, extra_fm=extra, body=cleaned)
return chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage)
return scrub_internal_wording(chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage))
# ------------------------------------------------------------------ driver
@@ -323,6 +323,7 @@ def answer(question_id: int, value: str, *, allow_update: bool = False) -> dict:
return result
payload = json.loads(row["payload"] or "{}")
recording = row["recording"]
previous = str(row["answer"] or "")
con.execute(
"UPDATE review_question SET status='answered',answer=?,answered_at=? WHERE id=?",
(value, time.time(), question_id))
@@ -343,9 +344,14 @@ def answer(question_id: int, value: str, *, allow_update: bool = False) -> dict:
confidence=1.0, confirmed=1)
saved = False
voiceprint_enrolled = False
if payload.get("kind") == "voiceprint":
voiceprint_enrolled = _confirm_voiceprint(
payload.get("draft_path", ""), payload.get("speaker_id", ""), value)
if payload.get("kind") == "voiceprint" and not (allow_update and value == previous):
try:
voiceprint_enrolled = _confirm_voiceprint(
payload.get("draft_path", ""), payload.get("speaker_id", ""), value)
except Exception as exc:
from .log import log
log(f"voiceprint enroll failed after answer saved: {exc}", level="WARN")
voiceprint_enrolled = False
if payload.get("draft_path"):
try:
saved = _persist_draft_feedback(recording, payload["draft_path"])
@@ -401,17 +407,32 @@ def reopen_question(question_id: int) -> bool:
return ok
def _question_matches(row, transcript_path: str, draft_path: str, job_id: str) -> bool:
payload = json.loads(row["payload"] or "{}")
return bool(
(transcript_path and row["recording"] == transcript_path)
or (draft_path and payload.get("draft_path") == draft_path)
or (job_id and payload.get("job_id") == job_id)
)
def delete_for_job(transcript_path: str = "", draft_path: str = "", job_id: str = "") -> int:
return _delete_questions(transcript_path, draft_path, job_id, pending_only=False)
def delete_pending_for_job(transcript_path: str = "", draft_path: str = "", job_id: str = "") -> int:
"""Drop unanswered questions. Answered rows stay so a failed refine can be retried."""
return _delete_questions(transcript_path, draft_path, job_id, pending_only=True)
def _delete_questions(transcript_path: str, draft_path: str, job_id: str, *, pending_only: bool) -> int:
con = lexicon.connect()
rows = con.execute("SELECT id, recording, payload FROM review_question").fetchall()
rows = con.execute("SELECT id, recording, payload, status FROM review_question").fetchall()
to_delete: list[int] = []
for r in rows:
payload = json.loads(r["payload"] or "{}")
if (
(transcript_path and r["recording"] == transcript_path)
or (draft_path and payload.get("draft_path") == draft_path)
or (job_id and payload.get("job_id") == job_id)
):
if pending_only and r["status"] != "pending":
continue
if _question_matches(r, transcript_path, draft_path, job_id):
to_delete.append(int(r["id"]))
if to_delete:
marks = ",".join("?" for _ in to_delete)
@@ -0,0 +1,57 @@
"""Browser session helpers shared by the new workbench and the legacy control UI.
An empty ``ASR_WEB_PASSWORD`` closes the UI. Set ``ASR_WEB_ALLOW_ANON=1`` only
for a local process that is not reachable from the network.
"""
from __future__ import annotations
import hashlib
import hmac
import os
from http import cookies
from .config import get_setting
def allow_anon() -> bool:
return os.getenv("ASR_WEB_ALLOW_ANON", "").strip().lower() in {
"1", "true", "yes", "on",
}
def password() -> str:
env_pw = os.getenv("ASR_WEB_PASSWORD")
if env_pw not in (None, ""):
return env_pw
return get_setting("web_password", "")
def _secret() -> bytes:
return (password() or os.getenv("ASR_AGENT_TOKEN", "") or "asr-web-unset").encode("utf-8")
def session_token() -> str:
return hmac.new(_secret(), b"asr-web-session-v1", hashlib.sha256).hexdigest()
def csrf_token() -> str:
return hmac.new(_secret(), b"asr-web-csrf-v1", hashlib.sha256).hexdigest()
def cookie_matches(header: str) -> bool:
if not password():
return False
jar = cookies.SimpleCookie()
try:
jar.load(header or "")
except Exception:
return False
morsel = jar.get("asr_session")
return bool(morsel and hmac.compare_digest(morsel.value, session_token()))
def csrf_matches(supplied: str) -> bool:
if not supplied:
return False
return hmac.compare_digest(supplied, csrf_token())
@@ -49,6 +49,8 @@ def connect() -> sqlite3.Connection:
""")
cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")}
default_prov = CFG.asr_provider or "qwen3cam"
if default_prov not in {"qwen3cam", "soniox", "gemini", "openai", "grok"}:
default_prov = "qwen3cam"
additions = {
"draft": "TEXT NOT NULL DEFAULT ''",
"result": "TEXT NOT NULL DEFAULT ''",
@@ -104,7 +106,7 @@ def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> boo
raise ValueError(f"invalid status: {status}")
allowed = {
"transcript", "draft", "result", "sha256", "attempts",
"error_code", "asr_provider", "asr_model", "title", "context",
"error_code", "asr_provider", "asr_model", "title", "context", "stored_name",
}
changes = {k: v for k, v in fields.items() if k in allowed and v is not None}
sets = ["status=?", "updated_at=?", "detail=?"]
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,18 @@
from __future__ import annotations
import fcntl
import hashlib
import json
import os
import re
import shutil
import sqlite3
import threading
import time
import uuid
from pathlib import Path
from asrpipe import graph, lexicon, providers, refine, review, revise, vault
from asrpipe.config import CFG
from asrpipe.locking import acquire_recording_lock
from . import db
from .config import (
DATA, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS,
@@ -21,6 +21,8 @@ from .config import (
)
from .notify import send
_RECORDING_LOCK = None
def log(message: str) -> None:
ensure_dirs()
@@ -70,6 +72,31 @@ def transcribe(
return transcript, res
def intake_header(payload: dict) -> str:
blob = json.dumps(payload, ensure_ascii=False).replace("--", "- -")
return f"<!-- intake: {blob} -->"
def replace_intake(raw: str, header: str) -> str:
if raw.startswith("<!-- intake:"):
_first, _sep, rest = raw.partition("\n")
return header + ("\n" + rest if _sep else "")
return header + "\n" + raw
def beat() -> None:
ensure_dirs()
(STATE / "worker.heartbeat").write_text(str(time.time()))
def _beat_loop(stop: threading.Event) -> None:
while not stop.wait(15):
try:
beat()
except Exception:
pass
def process_audio(source: Path, job: dict) -> None:
job_id = job["id"]
attempts = int(job.get("attempts") or 0) + 1
@@ -97,7 +124,7 @@ def process_audio(source: Path, job: dict) -> None:
"asr_model": res.model,
}
raw = transcript.read_text(encoding="utf-8")
raw = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->\n" + raw
raw = replace_intake(raw, intake_header(intake))
transcript.write_text(raw, encoding="utf-8")
db.update_job(
job_id, "refining", f"转写完成 ({res.provider}:{res.model}),正在生成整理稿",
@@ -137,25 +164,21 @@ def rerefine_job(job: dict) -> None:
"asr_provider": job.get("asr_provider") or CFG.asr_provider,
"asr_model": job.get("asr_model") or "",
}
intake_line = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->"
if re.match(r"^<!--\s*intake:\s*\{.*?\}\s*-->", raw):
raw = re.sub(r"^<!--\s*intake:\s*\{.*?\}\s*-->", intake_line, raw, count=1)
else:
raw = intake_line + "\n" + raw
raw = replace_intake(raw, intake_header(intake))
transcript.write_text(raw, encoding="utf-8")
db.update_job(job_id, "refining", "正在基于现有转写重新生成精修草稿")
review.delete_for_job(
transcript_path=str(transcript),
draft_path=str(job.get("draft") or ""),
job_id=job_id,
)
result = refine.refine(
raw, date=time.strftime("%Y-%m-%d"),
transcript_path=str(transcript), queue_review=True,
)
draft = DRAFTS / f"{job_id}.md"
vault.write_draft_file(draft, result.draft)
review.delete_pending_for_job(
transcript_path=str(transcript),
draft_path=str(job.get("draft") or draft),
job_id=job_id,
)
questions = review.enqueue_draft(str(transcript), draft)
db.update_job(
job_id, "review", f"整理稿已重新生成,{questions} 个问题待确认",
@@ -176,6 +199,9 @@ def claim(path: Path) -> tuple[Path, dict]:
if target.exists():
target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}"
path.replace(target)
if target.name != job.get("stored_name"):
db.update_job(job["id"], job["status"], job.get("detail") or "已认领", stored_name=target.name)
job = db.get_job(job["id"]) or job
return target, job
@@ -199,15 +225,54 @@ def scan_inbox() -> None:
error_code=type(exc).__name__,
)
log(f"job={job['id']} failed type={type(exc).__name__} retry={retry} err={exc}")
target = INBOX / source.name if retry else FAILED / source.name
if source.exists():
shutil.move(str(source), target)
try:
if source.exists():
folder = INBOX if retry else FAILED
target = folder / source.name
if target.exists():
target = folder / f"{source.stem}-{job['id'][:8]}{source.suffix}"
shutil.move(str(source), target)
if retry:
old = time.time() - max(STABLE_SECONDS, 60)
os.utime(target, (old, old))
except Exception as move_exc:
log(f"job={job['id']} audio left in processing after failure: {move_exc}")
def _defer_revision(marker: Path, payload: dict, exc: BaseException) -> None:
attempts = int(payload.get("attempts") or 0) + 1
payload["attempts"] = attempts
payload["last_error"] = str(exc)[:300]
job_id = str(payload.get("job_id") or "")
if attempts >= MAX_ATTEMPTS:
marker.unlink(missing_ok=True)
if job_id:
db.update_job(job_id, "failed", f"修订失败:{exc}", error_code=type(exc).__name__)
log(f"revision marker={marker.name} gave up after {attempts}: {exc}")
return
tmp = marker.with_name(f".{marker.name}.{os.getpid()}.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
tmp.replace(marker)
if job_id:
db.update_job(
job_id, "review",
f"修订失败,将重试 ({attempts}/{MAX_ATTEMPTS}):{exc}",
error_code=type(exc).__name__,
)
log(f"revision marker={marker.name} retry {attempts}/{MAX_ATTEMPTS}: {exc}")
def process_revisions() -> None:
for marker in sorted(REVISION_QUEUE.glob("*.json")):
if marker.name.startswith("."):
continue
try:
payload = json.loads(marker.read_text(encoding="utf-8"))
except Exception as exc:
log(f"revision marker={marker.name} unreadable: {exc}")
_defer_revision(marker, {}, exc)
continue
try:
action = str(payload.get("action") or "revise")
job_id = str(payload.get("job_id") or "")
if action == "rerefine":
@@ -231,27 +296,45 @@ def process_revisions() -> None:
)
marker.unlink(missing_ok=True)
except Exception as exc:
log(f"revision marker={marker.name} failed type={type(exc).__name__} err={exc}")
try:
payload = json.loads(marker.read_text(encoding="utf-8"))
jid = str(payload.get("job_id") or "")
if jid:
db.update_job(jid, "failed", f"精修/修订失败:{exc}", error_code=type(exc).__name__)
except Exception:
pass
marker.unlink(missing_ok=True)
_defer_revision(marker, payload, exc)
def _park_audio(path: Path) -> Path:
dest = INBOX / path.name
if dest.exists():
dest = INBOX / f"{path.stem}-recovered{path.suffix}"
shutil.move(str(path), dest)
old = time.time() - max(STABLE_SECONDS, 60)
os.utime(dest, (old, old))
return dest
def recover() -> None:
for job in db.pending_recovery():
if job["status"] in {"transcribing", "refining"}:
candidate = next(
(p for p in PROCESSING.iterdir() if p.name == job["stored_name"]),
None,
"""Return audio left in processing/ to the inbox and requeue its job."""
if not PROCESSING.exists():
return
files = [p for p in PROCESSING.iterdir() if p.is_file()]
jobs = db.pending_recovery()
claimed: set[str] = set()
for path in files:
match = None
for job in jobs:
if job["id"] in claimed:
continue
stored = str(job.get("stored_name") or "")
if path.name == stored or (job["id"][:8] and job["id"][:8] in path.name):
match = job
break
dest = _park_audio(path)
if match:
claimed.add(match["id"])
db.update_job(
match["id"], "queued", "worker 重启后重新排队",
stored_name=dest.name, error_code="",
)
if candidate:
shutil.move(str(candidate), INBOX / candidate.name)
db.update_job(job["id"], "queued", "worker 重启后重新排队")
log(f"recovered job={match['id']} audio={dest.name}")
else:
log(f"recovered orphan audio={dest.name}")
def sync_vault_knowledge() -> None:
@@ -281,24 +364,36 @@ def run_loop() -> None:
os.environ.setdefault("ASR_ROLE", "worker")
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
ensure_dirs()
lock = (STATE / "worker.lock").open("w")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
global _RECORDING_LOCK
lock_path = Path(os.getenv("ASR_CONSUMER_LOCK", str(STATE / "consumer.lock")))
_RECORDING_LOCK = acquire_recording_lock(lock_path)
recover()
sync_vault_knowledge()
(STATE / "worker.heartbeat").write_text(str(time.time()))
beat()
stop_beat = threading.Event()
threading.Thread(target=_beat_loop, args=(stop_beat,), name="asr-heartbeat", daemon=True).start()
last_backup = 0.0
last_vault_sync = time.time()
while True:
scan_inbox()
process_revisions()
try:
scan_inbox()
except Exception as exc:
log(f"scan_inbox failed: {exc}")
try:
process_revisions()
except Exception as exc:
log(f"process_revisions failed: {exc}")
now = time.time()
if now - last_vault_sync >= 900:
sync_vault_knowledge()
last_vault_sync = now
if now - last_backup >= 3600:
backup_databases()
try:
backup_databases()
except Exception as exc:
log(f"backup failed: {exc}")
last_backup = now
(STATE / "worker.heartbeat").write_text(str(now))
beat()
time.sleep(POLL_SECONDS)
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Standalone ASR post-processing entrypoint.
#
# Independent of Claude Code / opencode / any agent runtime: it uses its own venv
# and talks to the model provider over plain HTTPS. Safe to call from systemd,
# cron, or a shell that has nothing but bash.
set -euo pipefail
ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
PY="$ROOT/.venv/bin/python"
if [ ! -x "$PY" ]; then
echo "venv missing at $PY" >&2
echo "create it with:" >&2
echo " uv venv $ROOT/.venv --python 3.13" >&2
echo " uv pip install --python $PY pypinyin rapidfuzz numpy" >&2
exit 2
fi
export PYTHONPATH="$ROOT${PYTHONPATH:+:$PYTHONPATH}"
export PYTHONUNBUFFERED=1
exec "$PY" -m asrpipe.cli "$@"
+9 -13
View File
@@ -27,8 +27,8 @@ x-asr-environment: &asr-environment
services:
asr-web:
image: tools-asr-pipeline:2026-11-02
pull_policy: never
image: ${ASR_IMAGE:-tools-asr-pipeline:latest}
pull_policy: ${ASR_PULL_POLICY:-never}
build:
context: .
dockerfile: Dockerfile
@@ -38,12 +38,10 @@ services:
<<: *asr-environment
ASR_ROLE: web
ports:
- "192.168.50.49:8788:8788"
- "${ASR_BIND_ADDRESS:-192.168.50.49}:${ASR_PORT:-8788}:8788"
volumes:
- /mnt/truenas/personal/ASR-Pipeline:/data
- /opt/asr-pipeline/state:/state
- /opt/asr-pipeline/vault:/data/vault
- /opt/asr-pipeline/ssh:/root/.ssh:ro
- ${ASR_DATA_VOLUME:-/mnt/truenas/personal/ASR-Pipeline}:/data
- ${ASR_STATE_VOLUME:-/opt/asr-pipeline/state}:/state
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
interval: 30s
@@ -54,8 +52,8 @@ services:
- no-new-privileges:true
asr-worker:
image: tools-asr-pipeline:2026-11-02
pull_policy: never
image: ${ASR_IMAGE:-tools-asr-pipeline:latest}
pull_policy: ${ASR_PULL_POLICY:-never}
container_name: tools-asr-worker
restart: unless-stopped
environment:
@@ -63,10 +61,8 @@ services:
ASR_ROLE: worker
command: ["python", "-m", "asrservice.worker"]
volumes:
- /mnt/truenas/personal/ASR-Pipeline:/data
- /opt/asr-pipeline/state:/state
- /opt/asr-pipeline/vault:/data/vault
- /opt/asr-pipeline/ssh:/root/.ssh:ro
- ${ASR_DATA_VOLUME:-/mnt/truenas/personal/ASR-Pipeline}:/data
- ${ASR_STATE_VOLUME:-/opt/asr-pipeline/state}:/state
healthcheck:
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
interval: 30s
@@ -0,0 +1,16 @@
# 现网路径快照。模板参数 %I 与旧 asr-refine 路径保持原样,不要在本仓库直接启用。
[Unit]
Description=Prepare or publish Web-reviewed ASR draft %I
After=network-online.target asr-review-web.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline
EnvironmentFile=/home/claw/claude-workspace/OBSIDIAN/bin/.asr-refine.env
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/bin/asr-refine revise %I
TimeoutStartSec=900
UMask=0077
MemoryMax=512M
@@ -0,0 +1,31 @@
# 现网路径快照。ExecStart / EnvironmentFile / 工作目录保持原样,不要在本仓库直接启用。
[Unit]
Description=Persistent ASR recording intake and review UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/.venv/bin/python -m asrpipe.control_web
Environment=PYTHONPATH=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline
Environment=ASR_RECORDINGS_ROOT=/home/claw/claude-workspace/OBSIDIAN/recordings
Restart=on-failure
RestartSec=3
TimeoutStopSec=10
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/state
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/logs
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/recordings/inbox
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/recordings/state
MemoryMax=192M
TasksMax=32
[Install]
WantedBy=default.target
@@ -0,0 +1,15 @@
# 现网路径快照。EnvironmentFile 与 ExecStart 指向旧目录,保持原样,不要在本仓库直接启用。
[Unit]
Description=Refine new ASR transcripts into Obsidian drafts (standalone, no agent CLI)
After=network-online.target
[Service]
Type=oneshot
# Credentials only. The program needs no agent CLI, no login state, no OAuth —
# just this token and a network route. Token rotation = edit this one file.
EnvironmentFile=/home/claw/claude-workspace/OBSIDIAN/bin/.asr-refine.env
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/bin/asr-refine run-all
# A long meeting is ~90 chunks; S2 is parallel but S4 is one serial strong-model
# call, so allow a generous window for a batch of transcripts.
TimeoutStartSec=3600
@@ -0,0 +1,14 @@
[Unit]
Description=Watch Obsidian .asr-inbox for new finished transcripts
[Path]
# Fires when a finished transcript file is mv'd into .asr-inbox/ (dir mtime changes).
# Markers under .state/ and staging in .incoming/ live in subdirs, so they don't refire.
PathModified=/home/claw/obsidian-vault/.asr-inbox
# 2026-07-25: switched from obsidian-asr-refine.service (headless `claude -p`) to the
# standalone pipeline. The old unit + obsidian-asr-refine.sh are kept intact; to roll
# back, point Unit= back at obsidian-asr-refine.service and daemon-reload.
Unit=obsidian-asr-refine-native.service
[Install]
WantedBy=default.target
@@ -0,0 +1,20 @@
# 现网路径快照。ExecStart 与 MEETING_ASR_BACKEND 保持原样,不要在本仓库直接启用。
[Unit]
Description=Obsidian Meeting Recording Watcher (audio inbox -> ASR -> bridge to .asr-inbox)
After=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/claw/claude-workspace/OBSIDIAN/recordings/bin/meeting-watch.py
Restart=always
RestartSec=10
WorkingDirectory=/home/claw/claude-workspace/OBSIDIAN/recordings
Environment=PYTHONUNBUFFERED=1
Environment=MEETING_INBOX_BASE=/home/claw/claude-workspace/OBSIDIAN/recordings
Environment=MEETING_WEBHOOK_URL=http://127.0.0.1:8644/webhooks/meeting-recording
# ASR backend trial (2026-06-26): route live pipeline through VibeVoice 分角色.
# Revert: remove this line (or set =qwen3) + daemon-reload + restart.
Environment=MEETING_ASR_BACKEND=qwen3cam
[Install]
WantedBy=default.target
@@ -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 构建发布。
## 验收原则
原文不可覆盖;候选纠错须有依据;背景不伪装为录音事实;重要歧义交人工确认。
以关键内容和行动线索为目标,不追求伪精确的逐字恢复。
合成测试与真实录音端到端验收分开,不能以单元测试通过代替上线验收。
本文件是后续开发计划,本轮不新增推理请求、不切换生产服务、不自动部署容器。
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "meeting-asr"
version = "0.2.0"
description = "Standalone meeting transcription, terminology correction and human-reviewed minutes web application"
requires-python = ">=3.11"
dependencies = ["pypinyin>=0.51,<1", "rapidfuzz>=3,<4", "numpy>=1.26,<3"]
[tool.setuptools.packages.find]
include = ["asrpipe*", "asrservice*"]
@@ -0,0 +1,255 @@
#!/usr/bin/env bash
set -euo pipefail
INPUT=${1:?audio file required}
OUT=${2:?output txt required}
ASR_URL=${MEETING_ASR_URL:-http://192.168.50.100:8000/v1/audio/transcriptions}
ASR_MODEL=${MEETING_ASR_MODEL:-qwen3-asr}
SEGMENT_SECONDS=${MEETING_ASR_SEGMENT_SECONDS:-540}
LANGUAGE=${MEETING_ASR_LANGUAGE:-zh}
GLOSSARY=${MEETING_ASR_GLOSSARY:-$HOME/obsidian-vault/minutes/_glossary.md}
# Base dir derived from this script's location (recordings/bin/asr-transcribe.sh -> recordings/),
# overridable via MEETING_INBOX_BASE. Migration-proof.
BASE="${MEETING_INBOX_BASE:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
LOG_DIR="$BASE/logs"
LOG="$LOG_DIR/asr-transcribe.log"
mkdir -p "$LOG_DIR" "$(dirname "$OUT")"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"
}
if [[ ! -f "$INPUT" ]]; then
echo "input file not found: $INPUT" >&2
exit 2
fi
# ---- Cloud / Unified Provider branch (soniox / gemini / openai / grok) ------
case "${MEETING_ASR_BACKEND:-}" in
soniox|gemini|openai|grok)
PROJECT_ROOT="$(cd "$BASE/.." && pwd)"
log "START backend=${MEETING_ASR_BACKEND} input=$INPUT out=$OUT"
if PYTHONPATH="$PROJECT_ROOT:${PYTHONPATH:-}" python3 - "$INPUT" "$OUT" "${MEETING_ASR_BACKEND}" "${MEETING_ASR_MODEL:-}" <<'PY' 2>> "$LOG"
import sys
from pathlib import Path
from asrpipe.providers import transcribe_audio
audio = Path(sys.argv[1])
out = Path(sys.argv[2])
provider = sys.argv[3]
model = sys.argv[4] if len(sys.argv) > 4 else ""
res = transcribe_audio(audio, provider=provider, model=model)
out.write_text(res.transcript_text, encoding="utf-8")
PY
then
log "DONE backend=${MEETING_ASR_BACKEND} out=$OUT chars=$(wc -c < "$OUT")"
exit 0
fi
echo "${MEETING_ASR_BACKEND} transcription failed (see $LOG)" >&2
log "ERROR backend=${MEETING_ASR_BACKEND} failed"
exit 13
;;
esac
# ---- Backend switch (trial): VibeVoice 分角色后端 ----------------------------
# Default backend stays qwen3 (logic below). Set MEETING_ASR_BACKEND=vibevoice
# (e.g. in obsidian-recording-watch.service) to route the live pipeline through
# vibevoice-transcribe.py, producing a diarized transcript (timestamps + S#).
# Interface is preserved: this branch writes the same single $OUT txt the bridge
# expects. Fully reversible: unset the env / restore the .bak.
if [[ "${MEETING_ASR_BACKEND:-qwen3}" == "vibevoice" ]]; then
VIBE_PY="$BASE/bin/vibevoice-transcribe.py"
CHUNK="${MEETING_ASR_VIBE_CHUNK:-200}"
if [[ ! -f "$VIBE_PY" ]]; then
echo "vibevoice backend selected but $VIBE_PY missing" >&2
exit 2
fi
mkdir -p "$BASE/processing"
VOUT=$(mktemp -d "$BASE/processing/vibe.XXXXXX")
log "START backend=vibevoice input=$INPUT chunk=${CHUNK}s out=$OUT"
base=$(basename "$INPUT"); base="${base%.*}"
if python3 "$VIBE_PY" "$INPUT" --out "$VOUT" --chunk "$CHUNK" >> "$LOG" 2>&1 \
&& [[ -f "$VOUT/$base-diarized.txt" ]]; then
cp "$VOUT/$base-diarized.txt" "$OUT"
log "DONE backend=vibevoice out=$OUT chars=$(wc -c < "$OUT")"
rm -rf "$VOUT"
exit 0
fi
echo "vibevoice transcription failed or produced no output (see $LOG)" >&2
log "ERROR backend=vibevoice produced no diarized.txt"
rm -rf "$VOUT"
exit 11
fi
# ---- Backend: Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹 (:10099) ------------------
# MEETING_ASR_BACKEND=qwen3cam (别名 paraformer 向后兼容) 路由到分角色服务。整文件一次
# 请求(FSMN-VAD 内部切段),无客户端分段。输出带说话人标签;声纹命中显示真名,否则
# speaker_N。可用 env 切换回退。热词/postprocess 均已退役(解码软偏置服务端关闭、
# legacy hotword 不生效、确定性预替换抢跑污染),实体纠错统一在 refine 单点处理。
if [[ "${MEETING_ASR_BACKEND:-qwen3}" == "qwen3cam" || "${MEETING_ASR_BACKEND:-qwen3}" == "paraformer" ]]; then
DIAR_URL="${MEETING_DIARIZE_URL:-${MEETING_PARAFORMER_URL:-http://192.168.50.100:10099/asr}}"
log "START backend=qwen3cam input=$INPUT out=$OUT"
RESP=$(mktemp)
# 中文会议固定 Chinese,避免远场噪声触发粤语/日语漂移;英文缩写仍可正常识别。
args=( -sS --max-time 900 -F "file=@$INPUT" -F 'language=Chinese' -F 'save_voiceprint_session=true' )
if curl "${args[@]}" "$DIAR_URL" > "$RESP" 2>> "$LOG"; then
if python3 - "$RESP" "$OUT" <<'PY'
import json, sys
try:
d = json.load(open(sys.argv[1], encoding='utf-8'))
except Exception as e:
sys.stderr.write(f"qwen3cam json parse failed: {e}\n"); sys.exit(1)
if 'detail' in d or not d.get('segments'):
sys.stderr.write(f"qwen3cam no segments: {str(d)[:160]}\n"); sys.exit(1)
vp = d.get('voiceprint', {})
matched = [f"{m['speaker_local']}={m['person_name']}" for m in vp.get('matches', []) if m.get('status') == 'matched']
sid = vp.get('session_id') or ''
# machine-readable line for the voiceprint auto-enroll tool (survives to .asr-inbox)
nclusters = len({s.get('speaker') for s in d['segments']})
with open(sys.argv[2], 'w', encoding='utf-8') as out:
out.write(f"<!-- voiceprint_session: {sid} clusters: {nclusters} -->\n")
hdr = "# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)"
if matched:
hdr += " | 声纹命名: " + ", ".join(matched)
out.write(hdr + "\n\n")
turns = []
for s in d['segments']:
spk = s.get('speaker', '?'); t = (s.get('text') or '').strip()
if not t:
continue
if turns and turns[-1][0] == spk:
turns[-1][1] += t
else:
turns.append([spk, t])
for spk, t in turns:
out.write(f"【{spk}】{t}\n\n")
PY
then
log "DONE backend=qwen3cam out=$OUT chars=$(wc -c < "$OUT")"
rm -f "$RESP"; exit 0
fi
fi
echo "qwen3cam transcription failed (see $LOG)" >&2
log "ERROR backend=qwen3cam failed"
rm -f "$RESP"; exit 12
fi
# ---- default Qwen3 path follows --------------------------------------------
case "$SEGMENT_SECONDS" in
''|*[!0-9]*) echo "MEETING_ASR_SEGMENT_SECONDS must be integer" >&2; exit 2 ;;
esac
if (( SEGMENT_SECONDS < 480 || SEGMENT_SECONDS > 600 )); then
log "WARN segment seconds $SEGMENT_SECONDS outside recommended 480-600; continuing"
fi
if ! command -v ffprobe >/dev/null 2>&1; then
echo "ffprobe not found" >&2
exit 2
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "ffmpeg not found" >&2
exit 2
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl not found" >&2
exit 2
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 not found" >&2
exit 2
fi
HOTWORD_GEN="$HOME/claude-workspace/OBSIDIAN/bin/obsidian-asr-hotwords.py"
build_prompt() {
local terms
# Shared generator: cards ∪ glossary entity terms, mapping wrong-sides excluded,
# ranked by mention count. Fall back to a minimal inline reader if unavailable.
if [[ -f "$HOTWORD_GEN" ]]; then
terms=$(python3 "$HOTWORD_GEN" --budget 1800 2>/dev/null | tr ',' '、')
fi
if [[ -z "$terms" ]]; then
terms=$(python3 - "$GLOSSARY" <<'PY'
import re, sys
from pathlib import Path
p = Path(sys.argv[1]).expanduser()
if not p.exists():
sys.exit(0)
out, seen = [], set()
for raw in p.read_text(errors='ignore').splitlines():
s = raw.strip()
if not s.startswith(('-', '*')) or re.search(r'[→⇒]|->', s):
continue # mapping lines carry the WRONG form on the left — never bias to it
s = re.split(r'\s[—–-]\s|[::]', re.sub(r'^[-*+\d\.\)\s]+', '', s), maxsplit=1)[0].strip()
s = re.sub(r'\[\[|\]\]', '', s).strip()
if s and s not in seen and len(s) <= 30:
seen.add(s); out.append(s)
print('、'.join(out[:200]))
PY
)
fi
[[ -z "$terms" ]] && return 0
printf '以下是会议中可能出现的人名、公司、项目、产品和术语,请优先准确识别这些正确写法:%s' "$terms"
}
DURATION=$(ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 "$INPUT" | awk '{printf "%d", ($1 == int($1) ? $1 : int($1)+1)}')
if [[ -z "$DURATION" || "$DURATION" == "0" ]]; then
echo "failed to detect duration: $INPUT" >&2
exit 3
fi
mkdir -p "$BASE/processing"
TMPDIR=$(mktemp -d "$BASE/processing/asr.XXXXXX")
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT
PROMPT_TEXT=$(build_prompt || true)
: > "$OUT"
log "START input=$INPUT duration=${DURATION}s segment=${SEGMENT_SECONDS}s out=$OUT"
segments=$(( (DURATION + SEGMENT_SECONDS - 1) / SEGMENT_SECONDS ))
failures=0
for ((i=0; i<segments; i++)); do
start=$(( i * SEGMENT_SECONDS ))
end=$(( start + SEGMENT_SECONDS ))
if (( end > DURATION )); then end=$DURATION; fi
seg="$TMPDIR/segment_$((i+1)).mp3"
txt="$TMPDIR/segment_$((i+1)).txt"
printf -- '--- 第%d段 [%02d:%02d - %02d:%02d] ---\n' "$((i+1))" "$((start/60))" "$((start%60))" "$((end/60))" "$((end%60))" >> "$OUT"
ffmpeg -hide_banner -loglevel error -y -ss "$start" -t "$SEGMENT_SECONDS" -i "$INPUT" -ar 16000 -ac 1 -b:a 64k "$seg"
log "SEGMENT $((i+1))/$segments file=$seg size=$(stat -c%s "$seg")"
curl_args=(
-sS --fail-with-body --max-time 600
-F "model=$ASR_MODEL"
-F "file=@$seg"
)
if [[ -n "$LANGUAGE" ]]; then
curl_args+=( -F "language=$LANGUAGE" )
fi
if [[ -n "$PROMPT_TEXT" ]]; then
curl_args+=( -F "prompt=$PROMPT_TEXT" )
fi
if curl "${curl_args[@]}" "$ASR_URL" > "$txt" 2>> "$LOG"; then
python3 - "$txt" >> "$OUT" <<'PY'
import json, sys
from pathlib import Path
s=Path(sys.argv[1]).read_text(errors='ignore').strip()
try:
obj=json.loads(s)
print((obj.get('text') or obj.get('transcript') or s).strip())
except Exception:
print(s)
PY
else
failures=$((failures+1))
printf '[第%d段转录失败]\n' "$((i+1))" >> "$OUT"
log "ERROR segment $((i+1)) failed"
fi
printf '\n\n' >> "$OUT"
done
if (( failures > 0 )); then
log "DONE_WITH_FAILURES failures=$failures out=$OUT"
exit 10
fi
log "DONE out=$OUT chars=$(wc -c < "$OUT")"
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
import fcntl
import hashlib
import hmac
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
PROJECT = Path(__file__).resolve().parents[2]
BASE = Path(os.environ.get("MEETING_INBOX_BASE", str(PROJECT / "recordings")))
INBOX = BASE / "inbox"
PROCESSING = BASE / "processing"
PROCESSED = BASE / "processed"
FAILED = BASE / "failed"
TRANSCRIPTS = BASE / "transcripts"
STATE = BASE / "state"
LOGS = BASE / "logs"
ASR = Path(os.environ.get("MEETING_ASR_SCRIPT", str(PROJECT / "recordings" / "bin" / "asr-transcribe.sh")))
SEEN_FILE = STATE / "seen.json"
UPLOAD_META = STATE / "uploads"
CONTROL_DB = Path(os.environ.get(
"ASR_CONTROL_DB",
str(Path(os.environ.get("ASR_STATE_DIR", str(PROJECT / "state"))) / "intake.db")))
LOG = LOGS / "meeting-watch.log"
POLL_SECONDS = int(os.environ.get("MEETING_WATCH_POLL_SECONDS", "30"))
STABLE_AGE_SECONDS = int(os.environ.get("MEETING_WATCH_STABLE_AGE_SECONDS", "60"))
WEBHOOK_URL = os.environ.get("MEETING_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording")
WEBHOOK_ROUTE = os.environ.get("MEETING_WEBHOOK_ROUTE", "meeting-recording")
WEBHOOK_SECRET = os.environ.get("MEETING_WEBHOOK_SECRET", "")
STATUS_WEBHOOK_URL = os.environ.get("MEETING_STATUS_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording-status")
STATUS_WEBHOOK_ROUTE = os.environ.get("MEETING_STATUS_WEBHOOK_ROUTE", "meeting-recording-status")
SUBSCRIPTIONS = Path.home() / ".hermes" / "webhook_subscriptions.json"
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"}
def log(msg):
LOGS.mkdir(parents=True, exist_ok=True)
with LOG.open("a", encoding="utf-8") as f:
f.write(time.strftime("[%Y-%m-%d %H:%M:%S] ") + msg + "\n")
def load_seen():
try:
return json.loads(SEEN_FILE.read_text())
except Exception:
return {}
def save_seen(seen):
STATE.mkdir(parents=True, exist_ok=True)
tmp = SEEN_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(seen, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(SEEN_FILE)
def upload_meta(filename):
try:
return json.loads((UPLOAD_META / f"{filename}.json").read_text(encoding="utf-8"))
except Exception:
return {}
def update_job(meta, status, detail="", transcript=""):
job_id = str(meta.get("job_id") or "")
if not job_id or not CONTROL_DB.exists():
return
try:
con = sqlite3.connect(CONTROL_DB, timeout=15)
fields, vals = ["status=?", "updated_at=?"], [status, time.time()]
if detail:
fields.append("detail=?"); vals.append(detail[:1000])
if transcript:
fields.append("transcript=?"); vals.append(transcript)
vals.append(job_id)
con.execute(f"UPDATE intake_job SET {','.join(fields)} WHERE id=?", vals)
con.commit()
con.close()
except Exception as exc:
log(f"CONTROL_STATUS_FAILED job={job_id} status={status} error={exc}")
def ensure_job(path, fid):
"""Create a visible control-plane task for files dropped outside Web upload."""
job_id = f"fs-{fid}"
now = time.time()
try:
CONTROL_DB.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(CONTROL_DB, timeout=15)
con.execute("""
CREATE TABLE IF NOT EXISTS intake_job(
id TEXT PRIMARY KEY, original_name TEXT NOT NULL,
stored_name TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '',
transcript TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL,
updated_at REAL NOT NULL, draft TEXT NOT NULL DEFAULT '',
result TEXT NOT NULL DEFAULT ''
)
""")
columns = {row[1] for row in con.execute("PRAGMA table_info(intake_job)")}
for name in ("draft", "result"):
if name not in columns:
con.execute(
f"ALTER TABLE intake_job ADD COLUMN {name} "
"TEXT NOT NULL DEFAULT ''")
con.execute(
"INSERT OR IGNORE INTO intake_job("
"id,original_name,stored_name,title,context,size,status,detail,"
"transcript,created_at,updated_at,draft,result"
") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
(job_id, path.name, path.name, path.stem, "", path.stat().st_size,
"queued", "监控目录已接收", "", now, now, "", ""))
con.commit()
con.close()
return {
"job_id": job_id, "title": path.stem, "context": "",
"original_name": path.name, "stored_name": path.name,
}
except Exception as exc:
log(f"CONTROL_JOB_CREATE_FAILED file={path} error={exc}")
return {}
def duration_seconds(path):
try:
out = subprocess.check_output([
"ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", str(path)
], text=True, timeout=30).strip()
return int(float(out) + 0.999) if out else None
except Exception:
return None
def file_id(path):
st = path.stat()
h = hashlib.sha256()
h.update(str(path.name).encode())
h.update(str(st.st_size).encode())
h.update(str(int(st.st_mtime)).encode())
if st.st_size <= 256 * 1024 * 1024:
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()[:24]
def stable(path):
try:
st1 = path.stat()
if time.time() - st1.st_mtime < STABLE_AGE_SECONDS:
return False
time.sleep(2)
st2 = path.stat()
return st1.st_size == st2.st_size and int(st1.st_mtime) == int(st2.st_mtime)
except FileNotFoundError:
return False
def unique_path(directory, name):
p = directory / name
if not p.exists():
return p
stem, suffix = p.stem, p.suffix
for i in range(1, 10000):
q = directory / f"{stem}-{i}{suffix}"
if not q.exists():
return q
raise RuntimeError(f"cannot allocate unique path for {name}")
def webhook_secret(route=WEBHOOK_ROUTE):
if route == WEBHOOK_ROUTE and WEBHOOK_SECRET:
return WEBHOOK_SECRET
try:
data = json.loads(SUBSCRIPTIONS.read_text(encoding="utf-8"))
return str(data.get(route, {}).get("secret", ""))
except Exception:
return ""
def post_webhook(payload, url=WEBHOOK_URL, route=WEBHOOK_ROUTE, label="WEBHOOK", timeout=30):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json"}
secret = webhook_secret(route)
if secret and secret != "INSECURE_NO_AUTH":
headers["X-Webhook-Signature"] = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
req = urllib.request.Request(
url,
data=data,
headers=headers,
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read(1000).decode("utf-8", errors="ignore")
log(f"{label} status={resp.status} body={body[:300]!r}")
return resp.status
def post_status(status, filename, detail=""):
payload = {"status": status, "filename": filename, "detail": detail}
try:
return post_webhook(
payload,
url=STATUS_WEBHOOK_URL,
route=STATUS_WEBHOOK_ROUTE,
label="STATUS_WEBHOOK",
timeout=10,
)
except Exception as exc:
log(f"STATUS_WEBHOOK_FAILED status={status} file={filename} error={exc}")
return None
import re
_CONSUMER_LOCK = None
ASR_INBOX = Path(os.environ.get("ASR_VAULT", str(Path.home() / "obsidian-vault"))) / ".asr-inbox"
def bridge_to_asr_inbox(transcript, stem, title=""):
"""Atomically drop the finished transcript into the Obsidian .asr-inbox so the
Claude refine pipeline picks it up. Slug carries date+time for the draft name."""
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})[_-](\d{2})[_-](\d{2})[_-](\d{2})", stem)
if m:
y, mo, d, hh, mm, ss = m.groups()
tail = stem[m.end():].strip("_- ")
safe_tail = re.sub(
r"[^\w一-鿿-]+", "_", title or tail).strip("_-")
safe_tail = re.sub(r"-[0-9a-f]{8}$", "", safe_tail)
label = safe_tail or "录音"
slug = f"{y}-{mo}-{d}-{hh}{mm}{ss}-{label}"
else:
safe = re.sub(r"[^\w一-鿿-]+", "_", stem).strip("_") or "录音"
slug = f"{time.strftime('%Y-%m-%d-%H%M%S')}-{safe}"
incoming = ASR_INBOX / ".incoming"
incoming.mkdir(parents=True, exist_ok=True)
(ASR_INBOX / ".state").mkdir(parents=True, exist_ok=True)
# Collision guard: never overwrite an unprocessed transcript.
dest = ASR_INBOX / f"{slug}.txt"
n = 2
while dest.exists():
dest = ASR_INBOX / f"{slug}-{n}.txt"
n += 1
tmp = incoming / dest.name
shutil.copyfile(str(transcript), str(tmp))
tmp.replace(dest) # atomic within the vault filesystem -> triggers the path unit
return dest
def process(path):
seen = load_seen()
fid = file_id(path)
intake = upload_meta(path.name) or ensure_job(path, fid)
if fid in seen:
log(f"SKIP already seen file={path} id={fid}")
return
PROCESSING.mkdir(parents=True, exist_ok=True)
TRANSCRIPTS.mkdir(parents=True, exist_ok=True)
PROCESSED.mkdir(parents=True, exist_ok=True)
FAILED.mkdir(parents=True, exist_ok=True)
proc = unique_path(PROCESSING, path.name)
try:
shutil.move(str(path), str(proc))
except FileNotFoundError:
log(f"SKIP disappeared before claim file={path}")
return
transcript = TRANSCRIPTS / f"{fid}-{proc.stem}.txt"
dur = duration_seconds(proc)
log(f"PROCESS file={proc} id={fid} duration={dur}")
update_job(intake, "transcribing", f"录音时长约 {dur}s" if dur else "正在转写")
post_status("开始转录", proc.name, f"时长约 {dur}s;完成后会继续发送粗整理。")
try:
subprocess.run([str(ASR), str(proc), str(transcript)], check=True, timeout=None)
if intake:
original = transcript.read_text(encoding="utf-8", errors="ignore")
marker = json.dumps({
"job_id": intake.get("job_id", ""),
"title": intake.get("title", ""),
"context": intake.get("context", ""),
}, ensure_ascii=False)
transcript.write_text(f"<!-- intake: {marker} -->\n" + original,
encoding="utf-8")
done = unique_path(PROCESSED, proc.name)
shutil.move(str(proc), str(done))
post_status("转录完成,正在生成粗整理", done.name, f"转录文件:{transcript}")
# Refinement is handled by the Claude Code pipeline (not the Hermes agent
# webhook). Bridge the finished transcript into the Obsidian .asr-inbox so
# obsidian-asr-refine (systemd path unit) produces a rough-outline draft.
bridged = bridge_to_asr_inbox(
transcript, proc.stem, str(intake.get("title") or ""))
update_job(intake, "refining", "转写完成,正在生成整理稿",
transcript=str(transcript))
log(f"BRIDGED transcript -> {bridged}")
seen[fid] = {"filename": done.name, "audio_path": str(done), "transcript_path": str(transcript), "ts": int(time.time())}
save_seen(seen)
log(f"DONE file={done} transcript={transcript}")
except Exception as exc:
fail = unique_path(FAILED, proc.name)
if proc.exists():
shutil.move(str(proc), str(fail))
err = FAILED / f"{fail.name}.error.txt"
err.write_text(str(exc), encoding="utf-8")
post_status("处理失败", fail.name, f"错误:{exc}")
update_job(intake, "failed", f"处理失败:{exc}")
log(f"FAILED file={path} error={exc}")
def once():
for p in sorted(INBOX.iterdir() if INBOX.exists() else []):
if not p.is_file() or p.suffix.lower() not in SUPPORTED:
continue
if stable(p):
process(p)
def hold_consumer_lock():
"""Share ASR_STATE_DIR/consumer.lock with asrservice.worker. Exit if it is taken."""
path = Path(os.environ.get("ASR_CONSUMER_LOCK", str(CONTROL_DB.parent / "consumer.lock")))
path.parent.mkdir(parents=True, exist_ok=True)
handle = path.open("a+")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
log(f"EXIT another recording consumer holds {path}")
raise SystemExit(1)
handle.seek(0)
handle.truncate()
handle.write(str(os.getpid()))
handle.flush()
return handle
def main():
global _CONSUMER_LOCK
_CONSUMER_LOCK = hold_consumer_lock()
for d in [INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, STATE, LOGS]:
d.mkdir(parents=True, exist_ok=True)
if not ASR.exists():
raise SystemExit(f"missing ASR script: {ASR}")
log("START watcher")
if "--once" in sys.argv:
once()
return
while True:
try:
once()
except Exception as exc:
log(f"LOOP_ERROR {exc}")
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
ECAPA-TDNN 声纹 + KMeans 全局说话人统一(在 ASR venv 里跑)。
把 VibeVoice 每块独立的 S# 标签换成全场统一的 G# ID。
用法(由 vibevoice-transcribe.py --unify 调用,也可单独跑):
<venv>/bin/python vibevoice-speaker-unify.py <audio> <segs.json> <out.md> [K]
局限(见 vault: ASR服务横评-2026-06):单麦远场录音里声纹只能可靠锁"主讲人",
次要说话人分不干净。K 默认 4(K>=5 会劈裂主讲人)。
"""
import json, os, sys, subprocess, numpy as np
import torch, soundfile as sf
from speechbrain.inference.speaker import EncoderClassifier
from sklearn.cluster import KMeans
from collections import Counter
audio = sys.argv[1]
segs_path = sys.argv[2]
out_md = sys.argv[3]
K = int(sys.argv[4]) if len(sys.argv) > 4 else 4
MIN_DUR = 1.2
segs = [s for s in json.load(open(segs_path)) if s.get("Content", "").strip()]
work = os.path.join(os.path.dirname(segs_path), "_segwav"); os.makedirs(work, exist_ok=True)
clf = EncoderClassifier.from_hparams(source="speechbrain/spkrec-ecapa-voxceleb",
savedir=os.path.expanduser("~/.cache/ecapa_model"),
run_opts={"device": "cpu"})
emb, idx = [], []
for i, s in enumerate(segs):
if s["End"] - s["Start"] < MIN_DUR:
continue
wp = os.path.join(work, f"{i:04d}.wav")
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", str(s["Start"]),
"-t", str(s["End"] - s["Start"]), "-i", audio, "-ar", "16000", "-ac", "1", wp], check=True)
try:
wav, sr = sf.read(wp)
if len(wav) < 16000 * 0.8:
continue
e = clf.encode_batch(torch.tensor(wav, dtype=torch.float32).unsqueeze(0)).squeeze().detach().numpy()
emb.append(e); idx.append(i)
except Exception as ex:
print("embed fail", i, ex)
emb = np.array(emb)
X = emb / np.linalg.norm(emb, axis=1, keepdims=True)
print(f"embedded {len(emb)} segments; clustering K={K}", flush=True)
km = KMeans(n_clusters=K, n_init=10, random_state=0).fit(X)
gid = {idx[k]: int(km.labels_[k]) for k in range(len(idx))}
es = sorted(gid)
for i, s in enumerate(segs):
s["G"] = gid.get(i) if i in gid else gid[min(es, key=lambda j: abs(segs[j]["Start"] - segs[i]["Start"]))]
# rank G by talk-time (G0 = most)
t = Counter()
for s in segs: t[s["G"]] += s["End"] - s["Start"]
rank = {old: new for new, (old, _) in enumerate(t.most_common())}
for s in segs: s["G"] = rank[s["G"]]
tt = Counter()
for s in segs: tt[s["G"]] += s["End"] - s["Start"]
total = sum(tt.values()) or 1
print("talk-time:", {f"G{k}": f"{round(v)}s/{v/total*100:.0f}%" for k, v in sorted(tt.items())})
def hms(x): x = int(x); return f"{x//60:02d}:{x%60:02d}"
lines = [f"# {os.path.basename(audio)} · 全局统一说话人 (ECAPA+KMeans K={K})",
"> G0=说话最多 · 括号内为 VibeVoice 原始每块标签 · 单麦远场次要说话人分离有限 · 仅草稿\n"]
for s in sorted(segs, key=lambda x: x["Start"]):
lines.append(f"- `{hms(s['Start'])}` **G{s['G']}** (S{s['Speaker']}): {s['Content']}")
open(out_md, "w").write("\n".join(lines))
print(f"wrote {out_md}", flush=True)
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
VibeVoice 分角色转录(手动按需后端,非自动管线)。
把任意音频转成带说话人标签 + 时间戳的草稿。默认管线仍是 Qwen3(asr-transcribe.sh);
这个工具用于"需要谁说了什么"的场景。
用法:
vibevoice-transcribe.py <audio> [--out DIR] [--chunk 200] [--unify]
输出:
<out>/<basename>-diarized.md # 带 `mm:ss` 时间戳 + S# 说话人(每块独立编号)
<out>/<basename>-diarized.txt # 纯文本版
--unify 时额外 -global.md # ECAPA+KMeans 全局统一说话人(需 venv,单麦只能锁主讲)
注意(踩坑见 vault: ASR服务横评-2026-06):
- 服务端 segments 是 JSON 字符串,需 json.loads;非语音段无 Speaker 键。
- 200s 是最佳分段(被服务端 max_time 限,单请求 ~280s 音频上限)。
- 仅出草稿,正式纪要仍需 Kai 确认(memo-inbox 纪律)。
"""
import json, subprocess, os, math, re, sys, argparse
URL = os.environ.get("VIBE_URL", "http://192.168.50.100:10112/v1/audio/transcriptions")
MODEL = "vibevoice-asr-hf-nf4"
GLOSSARY = os.path.expanduser("~/obsidian-vault/minutes/_glossary.md")
SEG_RE = re.compile(r'\{"Start":\s*([\d.]+)\s*,\s*"End":\s*([\d.]+)\s*,\s*"Speaker":\s*(\d+|null)\s*,\s*"Content":\s*"((?:[^"\\]|\\.)*)"\s*\}')
def build_prompt():
"""Reuse glossary wikilinks + terms as a hotword prompt (same spirit as asr-transcribe.sh)."""
terms = []
p = GLOSSARY
if os.path.exists(p):
for raw in open(p, errors="ignore").read().splitlines():
s = raw.strip()
if not s or s.startswith("#") or s.startswith(">"):
continue
terms += [m.strip() for m in re.findall(r'\[\[([^\]|#]+)', s)]
s = re.sub(r'^[-*+\d\.\)\s]+', '', s)
s = re.split(r'\s[—–-]\s|[::]', s, maxsplit=1)[0].strip()
s = re.sub(r'\[\[|\]\]', '', s).strip()
if s and len(s) <= 80:
terms.append(s)
seen, out = set(), []
for t in terms:
if t and t not in seen:
seen.add(t); out.append(t)
if not out:
return ""
return "以下是会议中可能出现的人名、公司、项目、产品和术语,请优先按这些词纠正识别错误:" + " ".join(out[:200])
def parse_segs(d):
raw = d.get("segments")
arr = raw if isinstance(raw, list) else None
if arr is None and isinstance(raw, str):
try: arr = json.loads(raw)
except Exception: arr = None
if arr is None:
body = re.sub(r'^\s*assistant\s*', '', d.get("text", ""))
arr = [{"Start": float(a), "End": float(b),
"Speaker": (None if c == "null" else int(c)), "Content": w}
for a, b, c, w in SEG_RE.findall(body)]
out = []
for s in arr:
if not isinstance(s, dict):
continue
cc = (s.get("Content") or "").strip()
if not cc:
continue
out.append({"Start": float(s.get("Start", 0)), "End": float(s.get("End", 0)),
"Speaker": s.get("Speaker"), "Content": cc})
return out
def is_loop(cc):
return len(cc) > 200 and cc[:18] and cc.count(cc[:18]) > 8
def hms(x):
x = int(x); return f"{x // 60:02d}:{x % 60:02d}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("audio")
ap.add_argument("--out", default=None, help="output dir (default: alongside audio)")
ap.add_argument("--chunk", type=int, default=200)
ap.add_argument("--unify", action="store_true", help="also run ECAPA global speaker unification")
args = ap.parse_args()
audio = os.path.abspath(os.path.expanduser(args.audio))
if not os.path.isfile(audio):
sys.exit(f"audio not found: {audio}")
base = re.sub(r'\.[^.]+$', '', os.path.basename(audio))
outdir = os.path.abspath(args.out) if args.out else os.path.dirname(audio)
os.makedirs(outdir, exist_ok=True)
work = os.path.join(outdir, f".{base}.vibework"); os.makedirs(work, exist_ok=True)
prompt = build_prompt()
dur = float(subprocess.check_output(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nk=1:nw=1", audio]).decode().strip())
n = math.ceil(dur / args.chunk)
print(f"[vibevoice] {os.path.basename(audio)} dur={dur:.0f}s -> {n} chunks of {args.chunk}s", flush=True)
all_segs = []
for i in range(n):
start = i * args.chunk
length = min(args.chunk, dur - start)
mp3 = os.path.join(work, f"c{i:02d}.mp3")
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", str(start),
"-t", str(length), "-i", audio, "-ar", "16000", "-ac", "1", "-b:a", "64k", mp3], check=True)
out_json = os.path.join(work, f"c{i:02d}.json")
mnt = min(4096, 1024 + args.chunk * 12)
rc = subprocess.run(["curl", "-sS", "--max-time", "260", "-F", f"model={MODEL}",
"-F", f"file=@{mp3}", "-F", f"prompt={prompt}",
"-F", f"max_new_tokens={mnt}", URL, "-o", out_json]).returncode
try:
d = json.load(open(out_json)); segs = parse_segs(d)
except Exception as e:
print(f" chunk {i+1}/{n} FAIL ({e}) rc={rc}", flush=True); continue
loops = sum(1 for s in segs if is_loop(s["Content"]))
last = max((s["End"] for s in segs), default=0)
for s in segs:
cc = s["Content"]
if len(cc) > 400:
cc = cc[:120] + " …[复读截断]"
all_segs.append({"Start": start + s["Start"], "End": start + s["End"],
"Speaker": s["Speaker"], "Content": cc})
tag = f" ⚠loop×{loops}" if loops else ""
print(f" chunk {i+1}/{n} @{start:.0f}s: {len(segs)} segs, cov->{last:.0f}/{length:.0f}s{tag}", flush=True)
all_segs.sort(key=lambda x: x["Start"])
md = [f"# {base} · VibeVoice 分角色草稿",
f"> 音频 {dur/60:.0f}min · {args.chunk}s 切块 · 说话人 S# **每块独立编号**(块间需对齐) · 仅草稿待确认\n"]
txt = []
for s in all_segs:
spk = f"S{s['Speaker']}" if s["Speaker"] is not None else "·"
md.append(f"- `{hms(s['Start'])}` **{spk}**: {s['Content']}")
txt.append(f"[{hms(s['Start'])}] {spk}: {s['Content']}")
md_path = os.path.join(outdir, f"{base}-diarized.md")
txt_path = os.path.join(outdir, f"{base}-diarized.txt")
open(md_path, "w").write("\n".join(md))
open(txt_path, "w").write("\n".join(txt))
json.dump(all_segs, open(os.path.join(work, "segs.json"), "w"), ensure_ascii=False)
print(f"[vibevoice] {len(all_segs)} segs -> {md_path}", flush=True)
if args.unify:
unify = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vibevoice-speaker-unify.py")
venv_py = os.environ.get("ASR_VENV_PY",
os.path.expanduser("~/claude-workspace/OBSIDIAN/recordings/asr-venv/bin/python"))
if os.path.exists(venv_py) and os.path.exists(unify):
print("[vibevoice] running ECAPA global speaker unification ...", flush=True)
subprocess.run([venv_py, unify, audio, os.path.join(work, "segs.json"),
os.path.join(outdir, f"{base}-global.md")])
else:
print(f"[vibevoice] --unify skipped: venv ({venv_py}) or unify script missing. "
f"See vault ASR服务横评 to rebuild venv.", flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Send a Telegram status ping through the existing Hermes meeting-recording-status
webhook. Used by the Claude refine step to notify Kai that a draft is ready, reusing
the same signed channel that meeting-watch.py uses for 开始转录/转录完成.
Usage: meeting-status-notify.py "<status>" "<filename>" "<detail>"
Silent no-op (exit 0) if the webhook/secret is unavailable — never block the pipeline.
"""
import hashlib
import hmac
import json
import os
import sys
import urllib.request
from pathlib import Path
URL = os.environ.get("MEETING_STATUS_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording-status")
ROUTE = os.environ.get("MEETING_STATUS_WEBHOOK_ROUTE", "meeting-recording-status")
SUBSCRIPTIONS = Path(os.environ.get("MEETING_SUBSCRIPTIONS_FILE", str(Path.home() / ".hermes" / "webhook_subscriptions.json")))
def secret():
if os.environ.get("MEETING_STATUS_WEBHOOK_SECRET"):
return os.environ["MEETING_STATUS_WEBHOOK_SECRET"]
try:
data = json.loads(SUBSCRIPTIONS.read_text(encoding="utf-8"))
return str(data.get(ROUTE, {}).get("secret", ""))
except Exception:
return ""
def main():
status = sys.argv[1] if len(sys.argv) > 1 else "通知"
filename = sys.argv[2] if len(sys.argv) > 2 else ""
detail = sys.argv[3] if len(sys.argv) > 3 else ""
payload = json.dumps({"status": status, "filename": filename, "detail": detail},
ensure_ascii=False).encode("utf-8")
headers = {"Content-Type": "application/json"}
sec = secret()
if sec and sec != "INSECURE_NO_AUTH":
headers["X-Webhook-Signature"] = hmac.new(sec.encode(), payload, hashlib.sha256).hexdigest()
try:
req = urllib.request.Request(URL, data=payload, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=10) as resp:
print(f"status webhook -> {resp.status}")
except Exception as exc:
print(f"status webhook skipped: {exc}")
if __name__ == "__main__":
main()
@@ -0,0 +1,86 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
TMP = tempfile.TemporaryDirectory()
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
os.environ["ASR_RECORDINGS_ROOT"] = str(Path(TMP.name) / "recordings")
from asrpipe import control_web, review, segment # noqa: E402
class ControlWebTests(unittest.TestCase):
def setUp(self):
self.db_dir = Path(TMP.name) / "control-state"
self.db_dir.mkdir(parents=True, exist_ok=True)
control_web.DB = self.db_dir / "intake.db"
def test_job_state_is_persistent(self):
con = control_web._connect()
now = 100.0
con.execute(
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
("j1", "call.mp3", "stored.mp3", "项目通话", "背景", 123,
"queued", now, now))
con.commit()
con.close()
self.assertTrue(control_web.update_job(
"j1", "transcribing", "正在转写"))
job = control_web._jobs(1)[0]
self.assertEqual(job["status"], "transcribing")
self.assertEqual(job["detail"], "正在转写")
def test_schema_migrates_task_review_fields(self):
con = control_web._connect()
columns = {row["name"] for row in con.execute(
"PRAGMA table_info(intake_job)")}
con.close()
self.assertIn("draft", columns)
self.assertIn("result", columns)
def test_review_queue_is_scoped_to_one_job(self):
con = control_web._connect()
now = 200.0
con.execute(
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
"status,detail,transcript,created_at,updated_at,draft,result) "
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
("job-scope", "scope.wav", "scope.wav", "范围测试", "", 10,
"review", "", "/archive/scope.txt", now, now,
"/draft/scope.md", ""))
con.commit()
con.close()
own = review.enqueue("/archive/scope.txt", "本任务问题?", {
"kind": "draft_question"})
review.enqueue("/archive/other.txt", "其他任务问题?", {
"kind": "draft_question"})
job = control_web._job("job-scope")
rows = control_web._pending_for_job(job)
self.assertEqual([row["id"] for row in rows], [own])
self.assertTrue(control_web._review_url("job-scope").endswith(
"/jobs/job-scope/review/"))
def test_intake_metadata_is_not_transcript_body(self):
metadata = {
"job_id": "abc", "title": "项目通话",
"context": "参与人是张三和李四",
}
raw = (
"<!-- intake: " + json.dumps(metadata, ensure_ascii=False) + " -->\n"
"<!-- voiceprint_session: sess clusters: 1 -->\n"
"# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)\n\n"
"【speaker_0】测试正文。\n")
turns, meta = segment.parse(raw)
self.assertEqual(meta["intake"], metadata)
self.assertEqual(turns[0].text, "测试正文。")
self.assertNotIn("参与人", turns[0].text)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,409 @@
from __future__ import annotations
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
TMP = tempfile.TemporaryDirectory()
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
os.environ["XDG_RUNTIME_DIR"] = TMP.name
from asrpipe import cli, context, flagger, graph, lexicon, numbers, review, revise, vault # noqa: E402
from asrpipe.config import CFG # noqa: E402
from asrpipe.refine import _apply_settled, _queue_ambiguous # noqa: E402
from asrpipe import refine # noqa: E402
from asrpipe.segment import Chunk, Turn # noqa: E402
class CoreTests(unittest.TestCase):
def setUp(self):
CFG.vault.mkdir(parents=True, exist_ok=True)
def test_context_compacts_duplicate_recall(self):
raw = [
"刘冀是张科领弋的基金投资人,目前处于活跃合作状态。",
"刘冀是张科领弋的基金投资人,最近联系日期为2026年6月26日。",
"刘冀是张科领弋的基金投资人,目前处于活跃合作状态。",
]
out = context.compact(["刘冀"], raw, per_entity=2)
self.assertEqual(out.count("目前处于活跃合作状态"), 1)
self.assertLessEqual(out.count("\n- "), 2)
def test_settled_mapping_is_deterministic_and_common_word_is_safe(self):
flags = [
{"span": "老万", "settled": True, "common_word": False,
"candidates": [{"target": "万亚坤"}]},
{"span": "公司", "settled": True, "common_word": True,
"candidates": [{"target": "工时"}]},
]
out, decisions = _apply_settled("老万说公司暂时不投。", flags)
self.assertEqual(out, "万亚坤说公司暂时不投。")
self.assertEqual(decisions[0]["surface"], "老万")
def test_review_answer_becomes_reusable_alias(self):
qid = review.enqueue("fixture-1", "老万是不是万亚坤?", {
"surface": "老万", "candidates": ["万亚坤"],
"sentence": "老万说洛启的事情"})
result = review.answer(qid, "万亚坤")
self.assertTrue(result["learned"])
self.assertIn("万亚坤", [x[0] for x in lexicon.alias_map()["老万"]])
def test_review_answer_is_idempotent(self):
qid = review.enqueue("fixture-idempotent", "是否确认?", {
"surface": "老张", "candidates": ["张成海"]})
first = review.answer(qid, "张成海")
second = review.answer(qid, "另一个人")
self.assertTrue(first["learned"])
self.assertTrue(second["already_answered"])
self.assertEqual(second["answer"], "张成海")
def test_draft_delivery_queues_questions_and_persists_answers(self):
CFG.drafts.mkdir(parents=True, exist_ok=True)
path = CFG.drafts / "web-draft.md"
path.write_text(
"# 草稿\n\n## 通话背景(待确认)\n\n双方讨论项目安排。\n\n"
"## 待你确认\n\n"
"1. 刘总是 [[刘冀]] 还是 [[刘大涛]]?\n"
"2. speaker_0 是谁?\n",
encoding="utf-8")
count = review.enqueue_draft("web-draft-recording", path)
self.assertEqual(count, 4)
rows = [r for r in review.pending(20)
if r["recording"] == "web-draft-recording"]
self.assertEqual(len(rows), 4)
self.assertEqual(json.loads(rows[0]["payload"])["kind"],
"background_check")
question_payload = json.loads(rows[1]["payload"])
self.assertEqual(question_payload["candidates"], ["刘冀", "刘大涛"])
with mock.patch.object(vault, "sync", return_value="disabled"), \
mock.patch.object(review, "_request_revision", return_value=True):
for row, answer_value in zip(
rows, ["背景基本准确", "刘冀", "谭凯", "草稿整体可用"]):
review.answer(row["id"], answer_value)
text = path.read_text(encoding="utf-8")
self.assertIn("Web 人工确认记录", text)
self.assertIn("**回答**:刘冀", text)
self.assertIn("**回答**:草稿整体可用", text)
def test_draft_delivery_prompts_only_substantive_missing_voiceprints(self):
CFG.drafts.mkdir(parents=True, exist_ok=True)
transcripts = Path(TMP.name) / "voiceprint-transcript.txt"
transcripts.write_text(
"【speaker_0】" + "这是高瑶对协议条款和保密范围的详细说明。" * 8 + "\n"
"【speaker_2】嗯。\n"
"【谭凯】好的。\n", encoding="utf-8")
path = CFG.drafts / "voiceprint-draft.md"
path.write_text(
"---\nstatus: 草稿待确认\n"
f"transcript: {transcripts}\n"
"voiceprint_session: test-session\n"
"voiceprint_speakers:\n speaker_1: 谭凯\n---\n\n"
"# 草稿\n\n## 通话背景(待确认)\n\n协议修改沟通。\n\n"
"## 待你确认\n\n1. speaker_0 身份是谁?\n",
encoding="utf-8")
review.enqueue_draft("voiceprint-recording", path)
rows = [r for r in review.pending(20)
if r["recording"] == "voiceprint-recording"]
kinds = [json.loads(r["payload"]).get("kind") for r in rows]
self.assertEqual(kinds.count("voiceprint"), 1)
voice = next(json.loads(r["payload"]) for r in rows
if json.loads(r["payload"]).get("kind") == "voiceprint")
self.assertEqual(voice["speaker_id"], "speaker_0")
self.assertFalse(any("speaker_2" in r["question"] for r in rows))
def test_entity_card_suggestion_counts_distinct_formal_sources(self):
con = lexicon.connect()
con.execute(
"INSERT INTO entity_mention(entity,kind,source,occurred_at) "
"VALUES(?,?,?,?)", ("高瑶", "人名", "minute:older.md", 1))
con.commit()
con.close()
CFG.drafts.mkdir(parents=True, exist_ok=True)
path = CFG.drafts / "entity-suggestion.md"
path.write_text(
"---\npeople: [高瑶]\ncompanies: []\n---\n\n"
"# 草稿\n\n## 通话背景(待确认)\n\n协议沟通。\n\n"
"## 待你确认\n\n", encoding="utf-8")
review.enqueue_draft("entity-suggestion-recording", path)
rows = [r for r in review.pending(20)
if r["recording"] == "entity-suggestion-recording"]
cards = [json.loads(r["payload"]) for r in rows
if json.loads(r["payload"]).get("kind") == "entity_card"]
self.assertEqual(len(cards), 1)
self.assertEqual(cards[0]["entity"], "高瑶")
self.assertEqual(cards[0]["entity_kind"], "人名")
def test_first_review_prepares_preview_without_publishing(self):
CFG.drafts.mkdir(parents=True, exist_ok=True)
path = CFG.drafts / "publish-fixture.md"
path.write_text(
"---\ndate: 2026-07-26\ntype: minutes\n"
"tags: [会议纪要, 草稿, 待确认]\nsource: 录音转录\n"
"status: 草稿待确认\npeople: [谭凯]\ncompanies: []\n"
"transcript: /archive/publish.txt\n---\n\n# 粗整理草稿\n\n"
f"{review.FEEDBACK_START}\n## Web 人工确认记录\n\n"
"1. **问题**:背景?\n - **回答**:准确\n\n"
f"{review.FEEDBACK_END}\n", encoding="utf-8")
formal = """---
date: 2026-07-26
type: minutes
tags: [会议纪要]
source: 录音转录
people: [谭凯]
companies: []
transcript: /archive/publish.txt
---
# 谭凯通话测试
> [!summary] Summary
> 测试正式发布。
"""
with mock.patch.object(revise, "chat", return_value=formal), \
mock.patch("asrpipe.review_web.request_start", return_value=True):
self.assertTrue(revise.revise(path))
self.assertTrue(path.exists())
self.assertIn("status: 待发布确认", path.read_text())
approvals = [
r for r in review.pending(100)
if json.loads(r["payload"]).get("draft_path") == str(path)
and json.loads(r["payload"]).get("kind") == "publish_approval"
]
self.assertEqual(len(approvals), 1)
destination = CFG.minutes / "2026-07-26-谭凯通话测试.md"
self.assertFalse(destination.exists())
with mock.patch.object(review, "_request_revision", return_value=False):
review.answer(approvals[0]["id"], "发布正式稿")
with mock.patch.object(vault, "sync", return_value="pushed"), \
mock.patch.object(vault, "notify"), \
mock.patch.object(graph, "sync", return_value={}):
self.assertTrue(revise.revise(path))
self.assertTrue(destination.exists())
self.assertNotIn("草稿待确认", destination.read_text())
self.assertNotIn("待发布确认", destination.read_text())
self.assertFalse(path.exists())
self.assertTrue((CFG.state_dir / "published" / path.name).exists())
def test_normalize_can_disable_review_side_effects(self):
chunk = Chunk(0, [Turn("", "老万说洛启项目。", 0)])
flags = [{
"span": "老万", "settled": False, "common_word": False,
"sentence": "老万说洛启项目。",
"candidates": [{
"target": "万亚坤", "kind": "人名", "score": 0.9,
"rule": "alias", "source": "alias-card",
}],
}]
usage = refine.Usage()
with mock.patch.object(refine, "_queue_ambiguous") as queue, \
mock.patch.object(refine, "chat_many",
return_value=["老万说洛启项目。"]):
_parts, _decisions, stats = refine.normalize_chunks(
[chunk], [chunk.body], flags, "", "dry-run-fixture", usage,
queue_review=False)
queue.assert_not_called()
self.assertEqual(stats["review_queued"], 0)
def test_atomic_write_and_recovery_lookup(self):
CFG.drafts.mkdir(parents=True, exist_ok=True)
p = CFG.drafts / "fixture.md"
vault.write_draft_file(p, "---\ntranscript: /archive/a.txt\n---\nbody")
self.assertEqual(vault.find_draft_for_transcript("/archive/a.txt"), p)
leftovers = list(CFG.drafts.glob(".fixture.md.*"))
self.assertEqual(leftovers, [])
def test_relation_card_is_structured(self):
lexicon.upsert_relation("刘冀", "投资", "张科领弋", confidence=0.9,
source="test")
card = lexicon.relationship_card({"刘冀"})
self.assertTrue(any("刘冀 投资 张科领弋" in x for x in card))
def test_confirmed_correction_enters_personal_confusion_channel(self):
lexicon.learn_correction("张泰海", "张成海", confirmed=1,
note="ASR fixture")
rows = lexicon.confusion_map()["张泰海"]
self.assertTrue(any(canon == "张成海" and confirmed
for canon, _n, confirmed in rows))
def test_confirmed_correction_teaches_directional_syllable_error(self):
lexicon.learn_correction("张泰海", "张成海", confirmed=1)
flagger._PERSONAL_SYL = None
heard, intended = flagger.to_syls("泰")[0], flagger.to_syls("成")[0]
self.assertGreater(flagger.syl_sim(heard, intended, personal=True), 0.90)
self.assertLess(flagger.syl_sim(heard, intended, personal=False), 0.90)
def test_graph_indexes_minutes_and_recency(self):
for sub in ("People", "Companies", "Projects", "minutes"):
(CFG.vault / sub).mkdir(parents=True, exist_ok=True)
(CFG.vault / "People" / "刘冀.md").write_text(
"---\ncompany: 张科领弋\nrole: 投资人\n---\n", encoding="utf-8")
(CFG.vault / "Companies" / "张科领弋.md").write_text(
"---\n---\n", encoding="utf-8")
(CFG.vault / "Projects" / "杭州基金.md").write_text(
"---\n---\n", encoding="utf-8")
(CFG.vault / "minutes" / "2026-07-25-test.md").write_text(
"---\ndate: 2026-07-25\npeople: [刘冀]\ncompanies: [张科领弋]\n"
"---\n[[刘冀]]参与[[杭州基金]],并与[[张科领弋]]合作。\n",
encoding="utf-8")
result = graph.sync(force=True)
self.assertGreaterEqual(result["relations_written"], 3)
weights = graph.context_weights({"刘冀"})
self.assertIn("张科领弋", weights)
self.assertGreater(weights["张科领弋"], 0)
def test_number_normalization_is_typed_and_conservative(self):
text = "七月十日打款一千万元,BF四幺八二项目占百分之十五,共三十人,两万五九份待核。"
out, decisions = numbers.normalize(text)
self.assertIn("7月10日", out)
self.assertIn("1000万元", out)
self.assertIn("BF4182", out)
self.assertIn("15%", out)
self.assertIn("30人", out)
self.assertIn("两万五九份", out)
self.assertTrue(any(d["raw"] == "两万五九" and d["confidence"] < 0.8
for d in decisions))
def test_generic_discourse_counter_stays_chinese(self):
out, _ = numbers.normalize("我同步两个事,第一个是条款,第二个是公告。")
self.assertEqual(out, "我同步两个事,第一个是条款,第二个是公告。")
def test_financial_scale_without_currency_is_normalized_from_context(self):
out, decisions = numbers.normalize("八月份投资两千万,后续再退出。")
self.assertIn("投资2000万元", out)
self.assertTrue(any(d["kind"] == "money" for d in decisions))
def test_process_lock_remains_held(self):
handle = cli._lock()
lock_path = str(Path(TMP.name) / "asr-pipeline.lock")
code = (
"import fcntl,sys;"
f"f=open({lock_path!r},'w');"
"\ntry: fcntl.flock(f,fcntl.LOCK_EX|fcntl.LOCK_NB)"
"\nexcept OSError: sys.exit(23)"
"\nsys.exit(0)"
)
r = subprocess.run([os.sys.executable, "-c", code])
self.assertEqual(r.returncode, 23)
handle.close()
cli._LOCK_HANDLE = None
def test_staged_pipeline_smoke_without_network(self):
old = (CFG.use_termbank, CFG.use_hindsight, CFG.use_keyword_recall,
CFG.use_normalize, CFG.use_evidence)
CFG.use_termbank = False
CFG.use_hindsight = False
CFG.use_keyword_recall = False
CFG.use_normalize = False
CFG.use_evidence = True
def fake_many(_model, jobs, *, system=None, **_kw):
if system == refine.prompts.CLEAN_SYS:
return ["【谭凯】讨论洛启项目,下一步确认投资条款。" for _ in jobs]
return ["- [片段 0] 决策|洛启项目|下一步确认投资条款|置信度:明确"
for _ in jobs]
try:
with mock.patch.object(refine, "chat_many", side_effect=fake_many), \
mock.patch.object(refine, "chat", return_value="# 粗整理\n\n确认投资条款。"):
result = refine.refine(
"【谭凯】嗯,讨论洛启项目,然后下一步确认投资条款。",
date="2026-07-26", transcript_path="/archive/test.txt")
self.assertIn("确认投资条款", result.draft)
self.assertIn("[片段 0]", result.evidence)
self.assertEqual(result.stats["n_chunks"], 1)
finally:
(CFG.use_termbank, CFG.use_hindsight, CFG.use_keyword_recall,
CFG.use_normalize, CFG.use_evidence) = old
def test_pure_phonetic_guess_does_not_interrupt_human(self):
groups = [{
"span": "也说", "settled": False, "common_word": False,
"sentence": "但是他也说他肯定不会反驳",
"candidates": [{
"target": "叶硕", "kind": "人名", "score": 0.91,
"rule": "full", "source": "lexicon-entity",
"boost": 0, "support": "",
}],
}]
self.assertEqual(_queue_ambiguous("fixture-junk", groups), 0)
def test_context_adjudicator_must_explicitly_request_human_review(self):
groups = [{
"span": "老万", "settled": False, "common_word": False,
"sentence": "老万说洛启项目。",
"candidates": [{
"target": "万亚坤", "kind": "人名", "score": 0.92,
"rule": "alias", "source": "alias-card",
"boost": 1, "support": "关系图",
}],
}]
self.assertEqual(
_queue_ambiguous("fixture-context-decided", groups,
"老万说洛启项目。"), 0)
self.assertEqual(
_queue_ambiguous("fixture-context-uncertain", groups,
"[?万亚坤]说洛启项目。"), 1)
def test_common_words_block_embedded_name_guesses(self):
cases = [
("小问", "UPS是某个小问题", ["万亚坤", "万津"]),
("用介", "包括所有的这些公用介质报告", ["邓永杰"]),
("们总", "我上次问他们总监", ["卢萌"]),
("经签", "所有方案已经签掉", ["徐劲前"]),
("统统", "三批之间统统计一下", ["薛彤彤"]),
("峰总", "雷峰总这边的开发部", ["冯昆仑"]),
]
for i, (surface, sentence, choices) in enumerate(cases):
groups = [{
"span": surface, "settled": False, "common_word": False,
"sentence": sentence,
"candidates": [{
"target": target, "kind": "人名", "score": 0.92,
"rule": "alias", "source": "alias-card",
"boost": 1, "support": "关系图",
} for target in choices],
}]
marker = f"[?{'/'.join(choices)}]"
self.assertEqual(
_queue_ambiguous(f"fixture-common-{i}", groups, marker), 0)
def test_archive_resolution_uses_content_when_timestamp_collides(self):
old_obsidian = CFG.obsidian
root = Path(TMP.name) / "archive-resolution"
archive = root / "recordings" / "transcripts"
archive.mkdir(parents=True, exist_ok=True)
inbox = root / "2026-07-27-095902-录音-2.txt"
inbox.write_text("wav transcript", encoding="utf-8")
mp3 = archive / "aaa-2026-07-27_09_59_02.txt"
wav = archive / "bbb-2026-07-27_09_59_02.txt"
mp3.write_text("mp3 transcript", encoding="utf-8")
wav.write_text("wav transcript", encoding="utf-8")
CFG.obsidian = root
try:
self.assertEqual(
cli.resolve_archive("2026-07-27-095902-录音-2", inbox),
str(wav))
finally:
CFG.obsidian = old_obsidian
def test_observed_neighbour_needs_more_than_recent_weight(self):
groups = [{
"span": "们七", "settled": False, "common_word": False,
"sentence": "你们七月二十号签完",
"candidates": [{
"target": "范梦奇", "kind": "人名", "score": 0.96,
"rule": "observed", "source": "observed-correction",
"via": "范梦奇", "boost": 1.2, "support": "近期权重4.0",
}],
}]
self.assertEqual(_queue_ambiguous("fixture-observed-junk", groups), 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,47 @@
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class PortabilityTests(unittest.TestCase):
def probe(self, code, overrides):
env = {**os.environ, **overrides, "PYTHONPATH": str(ROOT),
"PYTHONDONTWRITEBYTECODE": "1"}
return subprocess.check_output([sys.executable, "-c", code],
env=env, text=True).strip()
def test_workspace_and_notifier_are_explicit(self):
with tempfile.TemporaryDirectory() as tmp:
result = self.probe(
"from asrpipe.config import CFG; print(CFG.obsidian); print(CFG.notifier)",
{"ASR_WORKSPACE": tmp, "ASR_NOTIFIER": tmp + "/notify.py"})
self.assertEqual(result.splitlines(), [tmp, tmp + "/notify.py"])
def test_watcher_does_not_assume_claude_workspace(self):
with tempfile.TemporaryDirectory() as tmp:
result = self.probe(
"import runpy; from pathlib import Path; "
"d=runpy.run_path('recordings/bin/meeting-watch.py',run_name='probe'); "
"print(d['CONTROL_DB']); print(d['ASR_INBOX'])",
{"ASR_STATE_DIR": tmp + "/state", "ASR_VAULT": tmp + "/vault"})
self.assertEqual(result.splitlines(),
[tmp + "/state/intake.db", tmp + "/vault/.asr-inbox"])
def test_notifier_accepts_external_webhook_without_sending(self):
result = self.probe(
"import runpy; d=runpy.run_path('scripts/meeting-status-notify.py',run_name='probe'); "
"print(d['URL']); print(d['secret']() == 'synthetic-only')",
{"MEETING_STATUS_WEBHOOK_URL": "http://example.invalid/hook",
"MEETING_STATUS_WEBHOOK_SECRET": "synthetic-only"})
self.assertEqual(result.splitlines(), ["http://example.invalid/hook", "True"])
if __name__ == "__main__":
unittest.main()
@@ -235,29 +235,42 @@ class ProviderAndServiceTests(unittest.TestCase):
q2 = review.enqueue(str(t1), "第一场问题B?", {"kind": "draft_question", "candidates": ["选项B1"]})
q3 = review.enqueue(str(t2), "第二场问题C?", {"kind": "draft_question", "candidates": ["选项C1"]})
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
os.environ.pop("ASR_WEB_ALLOW_ANON", None)
svc_web.AGENT_TOKEN = "test-agent-token"
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
port = server.server_address[1]
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
import http.cookiejar
from asrpipe import webauth
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
login_body = urllib.parse.urlencode({"password": "test-web-password"}).encode()
opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/login", data=login_body, method="POST",
), timeout=5)
def fetch(path: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
headers=headers or {},
)
with urllib.request.urlopen(req, timeout=5) as resp:
with opener.open(req, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
def post_form(path: str, data: dict[str, str]) -> tuple[int, str]:
encoded = urllib.parse.urlencode(data).encode("utf-8")
payload = dict(data)
payload.setdefault("csrf", webauth.csrf_token())
encoded = urllib.parse.urlencode(payload).encode("utf-8")
req = urllib.request.Request(
f"http://127.0.0.1:{port}{path}",
data=encoded,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
with opener.open(req, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
status, body = fetch("/health")
@@ -0,0 +1,375 @@
"""Regression checks for the review and the 8788 black-box report."""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unittest import mock
if "asrservice.config" not in sys.modules:
_boot = tempfile.mkdtemp(prefix="asr-harden-")
os.environ["ASR_DATA_DIR"] = str(Path(_boot) / "data")
os.environ["ASR_STATE_DIR"] = str(Path(_boot) / "state")
os.environ["ASR_VAULT"] = str(Path(_boot) / "vault")
os.environ["ASR_NOTIFY"] = "0"
os.environ["ASR_GIT_SYNC"] = "0"
os.environ["ASR_AGENT_TOKEN"] = "test-agent-token"
from asrpipe import locking, refine, review, webauth # noqa: E402
from asrpipe.config import Config # noqa: E402
from asrpipe import providers # noqa: E402
from asrservice import config as svc_cfg # noqa: E402
from asrservice import db as svc_db # noqa: E402
from asrservice import web as svc_web # noqa: E402
from asrservice import worker # noqa: E402
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
def _opener(follow=True):
if follow:
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
return urllib.request.build_opener(_NoRedirect)
def _start_server():
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, server.server_address[1]
def _raw(port: int, payload: bytes) -> bytes:
sock = socket.create_connection(("127.0.0.1", port), timeout=5)
try:
sock.sendall(payload)
sock.shutdown(socket.SHUT_WR)
chunks = []
while True:
part = sock.recv(65536)
if not part:
break
chunks.append(part)
return b"".join(chunks)
finally:
sock.close()
class HardeningTests(unittest.TestCase):
def setUp(self):
os.environ.pop("ASR_WEB_ALLOW_ANON", None)
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
os.environ["ASR_NOTIFY"] = "0"
os.environ["ASR_GIT_SYNC"] = "0"
svc_cfg.ensure_dirs()
svc_db.connect().close()
self._hindsight = Config.use_hindsight.__get__(svc_web.CFG, type(svc_web.CFG))
svc_web.CFG.use_hindsight = False
def tearDown(self):
svc_web.CFG.use_hindsight = self._hindsight
def test_closed_without_password_and_csrf_and_bad_length(self):
os.environ["ASR_WEB_PASSWORD"] = ""
server, port = _start_server()
try:
opener = _opener(False)
with self.assertRaises(urllib.error.HTTPError) as caught:
opener.open(f"http://127.0.0.1:{port}/review/", timeout=5)
self.assertEqual(caught.exception.code, 303)
self.assertIn("/login", caught.exception.headers.get("Location", ""))
page = urllib.request.urlopen(f"http://127.0.0.1:{port}/login", timeout=5).read().decode()
self.assertIn("ASR_WEB_PASSWORD", page)
raw = _raw(port, b"POST /login HTTP/1.0\r\nContent-Length: abc\r\n\r\n")
self.assertIn(b" 400 ", raw)
self.assertNotIn(b"Empty", raw)
finally:
server.shutdown()
server.server_close()
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
def test_pages_hide_secrets_escape_titles_and_reject_bad_edits(self):
svc_db.save_settings({
"litellm_api_key": "sk-SECRET-abcd-wxyz",
"diarize_url": "http://192.168.50.100:10099/asr",
})
jid = "abc123abc123abc123abc123"
title = "X');window.__xss=1;//"
svc_db.create_job(jid, "录音.wav", "sample.wav", title=title, size=100, source="web")
audio = svc_cfg.PROCESSED / "sample.wav"
audio.write_bytes(b"0123456789" * 10)
draft = svc_cfg.DRAFTS / f"{jid}.md"
draft.write_text("原文一行\n", encoding="utf-8")
result = svc_cfg.RESULTS / "2026-纪要.md"
result.write_text("# 纪要\n", encoding="utf-8")
svc_db.update_job(jid, "complete", "完成", draft=str(draft), result=str(result))
q1 = review.enqueue("rec-a", "第一题?", {"kind": "draft_question", "candidates": ["甲"]})
q2 = review.enqueue("rec-b", "第二题?", {"kind": "draft_question", "candidates": ["乙"]})
server, port = _start_server()
try:
opener = _opener(True)
opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/login",
data=urllib.parse.urlencode({"password": "test-web-password"}).encode(),
method="POST",
), timeout=5)
home = opener.open(f"http://127.0.0.1:{port}/", timeout=5).read().decode()
self.assertNotIn('onclick="delOne(', home)
self.assertIn("data-del", home)
settings = opener.open(f"http://127.0.0.1:{port}/settings", timeout=5).read().decode()
self.assertNotIn("192.168.50.100", settings)
self.assertNotIn("sk-SECRET", settings)
self.assertIn("已配置", settings)
review_html = opener.open(f"http://127.0.0.1:{port}/review/", timeout=5).read().decode()
forms = review_html.split('<form class="q-item"')
self.assertGreaterEqual(len(forms), 3)
for form in forms[1:]:
head = form.split("</form>", 1)[0]
self.assertLess(head.find('name="single_custom"'), head.find("custom_"))
missing = _raw(
port,
(
"POST /jobs/%s/meta HTTP/1.0\r\nHost: 127.0.0.1\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Cookie: asr_session=%s\r\nContent-Length: 10\r\n\r\ntitle=nope"
% (jid, webauth.session_token())
).encode(),
)
self.assertIn(b" 403 ", missing)
edit = urllib.parse.urlencode({
"csrf": webauth.csrf_token(),
"target": "../../x",
"content": "x",
}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=edit, method="POST",
)
body = opener.open(req, timeout=5).read().decode()
self.assertIn("未保存", body)
self.assertEqual(draft.read_text(encoding="utf-8"), "原文一行\n")
empty = urllib.parse.urlencode({
"csrf": webauth.csrf_token(), "target": "draft", "content": " ",
}).encode()
body = opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=empty, method="POST",
), timeout=5).read().decode()
self.assertIn("内容为空", body)
header = (
f"POST /jobs/{jid}/edit HTTP/1.0\r\nCookie: asr_session={webauth.session_token()}\r\n"
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3000000\r\n\r\n"
).encode()
huge = _raw(port, header)
self.assertIn(b" 413 ", huge)
art = urllib.request.Request(f"http://127.0.0.1:{port}/jobs/{jid}/artifact/result")
with opener.open(art, timeout=5) as resp:
disp = resp.headers.get("Content-Disposition", "")
text = resp.read().decode()
disp.encode("latin-1")
self.assertIn("filename*=UTF-8''", disp)
self.assertIn("filename=\"download.md\"", disp)
self.assertIn("纪要", text)
ranged = urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/audio",
headers={"Range": "bytes=80-10"},
)
try:
opener.open(ranged, timeout=5)
self.fail("reverse range should be rejected")
except urllib.error.HTTPError as exc:
self.assertEqual(exc.code, 416)
self.assertIn("bytes */100", exc.headers.get("Content-Range", ""))
suffix = urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/audio",
headers={"Range": "bytes=-10"},
)
with opener.open(suffix, timeout=5) as resp:
self.assertEqual(resp.status, 206)
self.assertEqual(resp.headers.get("Content-Range"), "bytes 90-99/100")
self.assertEqual(len(resp.read()), 10)
try:
opener.open(f"http://127.0.0.1:{port}/no-such-page", timeout=5)
self.fail("missing page should be 404")
except urllib.error.HTTPError as exc:
self.assertEqual(exc.code, 404)
self.assertIn("页面不存在", exc.read().decode())
finally:
server.shutdown()
server.server_close()
review.delete_for_job(transcript_path="rec-a")
review.delete_for_job(transcript_path="rec-b")
self.assertIsNotNone(q1)
self.assertIsNotNone(q2)
def test_busy_retry_does_not_move_processing_audio(self):
jid = "fedcba9876543210fedcba98"
name = "busy.wav"
svc_db.create_job(jid, name, name, title="进行中", size=4, source="web")
audio = svc_cfg.PROCESSING / name
audio.write_bytes(b"wave")
svc_db.update_job(jid, "transcribing", "正在转写")
server, port = _start_server()
try:
opener = _opener(True)
opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/login",
data=urllib.parse.urlencode({"password": "test-web-password"}).encode(),
method="POST",
), timeout=5)
body = urllib.parse.urlencode({
"csrf": webauth.csrf_token(),
"title": "进行中",
"provider": "not-a-real-engine",
"next_action": "retry",
}).encode()
page = opener.open(urllib.request.Request(
f"http://127.0.0.1:{port}/jobs/{jid}/meta", data=body, method="POST",
), timeout=5).read().decode()
self.assertIn("未重新排队", page)
self.assertIn("未知引擎", page)
self.assertTrue(audio.is_file())
self.assertFalse((svc_cfg.INBOX / name).exists())
self.assertEqual(svc_db.get_job(jid)["status"], "transcribing")
finally:
server.shutdown()
server.server_close()
def test_revision_failure_keeps_marker_and_answers(self):
jid = "a1b2c3d4e5f60718293a4b5c"
transcript = svc_cfg.TRANSCRIPTS / f"{jid}.txt"
transcript.write_text("【speaker_0】原文\n", encoding="utf-8")
draft = svc_cfg.DRAFTS / f"{jid}.md"
draft.write_text("---\ntype: minutes\n---\n# 稿\n", encoding="utf-8")
svc_db.create_job(jid, "a.wav", "a.wav", title="重精修", size=1, source="web")
svc_db.update_job(jid, "review", "待确认", transcript=str(transcript), draft=str(draft))
qid = review.enqueue(str(transcript), "已确认的问题?", {
"kind": "draft_question", "draft_path": str(draft), "candidates": ["保留"],
})
review.answer(qid, "保留")
pending_id = review.enqueue(str(transcript), "仍待确认?", {
"kind": "draft_question", "draft_path": str(draft), "candidates": ["甲"],
})
with mock.patch("asrservice.worker.refine.refine", side_effect=RuntimeError("llm down")):
with self.assertRaises(RuntimeError):
worker.rerefine_job(svc_db.get_job(jid))
answered = review.recent_answered(20)
self.assertIn(qid, [row["id"] for row in answered])
self.assertIn(pending_id, [row["id"] for row in review.pending(50)])
marker = svc_cfg.REVISION_QUEUE / f"revise-{jid}.json"
marker.write_text(json.dumps({
"action": "revise", "job_id": jid, "draft": "/missing/draft.md",
}), encoding="utf-8")
worker.process_revisions()
self.assertTrue(marker.is_file())
payload = json.loads(marker.read_text(encoding="utf-8"))
self.assertEqual(payload["attempts"], 1)
self.assertEqual(svc_db.get_job(jid)["status"], "review")
def test_recover_processing_and_shared_lock_and_stream(self):
jid = "00112233445566778899aabb"
name = f"stuck-{jid[:8]}.wav"
audio = svc_cfg.PROCESSING / name
audio.write_bytes(b"stuck")
svc_db.create_job(jid, name, name, title="卡住", size=5, source="web")
svc_db.update_job(jid, "transcribing", "中断")
worker.recover()
self.assertFalse(audio.exists())
parked = svc_cfg.INBOX / name
self.assertTrue(parked.is_file())
self.assertEqual(svc_db.get_job(jid)["status"], "queued")
worker.beat()
self.assertTrue((svc_cfg.STATE / "worker.heartbeat").is_file())
lock_path = Path(tempfile.mkdtemp()) / "consumer.lock"
handle = locking.acquire_recording_lock(lock_path)
try:
with self.assertRaises(SystemExit):
locking.acquire_recording_lock(lock_path)
finally:
handle.close()
blob = b"STREAMTEST" + b"q" * (256 * 1024)
audio_path = Path(tempfile.mkdtemp()) / "stream.wav"
audio_path.write_bytes(blob)
seen = {}
class Capture(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers["Content-Length"])
body = self.rfile.read(n)
seen["n"] = len(body)
seen["head"] = b"STREAMTEST" in body
seen["tail"] = body.endswith(b"q" + b"\r\n--" + body.split(b"----ASRBoundary", 1)[0][:0] or b"q")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok": true}')
def log_message(self, *_):
pass
cap = ThreadingHTTPServer(("127.0.0.1", 0), Capture)
threading.Thread(target=cap.serve_forever, daemon=True).start()
try:
out = providers._http_multipart(
f"http://127.0.0.1:{cap.server_address[1]}/up",
fields={"model": "unit"},
file_field="file",
file_path=audio_path,
timeout=10,
retries=1,
)
finally:
cap.shutdown()
cap.server_close()
self.assertTrue(out["ok"])
self.assertGreater(seen["n"], len(blob))
self.assertTrue(seen["head"])
def test_wording_timezone_git_default_and_lexicon_page(self):
self.assertEqual(
refine.scrub_internal_wording("卡片未提供职务,卡片提及张三"),
"记录未写明职务,录音中提到张三",
)
stamp = datetime(2026, 11, 2, 13, 8, 26, tzinfo=timezone.utc).timestamp()
self.assertEqual(svc_web.fmt_ts(stamp), "2026-11-02 21:08:26")
header = worker.intake_header({"context": "a-->b } c"})
self.assertEqual(header.count("-->"), 1)
self.assertTrue(header.endswith("-->"))
self.assertNotIn("a-->", header)
self.assertTrue(header.startswith("<!-- intake:"))
for i in range(3):
from asrpipe import lexicon
lexicon.learn_alias(f"别名{i}", f"规范{i}", origin="manual")
from asrpipe import lexicon
self.assertGreaterEqual(lexicon.count_aliases(q="别名"), 3)
page = lexicon.list_aliases(q="别名", limit=2, offset=0)
self.assertEqual(len(page), 2)
env = os.environ.copy()
env.pop("ASR_GIT_SYNC", None)
env.pop("ASR_NOTIFY", None)
env["ASR_STATE_DIR"] = tempfile.mkdtemp()
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
out = subprocess.check_output(
[sys.executable, "-c",
"from asrpipe.config import Config; c=Config(); print(int(c.git_sync), int(c.notify))"],
env=env, text=True,
).strip()
self.assertEqual(out, "0 0")
if __name__ == "__main__":
unittest.main()