diff --git a/servers/tools-pve/asr-pipeline/.env.example b/servers/tools-pve/asr-pipeline/.env.example index 2daba59..9ec61af 100644 --- a/servers/tools-pve/asr-pipeline/.env.example +++ b/servers/tools-pve/asr-pipeline/.env.example @@ -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 diff --git a/servers/tools-pve/asr-pipeline/.gitignore b/servers/tools-pve/asr-pipeline/.gitignore index 384239e..e80eb26 100644 --- a/servers/tools-pve/asr-pipeline/.gitignore +++ b/servers/tools-pve/asr-pipeline/.gitignore @@ -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__/ diff --git a/servers/tools-pve/asr-pipeline/AGENTS.md b/servers/tools-pve/asr-pipeline/AGENTS.md new file mode 100644 index 0000000..d0e155c --- /dev/null +++ b/servers/tools-pve/asr-pipeline/AGENTS.md @@ -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 写入和外部推理。模型更新、真实录音测试和服务切换分别验收。 diff --git a/servers/tools-pve/asr-pipeline/HANDOFF.md b/servers/tools-pve/asr-pipeline/HANDOFF.md new file mode 100644 index 0000000..694f7dc --- /dev/null +++ b/servers/tools-pve/asr-pipeline/HANDOFF.md @@ -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//{cleaned.txt,draft.md,report.json}` | +| **评测实验室** | `/home/claw/claude-workspace/OBSIDIAN/asr-lab/`(12 篇 fixture + 11 篇人工定稿 + `REPORT.md`) | +| fixture | `asr-lab/fixtures//{raw.txt,final.md,draft_baseline.md}` | +| 转写落地(触发点) | `~/obsidian-vault/.asr-inbox/.txt`(gitignored) | +| 转写持久归档 | `OBSIDIAN/recordings/transcripts/-.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 # 全流程,不写 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/-录音.txt(原子 mv) +④ path unit → obsidian-asr-refine-native.service → asr-refine run-all +⑤ 五阶段(见下)→ state/drafts/.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` 已禁止写入人工确认队列。 diff --git a/servers/tools-pve/asr-pipeline/README.md b/servers/tools-pve/asr-pipeline/README.md index e09c3f3..5bbdc2b 100644 --- a/servers/tools-pve/asr-pipeline/README.md +++ b/servers/tools-pve/asr-pipeline/README.md @@ -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/` 是现网单元快照,路径仍指向旧工作目录,不要原样安装到本仓库。 diff --git a/servers/tools-pve/asr-pipeline/asrpipe/config.py b/servers/tools-pve/asr-pipeline/asrpipe/config.py index 7a1512a..7eabb3d 100644 --- a/servers/tools-pve/asr-pipeline/asrpipe/config.py +++ b/servers/tools-pve/asr-pipeline/asrpipe/config.py @@ -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") diff --git a/servers/tools-pve/asr-pipeline/asrpipe/control_web.py b/servers/tools-pve/asr-pipeline/asrpipe/control_web.py index 2fbf0e8..1600c07 100644 --- a/servers/tools-pve/asr-pipeline/asrpipe/control_web.py +++ b/servers/tools-pve/asr-pipeline/asrpipe/control_web.py @@ -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""" + {html.escape(title)}
{body}
{script}""".encode("utf-8") +{nav_html} +
{body}
{guard}{script}""".encode("utf-8") def _provider_options(selected: str = "") -> str: @@ -298,22 +481,24 @@ def _provider_options(selected: str = "") -> str: def login_page(err: str = "") -> bytes: + if not webauth.password() and not webauth.allow_anon(): + err = err or "未配置访问口令。请设置环境变量 ASR_WEB_PASSWORD 后重启。仅本机调试可另设 ASR_WEB_ALLOW_ANON=1。" alert = f'

{html.escape(err)}

' if err else "" body = f"""
-

访问验证

请输入工作台访问口令。

{alert} +

访问验证

请输入工作台访问口令。口令来自环境变量 ASR_WEB_PASSWORD。

{alert}
- +

""" - return shell("登录", body) + return shell("登录", body, nav=False) def home(msg: str = "") -> bytes: options = _provider_options(CFG.asr_provider) notice = f'
{html.escape(msg)}
' if msg else "" body = f"""{notice}

上传会议 / 通话录音

-

支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。默认引擎与模型参数均可在 模型与系统配置 中直接修改并落盘至 SQLite。

+

支持 m4a、mp3、wav、mp4、aac、ogg、opus、webm、flac。单文件上限以服务端 ASR_UPLOAD_MAX_BYTES 为准(默认 4 GiB)。默认引擎可在模型配置中修改。

@@ -398,15 +583,16 @@ async function load(){
${E(j.status_label)} - +
-
${new Date(j.created_at*1000).toLocaleString()} · ${E(j.original_name)} (${(j.size/1048576).toFixed(1)} MB) · ${E(j.detail||'')}
+
${new Date(j.created_at*1000).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false})} · ${E(j.original_name)} (${(j.size/1048576).toFixed(1)} MB) · ${E(j.detail||'')}
任务详情 / 试听与编辑 ${j.pending_reviews?` · 前往人工确认 (${j.pending_reviews}) →`:''} ${j.artifacts&&j.artifacts.result?` · 下载正式纪要`:''}
`).join(''):'

暂无匹配任务

'; + box.querySelectorAll('.js-del').forEach(b=>b.addEventListener('click',()=>delOne(b.getAttribute('data-del'), b.getAttribute('data-name')||''))); syncBatchBtn(); } flt.onchange=load; pflt.onchange=load; qIn.oninput=()=>{clearTimeout(window._qt);window._qt=setTimeout(load,300)}; @@ -414,6 +600,9 @@ load(); setInterval(()=>{if(!document.querySelector('.job-chk:checked'))load()}, document.querySelector('#up').onsubmit=x=>{ x.preventDefault(); const f=document.querySelector('#file').files[0]; if(!f)return; + if(!/\\.(m4a|mp3|wav|mp4|aac|ogg|opus|webm|flac)$/i.test(f.name)){ + document.querySelector('#msg').textContent='不支持的文件类型,请选择音频或 mp4。'; return; + } const q=new XMLHttpRequest(), btn=document.querySelector('#send'), bar=document.querySelector('#bar'), msg=document.querySelector('#msg'); btn.disabled=true; bar.hidden=false; msg.textContent='正在上传…'; q.open('POST','/ui/upload'); @@ -426,7 +615,11 @@ document.querySelector('#up').onsubmit=x=>{ q.onload=()=>{ btn.disabled=false; bar.hidden=true; if(q.status===201){msg.textContent='上传成功,已进入处理队列。';x.target.reset();load();} - else{msg.textContent='上传失败:'+q.responseText;} + else{ + let t=q.responseText||('HTTP '+q.status); + try{const j=JSON.parse(q.responseText); const map={{'unsupported type':'不支持的文件类型','invalid size':'文件大小不合法','unauthorized':'需要登录','csrf':'页面已过期,请刷新后再试','incomplete upload':'上传未完成'}}; t=map[j.error]||j.error||t;}catch(e){{}} + msg.textContent='上传失败:'+t; + } }; q.onerror=()=>{btn.disabled=false;msg.textContent='网络中断,请重试';}; q.send(f); @@ -457,7 +650,7 @@ def job_page(job: dict, msg: str = "") -> bytes: notice = f'
{html.escape(msg)}
' if msg else "" events_html = "".join( f"

{html.escape(LABELS.get(e['status'], e['status']))} " - f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(e['created_at']))}" + f"{fmt_ts(e['created_at'])}" f"
{html.escape(e['detail'])}

" for e in db.events(jid) ) @@ -499,7 +692,7 @@ def job_page(job: dict, msg: str = "") -> bytes:
{'' if has_transcript else ''} - + {'' if job.get("status") not in BUSY_STATUSES else '正在处理,不能同时重新转写'}
""" @@ -561,7 +754,8 @@ def job_page(job: dict, msg: str = "") -> bytes: return shell("任务详情", body) -def _render_question_card(row: dict, action_url: str) -> str: +def _render_question_form(row: dict, action_url: str) -> str: + """One form per question so Enter submits this answer, not another question.""" qid = int(row["id"]) payload = json.loads(row.get("payload") or "{}") kind = str(payload.get("kind") or "") @@ -584,25 +778,34 @@ def _render_question_card(row: dict, action_url: str) -> str: f'' ) - select_opts = '' + "".join( - f'' - for v in [*choices, "无法确认,保留原文"] - ) sentence_html = f'
{html.escape(sentence)}
' if sentence else "" - return f"""
+ return f"""
+
{html.escape(kind_label)} #{qid} · {html.escape(row['question'])}
{sentence_html}
{quick_btns}
-
-
-
- - -
+
+ + +
-
""" +
""" + + +def _render_batch_select(row: dict) -> str: + qid = int(row["id"]) + payload = json.loads(row.get("payload") or "{}") + choices = payload.get("candidates") or [] + select_opts = '' + "".join( + f'' + for v in [*choices, "无法确认,保留原文"] + ) + return ( + f'' + f'' + ) def review_page(focus_job: dict | None = None, msg: str = "") -> bytes: @@ -660,25 +863,27 @@ def review_page(focus_job: dict | None = None, msg: str = "") -> bytes: hdr_html = f"""

{html.escape(title_str)}

ASR: {html.escape(prov_str)} -{time.strftime('%Y-%m-%d %H:%M', time.localtime(j['created_at']))} · 待确认 {len(rows)} 题
+{fmt_ts(j['created_at'], with_seconds=False)} · 待确认 {len(rows)} 题
{audio_bar}{draft_box}""" else: hdr_html = f"""

未关联任务的历史待确认项 ({len(rows)} 题)

""" - q_cards = "".join(_render_question_card(r, action_url) for r in rows) + q_forms = "".join(_render_question_form(r, action_url) for r in rows) + batch_selects = "".join(_render_batch_select(r) for r in rows) q_ids_csv = ",".join(str(r["id"]) for r in rows) group_cards.append(f"""
{hdr_html} +{q_forms}
-{q_cards} +
{batch_selects}
- 提示:既可点击上方候选按钮单题即时提交,也可在下拉/输入框填写多题后点击右侧批量提交。 + 每一题有自己的输入框,回车只提交该题。下面的下拉框用于一次提交多题。
+ -
""") @@ -709,8 +914,8 @@ def review_page(focus_job: dict | None = None, msg: str = "") -> bytes: f'
' f'' f'' - f'' - f'' + f'' + f'' f'
' ) history_html = ( @@ -731,8 +936,25 @@ def review_page(focus_job: dict | None = None, msg: str = "") -> bytes: return shell("人工确认中心", body) -def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes: +def _lexicon_pager(tab: str, q: str, page: int, total: int) -> str: + pages = max(1, (total + LEXICON_PAGE_SIZE - 1) // LEXICON_PAGE_SIZE) + page = min(max(page, 1), pages) + qv = urllib.parse.quote(q) + + def href(n: int) -> str: + return f"/lexicon?tab={tab}&q={qv}&page={n}" + + prev = f'上一页' if page > 1 else "" + nxt = f'下一页' if page < pages else "" + return ( + f'

共 {total} 条,第 {page}/{pages} 页,每页 {LEXICON_PAGE_SIZE} 条。 {prev} {nxt}

' + ) + + +def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "", page: int = 1) -> bytes: tab = tab if tab in ("aliases", "corrections", "entities", "relations") else "aliases" + page = max(1, page) + offset = (page - 1) * LEXICON_PAGE_SIZE try: st = lexicon.stats() except Exception: @@ -756,7 +978,9 @@ def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes: content_html = "" if tab == "aliases": - rows = lexicon.list_aliases(q=q, limit=180) + total = lexicon.count_aliases(q=q) + rows = lexicon.list_aliases(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) + pager = _lexicon_pager(tab, q, page, total) tr_list = [] for r in rows: al = str(r["alias"]) @@ -795,12 +1019,14 @@ def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes:

-

别名映射列表(支持行内直接编辑与删除,共显示 {len(tr_list)} 条)

+

别名映射列表

{pager} {''.join(tr_list) or ''}
简称 / 别名规范全名类型来源确认备注操作
暂无数据
""" elif tab == "corrections": - rows = lexicon.list_corrections(q=q, limit=180) + total = lexicon.count_corrections(q=q) + rows = lexicon.list_corrections(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) + pager = _lexicon_pager(tab, q, page, total) tr_list = [] for r in rows: sf = str(r["surface"]) @@ -832,12 +1058,14 @@ def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes:

-

发音纠错列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)

+

发音纠错列表

{pager} {''.join(tr_list) or ''}
误识别词正确规范词频次确认备注操作
暂无数据
""" elif tab == "entities": - rows = lexicon.list_entities(q=q, limit=180) + total = lexicon.count_entities(q=q) + rows = lexicon.list_entities(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) + pager = _lexicon_pager(tab, q, page, total) tr_list = [] for r in rows: can = str(r["canonical"]) @@ -878,12 +1106,14 @@ def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes:

-

实体列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)

+

实体列表

{pager} {''.join(tr_list) or ''}
规范名称类型公司/机构职务/角色关联人来源操作
暂无数据
""" else: # relations - rows = lexicon.list_relations(q=q, limit=180) + total = lexicon.count_relations(q=q) + rows = lexicon.list_relations(q=q, limit=LEXICON_PAGE_SIZE, offset=offset) + pager = _lexicon_pager(tab, q, page, total) tr_list = [] for r in rows: subj = str(r["subject"]) @@ -920,7 +1150,7 @@ def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes:

-

关系图谱列表(支持行内编辑与删除,共显示 {len(tr_list)} 条)

+

关系图谱列表

{pager} {''.join(tr_list) or ''}
主体关系客体置信度状态操作
暂无数据
""" @@ -946,11 +1176,11 @@ def lexicon_page(tab: str = "aliases", q: str = "", msg: str = "") -> bytes: def _mask_secret(val: str) -> str: - if not val: - return "未配置" - if len(val) <= 8: - return "已配置 (****)" - return f"已配置 ({val[:4]}…{val[-4:]})" + return "已配置" if val else "未配置" + + +def _configured(val: str) -> str: + return "已配置(内容已隐藏)" if val else "未配置" def settings_page(msg: str = "") -> bytes: @@ -965,7 +1195,7 @@ def settings_page(msg: str = "") -> bytes: f"{html.escape(p['id'])}" f"{html.escape(p['label'])}" f"{html.escape(p['model'])}" - f"{html.escape(p['endpoint'])}" + f"{_configured(p['endpoint'])}" f"{html.escape(p['via'])}" f"{badge}" ) @@ -982,18 +1212,19 @@ def settings_page(msg: str = "") -> bytes: litellm_key_mask = _mask_secret(s.get("litellm_api_key", "")) soniox_key_mask = _mask_secret(s.get("soniox_api_key", "")) grok_key_mask = _mask_secret(s.get("grok_api_key", "")) + web_pw_mask = _mask_secret(webauth.password()) prov_opts = _provider_options(CFG.asr_provider) body = f"""{notice}

模型与系统配置 (SQLite 持久化)

-存储位置: {html.escape(str(STATE / 'intake.db'))} (表 app_setting)
-

所有模型名称、网关地址与 API 密钥均直接保存在 SQLite 数据库中,修改后对 Web 与 Worker 进程立即生效,无需修改环境变量或重启容器。

+配置保存在 SQLite app_setting,页面不回显地址和密钥 +

模型名会显示在下面的输入框里。网关地址和 API 密钥只显示「已配置 / 未配置」,留空表示保持原值。保存后下一次模型调用会读取新密钥。

1. 后处理大模型网关 (LiteLLM) 与各阶段模型

-
+
@@ -1015,13 +1246,13 @@ def settings_page(msg: str = "") -> bytes:
-
+
-
+
-
+
@@ -1035,19 +1266,21 @@ def settings_page(msg: str = "") -> bytes:
-
+
-

3. 知识增强 (Hindsight / Termbank / Obsidian)

-
+

3. 知识增强与访问安全 (Hindsight / Termbank / Obsidian / Web 口令)

+
-
+
+
+
@@ -1060,8 +1293,8 @@ def settings_page(msg: str = "") -> bytes:

实时运行与通道就绪状态

- - + +
Worker 进程状态{html.escape(hb_text)}
Hindsight 记忆服务{html.escape(CFG.hindsight_api)} (bank: {html.escape(CFG.hindsight_bank)}) · {'在线' if hs_ok else '离线或未连接'}
Obsidian Vault 路径{html.escape(str(CFG.vault))} · {'已挂载' if vault_ok else '未挂载'}
Hindsight 记忆服务{_configured(CFG.hindsight_api)} · {'在线' if hs_ok else '离线或未连接'}
Obsidian Vault{'已挂载' if vault_ok else '未挂载'}

ASR 通道表

@@ -1076,7 +1309,14 @@ async function probeModels(){ const d=await r.json(); if(!d.ok){msg.textContent='查询失败:'+(d.error||'unknown');return;} msg.textContent=`共发现 ${d.models.length} 个模型(点击模型名可填入下方对应输入框):`; - box.innerHTML=d.models.map(m=>`${m}`).join(''); + box.replaceChildren(); + d.models.forEach(m=>{ + const s=document.createElement('span'); + s.className='tag'; s.style.cursor='pointer'; s.style.marginBottom='4px'; + s.textContent=m; + s.addEventListener('click',()=>{ const el=document.querySelector('#draft_model'); if(el) el.value=m; }); + box.appendChild(s); + }); }catch(e){msg.textContent='请求异常:'+e;} } """ @@ -1104,6 +1344,30 @@ class Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass + def setup(self) -> None: + super().setup() + self.connection.settimeout(30) + self._suppress_body = False + + def handle_one_request(self) -> None: + try: + super().handle_one_request() + except ClientError as exc: + try: + self.json({"error": exc.message}, exc.status) + except Exception: + pass + except (TimeoutError, socket.timeout): + try: + self.json({"error": "timeout"}, 408) + except Exception: + pass + except Exception: + try: + self.json({"error": "internal"}, 500) + except Exception: + pass + def send_data( self, data: bytes, @@ -1127,7 +1391,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header(k, v) self.send_header("Content-Length", str(len(data))) self.end_headers() - self.wfile.write(data) + if not getattr(self, "_suppress_body", False): + self.wfile.write(data) def redirect(self, location: str) -> None: self.send_response(303) @@ -1150,25 +1415,54 @@ class Handler(BaseHTTPRequestHandler): ) def web_authed(self) -> bool: - if not WEB_PASSWORD: + if webauth.allow_anon(): return True + if not webauth.password(): + return False if self.auth(): return True - raw_cookie = self.headers.get("Cookie", "") - jar = cookies.SimpleCookie() - try: - jar.load(raw_cookie) - except Exception: - return False - morsel = jar.get("asr_session") - return bool(morsel and hmac.compare_digest(morsel.value, _session_token())) + return webauth.cookie_matches(self.headers.get("Cookie", "")) def need_web_auth(self) -> bool: if self.web_authed(): return True - self.redirect("/login") + path = urllib.parse.urlsplit(self.path).path + if path.startswith("/ui/") or path.endswith("models-probe"): + self.json({"error": "unauthorized"}, 401) + else: + self.redirect("/login") return False + def _declared_length(self, max_bytes: int) -> int: + raw = self.headers.get("Content-Length") + if raw is None or raw == "": + return 0 + try: + n = int(raw) + except ValueError: + raise ClientError(400, "bad content-length") from None + if n < 0: + raise ClientError(400, "bad content-length") + if n > max_bytes: + raise ClientError(413, "payload too large") + return n + + def _read_exact(self, n: int) -> bytes: + if n <= 0: + return b"" + data = self.rfile.read(n) + if len(data) < n: + raise ClientError(400, "incomplete body") + return data + + def _csrf_ok(self, form: dict[str, list[str]] | None = None) -> bool: + if self.auth(): + return True + supplied = self.headers.get("X-CSRF-Token", "") + if not supplied and form is not None: + supplied = (form.get("csrf") or [""])[0] + return webauth.csrf_matches(supplied) + def need_auth(self) -> bool: if self.auth(): return True @@ -1178,20 +1472,22 @@ class Handler(BaseHTTPRequestHandler): def header(self, name: str, limit: int) -> str: return safe(urllib.parse.unquote(self.headers.get(name, "")), limit) - def read_form(self, max_bytes: int = 65536) -> dict[str, list[str]]: - length = min(int(self.headers.get("Content-Length", "0")), max_bytes) - raw = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else "" - return urllib.parse.parse_qs(raw, keep_blank_values=True) + def read_form(self, max_bytes: int = 65536, *, csrf: bool = True) -> dict[str, list[str]]: + length = self._declared_length(max_bytes) + raw = self._read_exact(length).decode("utf-8", errors="ignore") if length else "" + form = urllib.parse.parse_qs(raw, keep_blank_values=True) + if csrf and not self._csrf_ok(form): + raise ClientError(403, "csrf") + return form def upload(self, source: str, protected: bool) -> None: if protected and not self.auth(): return self.json({"error": "unauthorized"}, 401) if not protected and not self.web_authed(): return self.json({"error": "unauthorized"}, 401) - try: - length = int(self.headers.get("Content-Length", "0")) - except ValueError: - length = 0 + if not protected and not self._csrf_ok(): + return self.json({"error": "csrf"}, 403) + length = self._declared_length(MAX_UPLOAD) name = self.header("X-Filename", 240) suffix = Path(name).suffix.lower() if length <= 0 or length > MAX_UPLOAD: @@ -1215,12 +1511,13 @@ class Handler(BaseHTTPRequestHandler): while remaining: chunk = self.rfile.read(min(1024 * 1024, remaining)) if not chunk: - raise EOFError + raise ClientError(400, "incomplete upload") out.write(chunk) digest.update(chunk) remaining -= len(chunk) out.flush() os.fsync(out.fileno()) + part.replace(INBOX / stored) job = db.create_job( jid, name, stored, title=title, @@ -1236,8 +1533,10 @@ class Handler(BaseHTTPRequestHandler): asr_provider=prov, asr_model=model, ) - part.replace(INBOX / stored) self.json(public(job), 201) + except ClientError: + part.unlink(missing_ok=True) + raise except Exception: part.unlink(missing_ok=True) self.json({"error": "upload failed"}, 500) @@ -1253,7 +1552,7 @@ class Handler(BaseHTTPRequestHandler): text.encode("utf-8"), 200, "text/markdown; charset=utf-8", - f'inline; filename="{path.name}"', + content_disposition(path.name), ) def stream_audio(self, job: dict | None) -> None: @@ -1264,18 +1563,13 @@ class Handler(BaseHTTPRequestHandler): return self.send_data(b"Audio not found", 404, "text/plain") mime = mimetypes.guess_type(audio_path.name)[0] or "audio/mpeg" size = audio_path.stat().st_size - range_hdr = self.headers.get("Range", "") - start, end = 0, size - 1 - status = 200 - if range_hdr.startswith("bytes="): - m = re.match(r"bytes=(\d*)-(\d*)", range_hdr) - if m: - if m.group(1): - start = int(m.group(1)) - if m.group(2): - end = min(size - 1, int(m.group(2))) - if 0 <= start <= end < size: - status = 206 + status, start, end = _parse_byte_range(self.headers.get("Range", ""), size) + if status == 416: + self.send_response(416) + self.send_header("Content-Range", f"bytes */{size}") + self.send_header("Content-Length", "0") + self.end_headers() + return length = end - start + 1 self.send_response(status) self.send_header("Content-Type", mime) @@ -1284,6 +1578,8 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Range", f"bytes {start}-{end}/{size}") self.send_header("Content-Length", str(length)) self.end_headers() + if getattr(self, "_suppress_body", False): + return with audio_path.open("rb") as f: f.seek(start) rem = length @@ -1310,6 +1606,15 @@ class Handler(BaseHTTPRequestHandler): return self.json({"ready": ok}, 200 if ok else 503) if path == "/login": return self.send_data(login_page()) + if path == "/logout": + self.send_response(303) + self.send_header("Location", "/login") + self.send_header( + "Set-Cookie", + "asr_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", + ) + self.end_headers() + return if path == "/api/v1/providers": if not self.need_auth(): return @@ -1319,7 +1624,7 @@ class Handler(BaseHTTPRequestHandler): return raw_s = db.get_all_settings() redacted = { - k: (_mask_secret(v) if k.endswith("_api_key") else v) + k: (_mask_secret(v) if (k.endswith("_api_key") or k == "web_password") else v) for k, v in raw_s.items() } return self.json({"settings": redacted}) @@ -1381,6 +1686,7 @@ class Handler(BaseHTTPRequestHandler): tab=q.get("tab", ["aliases"])[0], q=q.get("q", [""])[0], msg=q.get("msg", [""])[0], + page=max(1, int(q.get("page", ["1"])[0] or 1)) if str(q.get("page", ["1"])[0]).isdigit() else 1, ) ) if path == "/settings": @@ -1431,7 +1737,25 @@ class Handler(BaseHTTPRequestHandler): m = re.fullmatch(r"/jobs/([0-9a-f]{24})/artifact/(transcript|draft|result)", path) if m: return self.artifact(db.get_job(m.group(1)), m.group(2), False) - self.send_data(b"Not found", 404, "text/plain") + self.send_data(shell( + "未找到", + '

页面不存在

' + '

这个地址没有对应的页面。

' + '

返回任务列表

', + ), 404) + + def do_HEAD(self) -> None: + self._suppress_body = True + try: + self.do_GET() + finally: + self._suppress_body = False + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Allow", "GET, HEAD, POST, DELETE, OPTIONS") + self.send_header("Content-Length", "0") + self.end_headers() def do_DELETE(self) -> None: path = urllib.parse.urlsplit(self.path).path @@ -1446,17 +1770,34 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self) -> None: path = urllib.parse.urlsplit(self.path).path if path == "/login": - form = self.read_form(4096) + origin = self.headers.get("Origin", "") + host = self.headers.get("Host", "") + if origin and urllib.parse.urlsplit(origin).netloc not in {"", host}: + return self.send_data(login_page("来源不被接受"), 403) + form = self.read_form(4096, csrf=False) pw = form.get("password", [""])[0] - if WEB_PASSWORD and hmac.compare_digest(pw, WEB_PASSWORD): + if not webauth.password(): + return self.send_data(login_page(), 401) + ip = self.client_address[0] + fails, stamp = _LOGIN_FAILS.get(ip, (0, 0.0)) + if time.time() - stamp > 600: + fails = 0 + if fails >= 8: + return self.send_data(login_page("尝试过多,请 10 分钟后再试"), 429) + if hmac.compare_digest(pw, webauth.password()): + _LOGIN_FAILS.pop(ip, None) + secure = "" + if os.getenv("ASR_WEB_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}: + secure = "; Secure" self.send_response(303) self.send_header("Location", "/") self.send_header( "Set-Cookie", - f"asr_session={_session_token()}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000", + f"asr_session={webauth.session_token()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400{secure}", ) self.end_headers() return + _LOGIN_FAILS[ip] = (fails + 1, time.time()) return self.send_data(login_page("口令错误,请重试"), 401) if path == "/ui/upload": @@ -1467,9 +1808,11 @@ class Handler(BaseHTTPRequestHandler): if path == "/api/v1/settings": if not self.need_auth(): return - length = min(int(self.headers.get("Content-Length", "0")), 32768) - body = json.loads(self.rfile.read(length).decode("utf-8") or "{}") - db.save_settings(body) + length = self._declared_length(32768) + body = json.loads(self._read_exact(length).decode("utf-8") or "{}") + if not isinstance(body, dict): + raise ClientError(400, "settings must be an object") + db.save_settings(_filter_settings(body)) return self.json({"ok": True}) m = re.fullmatch(r"/api/v1/jobs/([0-9a-f]{24})/reviews/(\d+)", path) @@ -1480,8 +1823,8 @@ class Handler(BaseHTTPRequestHandler): qid = int(m.group(2)) if not job or qid not in {x["id"] for x in pending(job)}: return self.json({"error": "not found"}, 404) - length = min(int(self.headers.get("Content-Length", "0")), 4096) - body = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + length = self._declared_length(4096) + body = json.loads(self._read_exact(length).decode("utf-8") or "{}") ans = str(body.get("answer", "")).strip() if not ans: return self.json({"error": "answer required"}, 400) @@ -1494,6 +1837,8 @@ class Handler(BaseHTTPRequestHandler): return if not api and not self.need_web_auth(): return + if not api: + self.read_form(8192) job = db.get_job(m.group(1)) if not job: return self.json({"error": "not found"}, 404) @@ -1512,15 +1857,12 @@ class Handler(BaseHTTPRequestHandler): job = db.get_job(m.group(1)) if not job: return self.json({"error": "not found"}, 404) - source = find_audio_file(job) - if not source or job["status"] in {"transcribing", "refining", "publishing"}: - return self.json({"error": "cannot retry"}, 409) - length = min(int(self.headers.get("Content-Length", "0")), 4096) - raw_body = self.rfile.read(length).decode("utf-8", errors="ignore") if length > 0 else "" new_prov = job.get("asr_provider") or CFG.asr_provider new_model = job.get("asr_model") or "" - if raw_body: - if api and raw_body.strip().startswith("{"): + if api: + length = self._declared_length(4096) + raw_body = self._read_exact(length).decode("utf-8", errors="ignore") if length else "" + if raw_body.strip().startswith("{"): try: body_json = json.loads(raw_body) if body_json.get("provider"): @@ -1528,25 +1870,21 @@ class Handler(BaseHTTPRequestHandler): if "model" in body_json: new_model = safe(str(body_json.get("model") or ""), 80) except ValueError: - pass - else: - form = urllib.parse.parse_qs(raw_body) - if form.get("provider"): - new_prov = providers.normalize_provider_name(form["provider"][0]) - if "model" in form: - new_model = safe(form.get("model", [""])[0], 80) - dest = INBOX / job["stored_name"] - if source.resolve() != dest.resolve(): - shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest) - old_ts = time.time() - 60 - os.utime(dest, (old_ts, old_ts)) - db.update_job( - job["id"], "queued", f"重新排队 (ASR: {new_prov})", - error_code="", asr_provider=new_prov, asr_model=new_model, - ) + raise ClientError(400, "bad json") from None + else: + form = self.read_form(8192) + if form.get("provider"): + new_prov = providers.normalize_provider_name(form["provider"][0]) + if "model" in form: + new_model = safe(form.get("model", [""])[0], 80) + err = _requeue_audio(job, new_prov, new_model) + if err: + if api: + return self.json({"error": err}, 409) + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(err)) if api: return self.json({"ok": True, "asr_provider": new_prov, "asr_model": new_model}, 202) - return self.redirect(f"/jobs/{job['id']}/") + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已重新排队转写")) if not self.need_web_auth(): return @@ -1572,8 +1910,9 @@ class Handler(BaseHTTPRequestHandler): form = self.read_form(4096) del_files = form.get("delete_files", ["0"])[0] == "1" del_res = form.get("delete_result", ["0"])[0] == "1" - delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res) - return self.redirect("/?msg=" + urllib.parse.quote("任务已删除")) + ok = delete_job_and_artifacts(m.group(1), delete_files=del_files, delete_result=del_res) + msg = "任务已删除" if ok else "没有找到这个任务,未删除" + return self.redirect("/?msg=" + urllib.parse.quote(msg)) m = re.fullmatch(r"/jobs/([0-9a-f]{24})/meta", path) if m: @@ -1581,33 +1920,34 @@ class Handler(BaseHTTPRequestHandler): if not job: return self.send_data(b"Not found", 404, "text/plain") form = self.read_form(32768) - new_title = safe(form.get("title", [""])[0], 80) + raw_title = form.get("title", [""])[0] + raw_prov = form.get("provider", [CFG.asr_provider])[0].strip().lower() + new_title = safe(raw_title, 80) new_ctx = safe(form.get("context", [""])[0], 2000) - new_prov = providers.normalize_provider_name(form.get("provider", [CFG.asr_provider])[0]) + new_prov = providers.normalize_provider_name(raw_prov) new_model = safe(form.get("model", [""])[0], 80) db.update_job_meta( job["id"], title=new_title, context=new_ctx, asr_provider=new_prov, asr_model=new_model, ) + notes = [] + if len(raw_title) > 80: + notes.append("标题已截断到 80 字") + if raw_prov and raw_prov not in providers.PROVIDERS: + notes.append(f"未知引擎已改用 {new_prov}") action = form.get("next_action", ["save"])[0] updated_job = db.get_job(job["id"]) or job if action == "rerefine": - queue_rerefine(updated_job) - return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并排队重新生成草稿")) - if action == "retry": - source = find_audio_file(updated_job) - if source: - dest = INBOX / updated_job["stored_name"] - if source.resolve() != dest.resolve(): - shutil.copyfile(source, dest) if source.parent == PROCESSED else source.replace(dest) - old_ts = time.time() - 60 - os.utime(dest, (old_ts, old_ts)) - db.update_job( - job["id"], "queued", f"重新排队转写 (ASR: {new_prov})", - error_code="", asr_provider=new_prov, asr_model=new_model, - ) - return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("已保存并重新排队转写")) - return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("任务信息已更新")) + queued = queue_rerefine(updated_job) + msg = "已保存并排队重新生成草稿" if queued else "已保存;任务正在处理,未重新生成草稿" + elif action == "retry": + err = _requeue_audio(updated_job, new_prov, new_model) + msg = err or "已保存并重新排队转写" + else: + msg = "任务信息已更新" + if notes: + msg += "(" + ";".join(notes) + ")" + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote(msg)) m = re.fullmatch(r"/jobs/([0-9a-f]{24})/revise", path) if m: @@ -1669,7 +2009,10 @@ class Handler(BaseHTTPRequestHandler): answered_count += 1 # Legacy fallback (`id` + `answer`/`custom`) elif form.get("id"): - qid = int(form.get("id", ["0"])[0] or 0) + raw_id = form.get("id", ["0"])[0] + if not str(raw_id).isdigit(): + raise ClientError(400, "bad id") + qid = int(raw_id) ans_val = form.get("custom", [""])[0].strip() or form.get("answer", [""])[0].strip() if qid in allowed_ids and ans_val: review.answer(qid, ans_val[:200]) @@ -1679,7 +2022,10 @@ class Handler(BaseHTTPRequestHandler): if path == "/review/update-answer": form = self.read_form(8192) - qid = int(form.get("id", ["0"])[0] or 0) + raw_id = form.get("id", ["0"])[0] + if not str(raw_id).isdigit(): + raise ClientError(400, "bad id") + qid = int(raw_id) if qid > 0: if form.get("reopen"): review.reopen_question(qid) @@ -1698,9 +2044,16 @@ class Handler(BaseHTTPRequestHandler): form = self.read_form(2 * 1024 * 1024) target = form.get("target", [""])[0] content = form.get("content", [""])[0] - if target in ("draft", "result") and job.get(target) and content.strip(): - tpath = Path(str(job[target])) - vault.write_draft_file(tpath, content) + if target not in ("draft", "result"): + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:只能编辑草稿或正式纪要")) + if not content.strip(): + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:内容为空")) + if not job.get(target): + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:还没有这个文件")) + tpath = Path(str(job[target])) + if not path_is_under(tpath, artifact_roots()): + return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("未保存:文件不在允许的目录")) + vault.write_draft_file(tpath, content) return self.redirect(f"/jobs/{job['id']}/?msg=" + urllib.parse.quote("Markdown 修改已保存")) # ---- Lexicon CRUD endpoints ---- @@ -1726,16 +2079,18 @@ class Handler(BaseHTTPRequestHandler): kind = safe(form.get("kind", ["人名"])[0], 20) conf = 1 if form.get("confirmed") else 0 note = safe(form.get("note", [""])[0], 120) - if new_a and new_c: - lexicon.update_alias(old_a or new_a, old_c or new_c, new_a, new_c, kind=kind, confirmed=conf, note=note) + if not (new_a and new_c): + return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote("未填写完整,没有修改别名")) + lexicon.update_alias(old_a or new_a, old_c or new_c, new_a, new_c, kind=kind, confirmed=conf, note=note) return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已更新别名:{new_a} → {new_c}")) if path == "/lexicon/alias/delete": form = self.read_form(8192) old_a = safe(form.get("old_alias", form.get("alias", [""]))[0], 40) old_c = safe(form.get("old_canonical", form.get("canonical", [""]))[0], 40) - lexicon.delete_alias(old_a, old_c) - return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(f"已删除别名:{old_a}")) + removed = lexicon.delete_alias(old_a, old_c) + msg = f"已删除别名:{old_a}" if removed else f"没有找到别名「{old_a}」,未删除" + return self.redirect("/lexicon?tab=aliases&msg=" + urllib.parse.quote(msg)) if path == "/lexicon/correction/update": form = self.read_form(8192) @@ -1824,11 +2179,15 @@ class Handler(BaseHTTPRequestHandler): "grok_api_base", "grok_asr_model", "hindsight_api", "hindsight_bank", ) for k in text_keys: - if k in form: - updates[k] = safe(form[k][0], 200) + if k not in form: + continue + text = safe(form[k][0], 200) + if not text: + continue + updates[k] = text if "asr_provider" in updates: updates["asr_provider"] = providers.normalize_provider_name(updates["asr_provider"]) - for sk in ("litellm_api_key", "soniox_api_key", "grok_api_key"): + for sk in ("litellm_api_key", "soniox_api_key", "grok_api_key", "web_password"): raw_k = form.get(sk, [""])[0].strip() if raw_k == "CLEAR": updates[sk] = "" @@ -1839,10 +2198,19 @@ class Handler(BaseHTTPRequestHandler): db.save_settings(updates) return self.redirect("/settings?msg=" + urllib.parse.quote("配置已保存至 SQLite 并立即生效")) - self.send_data(b"Not found", 404, "text/plain") + self.send_data(shell( + "未找到", + '

页面不存在

返回任务列表

', + ), 404) def main() -> None: + if not webauth.password() and not webauth.allow_anon(): + print( + "ASR_WEB_PASSWORD is empty; the browser UI stays closed until it is set. " + "ASR_WEB_ALLOW_ANON=1 opens it with no password.", + flush=True, + ) os.environ.setdefault("ASR_ROLE", "web") os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE)) ensure_dirs() diff --git a/servers/tools-pve/asr-pipeline/asrservice/worker.py b/servers/tools-pve/asr-pipeline/asrservice/worker.py index c632364..4c75935 100644 --- a/servers/tools-pve/asr-pipeline/asrservice/worker.py +++ b/servers/tools-pve/asr-pipeline/asrservice/worker.py @@ -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"" + + +def replace_intake(raw: str, header: str) -> str: + if raw.startswith("\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"" - if re.match(r"^", raw): - raw = re.sub(r"^", 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) diff --git a/servers/tools-pve/asr-pipeline/bin/asr-refine b/servers/tools-pve/asr-pipeline/bin/asr-refine new file mode 100755 index 0000000..b3635db --- /dev/null +++ b/servers/tools-pve/asr-pipeline/bin/asr-refine @@ -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 "$@" diff --git a/servers/tools-pve/asr-pipeline/compose.yaml b/servers/tools-pve/asr-pipeline/compose.yaml index 613570c..d6e6070 100644 --- a/servers/tools-pve/asr-pipeline/compose.yaml +++ b/servers/tools-pve/asr-pipeline/compose.yaml @@ -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 diff --git a/servers/tools-pve/asr-pipeline/deploy/systemd/asr-review-revise@.service b/servers/tools-pve/asr-pipeline/deploy/systemd/asr-review-revise@.service new file mode 100644 index 0000000..a8c0e20 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/deploy/systemd/asr-review-revise@.service @@ -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 diff --git a/servers/tools-pve/asr-pipeline/deploy/systemd/asr-review-web.service b/servers/tools-pve/asr-pipeline/deploy/systemd/asr-review-web.service new file mode 100644 index 0000000..f76d716 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/deploy/systemd/asr-review-web.service @@ -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 diff --git a/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-asr-refine-native.service b/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-asr-refine-native.service new file mode 100644 index 0000000..4e1e600 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-asr-refine-native.service @@ -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 diff --git a/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-asr-refine.path b/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-asr-refine.path new file mode 100644 index 0000000..437b645 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-asr-refine.path @@ -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 diff --git a/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-recording-watch.service b/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-recording-watch.service new file mode 100644 index 0000000..9bc11ed --- /dev/null +++ b/servers/tools-pve/asr-pipeline/deploy/systemd/obsidian-recording-watch.service @@ -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 diff --git a/servers/tools-pve/asr-pipeline/docs/agent-decoupling-implementation-v2.md b/servers/tools-pve/asr-pipeline/docs/agent-decoupling-implementation-v2.md new file mode 100644 index 0000000..a344838 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/docs/agent-decoupling-implementation-v2.md @@ -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 变更。对不满足的门槛明确标为未完成,不用“重构完成”掩盖投递尚未恢复。 diff --git a/servers/tools-pve/asr-pipeline/docs/agent-decoupling-implementation.md b/servers/tools-pve/asr-pipeline/docs/agent-decoupling-implementation.md new file mode 100644 index 0000000..f61c0aa --- /dev/null +++ b/servers/tools-pve/asr-pipeline/docs/agent-decoupling-implementation.md @@ -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 .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//,通知步骤降级但不报错 +``` + +--- + +## 6. Stage 4 —(可选,需 Kai 决策)`direct` 后端 + +Stage 1–3 完成后,通知链路仍然经过 Hermes:管线 → Hermes 网关 webhook → Telegram。只要 Hermes 不在线,Kai 就收不到通知。 + +**可选的彻底解耦**:增加 `backend: "direct"`,直接调 Telegram Bot API(`https://api.telegram.org/bot/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 答复。 diff --git a/servers/tools-pve/asr-pipeline/docs/agent-decoupling-review.md b/servers/tools-pve/asr-pipeline/docs/agent-decoupling-review.md new file mode 100644 index 0000000..a09f77e --- /dev/null +++ b/servers/tools-pve/asr-pipeline/docs/agent-decoupling-review.md @@ -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 接口面就够,避免过度工程化。 diff --git a/servers/tools-pve/asr-pipeline/docs/architecture-v2.md b/servers/tools-pve/asr-pipeline/docs/architecture-v2.md new file mode 100644 index 0000000..7accf38 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/docs/architecture-v2.md @@ -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. diff --git a/servers/tools-pve/asr-pipeline/docs/control-web.md b/servers/tools-pve/asr-pipeline/docs/control-web.md new file mode 100644 index 0000000..c924e1d --- /dev/null +++ b/servers/tools-pve/asr-pipeline/docs/control-web.md @@ -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` 调整。 diff --git a/servers/tools-pve/asr-pipeline/docs/current-state.md b/servers/tools-pve/asr-pipeline/docs/current-state.md new file mode 100644 index 0000000..95c76cb --- /dev/null +++ b/servers/tools-pve/asr-pipeline/docs/current-state.md @@ -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 同步完整链路后再移除旧目录。 + +本轮未执行上述生产切换,不承诺已独立上线。 diff --git a/servers/tools-pve/asr-pipeline/docs/webui-roadmap.md b/servers/tools-pve/asr-pipeline/docs/webui-roadmap.md new file mode 100644 index 0000000..40fc52b --- /dev/null +++ b/servers/tools-pve/asr-pipeline/docs/webui-roadmap.md @@ -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 构建发布。 + +## 验收原则 + +原文不可覆盖;候选纠错须有依据;背景不伪装为录音事实;重要歧义交人工确认。 +以关键内容和行动线索为目标,不追求伪精确的逐字恢复。 +合成测试与真实录音端到端验收分开,不能以单元测试通过代替上线验收。 + +本文件是后续开发计划,本轮不新增推理请求、不切换生产服务、不自动部署容器。 diff --git a/servers/tools-pve/asr-pipeline/pyproject.toml b/servers/tools-pve/asr-pipeline/pyproject.toml new file mode 100644 index 0000000..ff52919 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/pyproject.toml @@ -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*"] diff --git a/servers/tools-pve/asr-pipeline/recordings/bin/asr-transcribe.sh b/servers/tools-pve/asr-pipeline/recordings/bin/asr-transcribe.sh new file mode 100755 index 0000000..13ec9a0 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/recordings/bin/asr-transcribe.sh @@ -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"\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 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")" diff --git a/servers/tools-pve/asr-pipeline/recordings/bin/meeting-watch.py b/servers/tools-pve/asr-pipeline/recordings/bin/meeting-watch.py new file mode 100755 index 0000000..024b0be --- /dev/null +++ b/servers/tools-pve/asr-pipeline/recordings/bin/meeting-watch.py @@ -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"\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() diff --git a/servers/tools-pve/asr-pipeline/recordings/bin/vibevoice-speaker-unify.py b/servers/tools-pve/asr-pipeline/recordings/bin/vibevoice-speaker-unify.py new file mode 100755 index 0000000..a497547 --- /dev/null +++ b/servers/tools-pve/asr-pipeline/recordings/bin/vibevoice-speaker-unify.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +ECAPA-TDNN 声纹 + KMeans 全局说话人统一(在 ASR venv 里跑)。 +把 VibeVoice 每块独立的 S# 标签换成全场统一的 G# ID。 + +用法(由 vibevoice-transcribe.py --unify 调用,也可单独跑): + /bin/python vibevoice-speaker-unify.py
ID名称默认型号接口地址接入方式状态