feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
This commit is contained in:
@@ -35,6 +35,9 @@ HINDSIGHT_BANK=personal-context
|
|||||||
ASR_GIT_SYNC=0
|
ASR_GIT_SYNC=0
|
||||||
|
|
||||||
# Web & Notifications
|
# 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_PUBLIC_URL=http://192.168.50.49:8788
|
||||||
ASR_REVIEW_PUBLIC_HOST=192.168.50.145
|
ASR_REVIEW_PUBLIC_HOST=192.168.50.145
|
||||||
ASR_REVIEW_PORT=8788
|
ASR_REVIEW_PORT=8788
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
|
.venv/
|
||||||
.env
|
.env
|
||||||
seed/*.db
|
.env.*
|
||||||
|
!.env.example
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
state/
|
||||||
|
cache/
|
||||||
|
logs/
|
||||||
|
dry-run/
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
*.sqlite*
|
||||||
|
*.bak*
|
||||||
|
recordings/*
|
||||||
|
!recordings/bin/
|
||||||
|
recordings/bin/__pycache__/
|
||||||
|
|||||||
@@ -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 写入和外部推理。模型更新、真实录音测试和服务切换分别验收。
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# ASR 后处理管线 — 项目方案与交接状态
|
||||||
|
|
||||||
|
最后更新:2026-07-26(会话结束时移交)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 一句话现状
|
||||||
|
|
||||||
|
独立 Python 程序已上线并接管 systemd,**召回问题已定位并大幅修复(1/13 → 12/13)**,
|
||||||
|
**结构归拢已生效**;但 Kai 指出的更根本的 6 项设计缺陷(S2 职责过载、背景重复、
|
||||||
|
数字处理、音通道过窄、缺关系图谱、纪要目标定位)**只完成方案设计,未实现**。
|
||||||
|
下一个接手的模型应从「第 4 节 待实施方案」开始。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目路径
|
||||||
|
|
||||||
|
| 用途 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| **生产程序(主)** | `/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/` |
|
||||||
|
| 入口 | `asr-pipeline/bin/asr-refine`(自带 `.venv`,依赖仅 pypinyin / rapidfuzz / numpy) |
|
||||||
|
| 代码包 | `asr-pipeline/asrpipe/*.py` |
|
||||||
|
| 词库(SQLite) | `asr-pipeline/state/lexicon.db` |
|
||||||
|
| 术语库 | `asr-pipeline/termbank/terms.json`(27.7k) + `allowlist.txt`(9.5k) |
|
||||||
|
| 缓存(可删) | `asr-pipeline/cache/` |
|
||||||
|
| dry-run 产物 | `asr-pipeline/dry-run/<slug>/{cleaned.txt,draft.md,report.json}` |
|
||||||
|
| **评测实验室** | `/home/claw/claude-workspace/OBSIDIAN/asr-lab/`(12 篇 fixture + 11 篇人工定稿 + `REPORT.md`) |
|
||||||
|
| fixture | `asr-lab/fixtures/<name>/{raw.txt,final.md,draft_baseline.md}` |
|
||||||
|
| 转写落地(触发点) | `~/obsidian-vault/.asr-inbox/<slug>.txt`(gitignored) |
|
||||||
|
| 转写持久归档 | `OBSIDIAN/recordings/transcripts/<fid>-<datetime>.txt` |
|
||||||
|
| 私有粗稿 | `asr-pipeline/state/drafts/`(不进 Vault/Git) |
|
||||||
|
| 正式输出 | Web 全部确认后直接写 `~/obsidian-vault/minutes/` |
|
||||||
|
| 术语表(**只读**) | `~/obsidian-vault/minutes/_glossary.md` |
|
||||||
|
| 人物/公司卡(**只读**) | `~/obsidian-vault/People/*.md`、`Companies/*.md` |
|
||||||
|
| 日志 | `OBSIDIAN/logs/asr-pipeline.log` |
|
||||||
|
| 凭证(600) | `OBSIDIAN/bin/.asr-refine.env`(`ANTHROPIC_AUTH_TOKEN=` ZenMux key) |
|
||||||
|
| systemd 触发 | `~/.config/systemd/user/obsidian-asr-refine.path` → `obsidian-asr-refine-native.service` |
|
||||||
|
| 旧链路(保留可回退) | `OBSIDIAN/bin/obsidian-asr-refine.sh` + `obsidian-asr-refine.service` |
|
||||||
|
| skill 文档 | `~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md` |
|
||||||
|
|
||||||
|
### 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
P=~/claude-workspace/OBSIDIAN/asr-pipeline/bin/asr-refine
|
||||||
|
$P doctor # 路径/凭证/词库/Hindsight/两个模型 全检
|
||||||
|
$P dry-run <file.txt> # 全流程,不写 vault、不 commit
|
||||||
|
$P run-all # 处理 .asr-inbox 中所有未处理转写
|
||||||
|
$P lexicon stats|sync|show|learn <别名> <规范名> --note "…"
|
||||||
|
$P build-termbank # 重建术语库(联网)
|
||||||
|
```
|
||||||
|
|
||||||
|
回退到旧 `claude -p` 链路:
|
||||||
|
```bash
|
||||||
|
sed -i 's/^Unit=.*/Unit=obsidian-asr-refine.service/' ~/.config/systemd/user/obsidian-asr-refine.path
|
||||||
|
systemctl --user daemon-reload && systemctl --user restart obsidian-asr-refine.path
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 当前架构(已实现)
|
||||||
|
|
||||||
|
```
|
||||||
|
① 音频 → recordings/inbox/ obsidian-recording-watch.service
|
||||||
|
② meeting-watch.py → asr-transcribe.sh MEATING_ASR_BACKEND=qwen3cam
|
||||||
|
= :10099 Qwen3-ASR-1.7B(vLLM) + FSMN-VAD + ForcedAligner-0.6B + CAM++ + 声纹(11人)
|
||||||
|
输出 【谭凯】…(声纹已命名,身份可信)/【speaker_N】…(未命名)
|
||||||
|
※ FunASR 不是独立后端,只是该服务内部 VAD/hotword 组件
|
||||||
|
③ 桥接为 ~/obsidian-vault/.asr-inbox/<date>-<HHMMSS>录音.txt(原子 mv)
|
||||||
|
④ path unit → obsidian-asr-refine-native.service → asr-refine run-all
|
||||||
|
⑤ 五阶段(见下)→ state/drafts/<slug>.md
|
||||||
|
⑥ git commit+push → 更新 _待确认.md → Telegram 通知(hermes-gateway :8644 签名 webhook)
|
||||||
|
⑦ Kai 在一次性 Web 页复核 → Sonnet 应用确认 → 直接发布正式 minutes/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 五阶段
|
||||||
|
|
||||||
|
| 阶段 | 模块 | 内容 | 成本 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S0 | `segment.py` | 退化噪声折叠 + 说话人感知分片(1800/200 overlap) | 免费 |
|
||||||
|
| S1c | `refine.keyword_recall` | LLM 抽专有名词 → Hindsight recall | 1 次便宜调用 |
|
||||||
|
| S1 | `flagger.py` | 候选生成:音近 + 别名 + 变体 + 关系支持 | 免费(4~13s) |
|
||||||
|
| S2 | `refine.clean_chunks` | 去口语化 **+ 候选裁决**(并发 6) | N 次便宜调用 |
|
||||||
|
| S3 | `fidelity.py` | 确定性保真闸门 → 失败则严格重试 → 再失败保留原文 | 免费 |
|
||||||
|
| S4 | `refine.write_draft` | 一次强模型调用,结构化 + 出处标注 | 1 次调用 |
|
||||||
|
|
||||||
|
模型(实测选型,勿随意改):
|
||||||
|
- 清洗 `google/gemini-3.5-flash-lite`。对比 `claude-haiku-4.5`:闸门失败 19/87 vs 78/87,
|
||||||
|
haiku 有 69 次是**过度压缩**(当摘要器,删掉 AUTOIMMUNE/ADCS/CD),且慢 2×、贵 2.9×。
|
||||||
|
- 成稿 `anthropic/claude-sonnet-5`。
|
||||||
|
- 端点 `https://zenmux.ai/api/v1/chat/completions`;并发 12 触发 429、6 稳定;
|
||||||
|
`temperature` 被 sonnet-5/opus-5 拒(客户端已自愈并记忆);HTTP 402 是瞬时的(按 max_tokens 预留额度)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 本轮已完成(可直接使用)
|
||||||
|
|
||||||
|
### 3.1 诊断结论(重要,别重复走弯路)
|
||||||
|
|
||||||
|
Kai 反馈"纠错太次",实测复现 `2026-07-21-160435录音`:
|
||||||
|
|
||||||
|
```
|
||||||
|
8 个规范实体全部 in_play=True,池子里也都有,13 个口语面形只有 1 个产出候选
|
||||||
|
```
|
||||||
|
|
||||||
|
**不是召回/推断能力问题,是匹配器把答案扔了。** 三条硬规则造成:
|
||||||
|
|
||||||
|
1. `if span == cand.form: return` — 精确别名被当"已正确"丢弃。`万亚坤` 卡片写着 `aliases:["老万"]`,`迈威生物` 写着 `["迈威"]`,全被吃掉。
|
||||||
|
2. `abs(len(span)-len(cand.form)) > 1` — 简称永远匹配不上全名(麦威↔迈威生物、洛奇↔洛启生物、保山↔宝山基金)。
|
||||||
|
3. 只做等长整串 `seq_score`,无子串/称谓形态(光辉↔李光辉、张泰海↔张成海)。
|
||||||
|
|
||||||
|
另有第 4 条:`in_play` 里的名字若池子里没有 Cand(宝山基金无公司卡,仅靠纪要**文件名**进 in_play)则永不可匹配;且 in_play 被文件名/日期污染 25/70。
|
||||||
|
|
||||||
|
### 3.2 已实现的修复
|
||||||
|
|
||||||
|
**`lexicon.py`(新)** — SQLite 词库。设计铁律:**Obsidian 只读**,不回写 `_glossary.md`、不改卡片;
|
||||||
|
学到的别名/纠错单独沉淀(类比声纹库)。选 SQLite 而非 Hindsight 的理由:别名解析要确定性精确查表,
|
||||||
|
Hindsight 是 LLM 抽取 + 语义召回(1.4s/次,概率性),两者互补 —— lexicon 定身份,Hindsight 供语境。
|
||||||
|
|
||||||
|
```
|
||||||
|
entity(canonical, kind, company, role, related, card) 251 条(95 有归属,70 有关系)
|
||||||
|
alias(alias, canonical, kind, origin, confirmed, hits, note) 210 条(82 卡片 + 128 学习/术语表)
|
||||||
|
correction(surface, canonical, n, confirmed, note) 158 条
|
||||||
|
meta(vault_sig, synced_at)
|
||||||
|
```
|
||||||
|
- 从卡片 frontmatter 拉 `aliases/company/role/related_people`
|
||||||
|
- 导入 `_glossary.md` 153 条,**保留一对多语境映射**(`许博→许俊彦/许恒皓`、`徐总→徐劲前/邹洵`)
|
||||||
|
- 已录入 Kai 本轮 12 条确认(老尚→单永强、麦威→迈威生物、张泰海→张成海 等)
|
||||||
|
- 坑已修:`_fm` 用 `[ \t]*` 而非 `\s*`(`\s` 吃换行,空 `role:` 会把下一行 `status: active` 当值)
|
||||||
|
|
||||||
|
**`flagger.py` 重构** — 变体展开 + 多通道匹配:
|
||||||
|
- `variants()`:机构简称(org_head)、去姓称名、称谓(老X/小X/X总/X博/X哥/X姐/X老师/X董,`博=博士` 有 glossary 三例佐证)
|
||||||
|
- 每条候选带 `rule`(推导方式)+ `owner`(任职)+ `support`(`X(本次在谈)` / `X(近期相关)`)
|
||||||
|
- `RULE_MIN` 分规则阈值;`anchor` 结构锁定:称谓字必须**字面相同**,只有名字音节可漂
|
||||||
|
(否则 0.82 阈值等于"一个音节对上就行",产生 36 条垃圾:想谈→谭凯、理解→李光辉、就动→刘冀)
|
||||||
|
- `one-off`:等长、n-1 字相同、**首字必须相同**、且只对 canonical(不对别名 —— 否则 `林老板` 会让 我老板/跟老板/那老板 全指向邹洵,18 条垃圾)
|
||||||
|
- `consistency`:文档内一致性传播,目标已确立后其他写法放宽到 0.80,但 `<0.90 时首字必须相同`
|
||||||
|
- in_play 实体化(补 Cand)+ 去文件名/日期污染 + 去重(`迈威` 既是别名又被材料化 → 两个竞争候选)
|
||||||
|
- 一对多别名**全部**加入(原来只取 `targets[0]`,把 `徐总→邹洵` 丢了)
|
||||||
|
- 平局时优先保留 alias 出处(`万金→万津` 两条都 1.0,丢了 alias 就降级成猜测)
|
||||||
|
- `settled` 判定 + `render_for_prompt` A/B 分组:A=已确认唯一映射(必须替换),B=待判断
|
||||||
|
- 性能:rapidfuzz 预筛一致性通道,最慢 24.5s → 12.9s
|
||||||
|
|
||||||
|
**`prompts.py`**:JUDGE_RULES 说明各 rule 含义与证据强度;DRAFT_PROMPT 加**按议题归拢**要求
|
||||||
|
(结论→支撑事实→待办,单节 ≤7 bullet,禁止平铺)+ 反过度回避条款(能确定就写全名,别写"身份不确定")。
|
||||||
|
|
||||||
|
**`refine.py`**:S1c 移到 S1 **之前**,keyword_recall 的实体名回灌 `extra_in_play`
|
||||||
|
(原来只进背景文本,从不进候选空间 —— 无说话人标签的转写因此几乎没候选)。
|
||||||
|
|
||||||
|
**`cli.py`**:新增 `lexicon sync|stats|learn|show`;`doctor` 增加 lexicon 检查。
|
||||||
|
|
||||||
|
### 3.3 效果实测
|
||||||
|
|
||||||
|
| 指标 | 修复前 | 修复后 |
|
||||||
|
|---|---|---|
|
||||||
|
| 07-21 目标面形召回 | 1/13 | **12/13**(仅 `趟鬼` 未召回,Kai 本人也未给答案) |
|
||||||
|
| flags 总数 / 密度 | 10 | 34(12 篇 fixture 合计 400,10.5/万字) |
|
||||||
|
| S1 耗时(最慢) | 4.7s | 12.9s |
|
||||||
|
| 草稿结构 | 平铺 bullet | 5 议题节,结论→事实→待办 |
|
||||||
|
| 保真 | — | num/latin/term = 1.00,gate_fail 0 |
|
||||||
|
|
||||||
|
`老万` 现在带交叉证据:`万亚坤(人名·已确认别名,洛启生物(本次在谈)、实控人)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 待实施方案(Kai 2026-07-26 给出的 8 点,**核心工作在这里**)
|
||||||
|
|
||||||
|
### 4.1 已量化的三条症状
|
||||||
|
|
||||||
|
| Kai 判断 | 实测证据 |
|
||||||
|
|---|---|
|
||||||
|
| 去口语化 input 太复杂,flash-lite 兜不住 | S2 单片提示词 4380 字,**正文仅占 37%**;候选块 1566 字(36%)比规则本身大 3 倍;指令:正文 = 1.7:1 |
|
||||||
|
| 召回事实重复 | context 7 条长行中 **3 对高重叠**,一对 Jaccard=**1.00**;2081 字先砍到 678 字,**砍之前没去重**,预算全买重复 |
|
||||||
|
| 数字处理 | 07-21 篇中文数字串 15 个:`两千/万十/十两/两两/一十亿`;另一篇 91 个。不只中→阿拉伯,还有**口吃混入数字**和**量级歧义**("两千"=2000 还是 2千万,草稿自己在问) |
|
||||||
|
|
||||||
|
### 4.2 【P1】S2 拆成两步(Kai 第 8 点)— 最高优先
|
||||||
|
|
||||||
|
现在 S2 一次干四件事:去口语 + 候选裁决 + 消化背景 + 满足闸门。**这是一个已复现 bug 的根因**:
|
||||||
|
严格重试提示词写「专业术语必须**原样出现**」,把已确认的实体替换又还原了。
|
||||||
|
证据:单片隔离测试 A 组替换成功(张泰海→张成海、老尚→单永强),但完整 run(`retried=2`)
|
||||||
|
`cleaned.txt` 里 张泰海/老尚/老善/麦威/金山 全部未替换。
|
||||||
|
|
||||||
|
```
|
||||||
|
S2a 去口语化 仅 CLEAN_RULES + 正文(无候选、无背景)→ 保真闸门只管这一步
|
||||||
|
S2b 实体/数字归一 A 组由代码确定性替换;B 组一次专注 LLM 裁决(带关系事实,允许弃权)
|
||||||
|
```
|
||||||
|
- 闸门只校验 S2a。S2b **有意改写 token**,用闸门校它本身矛盾 → 改成白名单校验:
|
||||||
|
S2b 输出与输入的差异必须能被映射表解释,否则拒绝(比闸门更严且不冲突)。
|
||||||
|
- flash-lite 吐 token 快,两次调用不影响总时延(Kai 明确认可)。
|
||||||
|
|
||||||
|
### 4.3 【P1】A 组代码化确定性替换
|
||||||
|
|
||||||
|
`settled` 的映射不应依赖 LLM 服从指令。边界(**必须实现,否则是净负**):
|
||||||
|
- 仅当目标实体本次 `in_play` 时才替换,否则降级 B 组
|
||||||
|
(`金山→宝山基金` 是特定语境确认,全局替换危险:金山也可能是地名/金山办公)
|
||||||
|
- 排除 `common_word`(`光辉`/`公司` 这类)
|
||||||
|
- 避免嵌套误替(`白帆`→`白帆生物` 不能产生 `白帆生物生物`,用负向 lookahead)
|
||||||
|
|
||||||
|
### 4.4 【P1】背景去重压缩(Kai 第 7 点)
|
||||||
|
|
||||||
|
- 去重放在**截断之前**(Jaccard > 0.45 合并同义句)
|
||||||
|
- 散文式记忆压成紧凑表,例:
|
||||||
|
`刘冀|张科领弋 基金投资人|活跃|最近 2026-06-26|连接 基金/LP/白帆CDMO/桂林三金`
|
||||||
|
|
||||||
|
### 4.5 【P2】关系图谱 + 紧密度衰减(Kai 第 3、4 点)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
relation(a, b, kind, weight, first_seen, last_seen, n)
|
||||||
|
kind: 任职 | 同事 | 项目参与 | 共同出现 | 出资
|
||||||
|
```
|
||||||
|
来源优先级:卡片 `company`/`related_people` → 纪要 `people`/`companies` 共现(带日期)
|
||||||
|
→ Hindsight 实体 → (可选)LLM 抽 typed 关系。建议先做确定性来源,评估够不够再上 LLM。
|
||||||
|
|
||||||
|
`in_play` 从「90 天硬截断 + 计数」改为**指数衰减** `w = Σ exp(-Δdays/λ)`,λ≈120 天,
|
||||||
|
直接进候选排序,替掉现在粗糙的 `boost=0/1`。
|
||||||
|
|
||||||
|
### 4.6 【P2】音通道放宽 + 字通道独立(Kai 第 1 点)
|
||||||
|
|
||||||
|
**关键认知修正**:`张泰海→张成海` 中 tai/cheng 音距很远,**不是靠音捞回来的**,
|
||||||
|
是靠"等长 + 同首字 + 只差一字"的字形锚定。所以要两条独立通道并行取较高者,
|
||||||
|
而不是把一切压进一个音近分数:
|
||||||
|
- **音通道放宽**:现有折叠 zh/z、ch/c、sh/s、n/l、f/h、ng、en/eng、in/ing。
|
||||||
|
需补:送气丢失(b/p、d/t、g/k)、尖团(j/z、q/c、x/s)、r/l、u/ü、ai/ei、ao/ou;
|
||||||
|
声调默认不计权(口音下不可靠)。注意 char→pinyin 回还本身会引入误差。
|
||||||
|
- **字通道**:首字锚定 + 尾字锚定 + n-1 字相同,**任意长度**(现在只做 ≥3 字且首字相同)。
|
||||||
|
- 建议用加权评分替掉 6 个互相独立的 `RULE_MIN` 阈值(那套是打补丁堆出来的,规则一多互相漏),
|
||||||
|
结构性 gate(称谓字字面相同)保留。
|
||||||
|
|
||||||
|
### 4.7 【P2】数字归一(Kai 第 5 点)
|
||||||
|
|
||||||
|
- 无歧义中文数字 → 阿拉伯(`八千万`→8000万、`二零二一`→2021)
|
||||||
|
- 清理口吃残留(`一一百八`、`两五千`、`万十`、`两两`)
|
||||||
|
- **量级歧义不猜**:写成 `两千[?万/千万]` 并进「待你确认」
|
||||||
|
- **闸门必须认识中文数字**,否则转换会被判成数字丢失
|
||||||
|
|
||||||
|
### 4.8 【P1】纪要目标重新定位(Kai 第 6 点)
|
||||||
|
|
||||||
|
要改掉我此前的默认倾向。DRAFT_PROMPT 以**梳理关键内容与线索**为目标,明确写:
|
||||||
|
转写原文已保留在 `transcript:`,遗漏可事后按 Kai 指示补救;宁可写"此处录音模糊、金额量级不明",
|
||||||
|
不要为凑完整而编。**闸门继续留在 S2a**(防的是清洗阶段静默删信息,是另一回事),
|
||||||
|
但成稿阶段不再以逐字对齐为目标。
|
||||||
|
|
||||||
|
Kai 原话要点:「哪怕人类回听也有很大概率不能完全还原,所以撰写主要是总结梳理关键内容和线索,
|
||||||
|
不要追求逐字还原」。
|
||||||
|
|
||||||
|
### 4.9 【P0】必须承认的边界(不要再花时间攻)
|
||||||
|
|
||||||
|
- `徐总`:三个候选(徐劲前/邹洵/许俊彦)全与桂林三金相关,**结构性歧义**,
|
||||||
|
正确输出是 `徐总(疑似 [[徐劲前]] 或 [[邹洵]])`,不是硬选。
|
||||||
|
- `趟鬼`:零信息。
|
||||||
|
- `"张泰海、张博"` 是同一人说两次还是两个人 —— 音频不可恢复。
|
||||||
|
- Kai 明确说过:「有些模糊问题,你是永远无法解决的」。目标是
|
||||||
|
**不含糊的直接落定 + 含糊的带证据出候选集**,不是让管线替他决定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 建议实施顺序
|
||||||
|
|
||||||
|
1. **第一段(当天可回归)**:4.2 S2 拆分 + 4.3 A 组代码化 + 4.4 背景去重 + 4.8 成稿定位
|
||||||
|
—— 这四条直接解释了 Kai 看到的全部症状,改动最小、收益最确定。
|
||||||
|
2. **第二段**:4.7 数字归一(含闸门认中文数字)。
|
||||||
|
3. **第三段**:4.5 关系图谱 + 衰减权重(工程量最大)、4.6 音/字双通道重构。
|
||||||
|
|
||||||
|
### 回归验证要求(不可省)
|
||||||
|
|
||||||
|
- `asr-lab/fixtures` 12 篇 + 11 篇人工定稿卡回归。
|
||||||
|
- **过度纠错率必须保持 0**(历史横评实测为 0),否则确定性替换是净负。
|
||||||
|
- 保真三项(num/latin/term recall)在 S2a 上保持 1.00。
|
||||||
|
- 07-21 录音的 13 个面形召回不低于 12/13。
|
||||||
|
- 命令:`dry-run` 看 `cleaned.txt` 与 `draft.md` 的差异,判断问题在 S2 还是 S4。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 未提交 / 遗留
|
||||||
|
|
||||||
|
- `asr-pipeline/` 与 `asr-lab/` 所在的 `claude-workspace` **不是 git 仓库**,无版本控制,改动前建议手工备份。
|
||||||
|
- `state/lexicon.db` 含 Kai 12 条人工确认,**删库会丢**(`lexicon sync` 只能重建卡片+术语表部分,
|
||||||
|
`origin='learned'` 的丢失后无法自动恢复)。备份它。
|
||||||
|
- `dry-run/` 下有 3 份历史产物,可删。
|
||||||
|
- skill 文档 `asr-refine-pipeline.md` 已更新到「独立程序 + qwen3cam + S0–S4」,
|
||||||
|
但**尚未反映本轮 lexicon 与 S2 拆分方案**,实施后需同步。
|
||||||
|
- 已知未修 bug:见 4.2(严格重试还原已确认替换)。
|
||||||
|
# 2026-07-26 Codex 接手更新
|
||||||
|
|
||||||
|
当前实现已开始迁移到 v2 分阶段架构:纯去口语化与实体/数字规范化分离,确认映射由程序
|
||||||
|
确定性执行,Hindsight recall 在进入 prompt 前按实体压缩去重,并增加可复用的 SQLite
|
||||||
|
关系、互动、观察、模糊音和人工确认队列表。详细设计见 `docs/architecture-v2.md`。
|
||||||
|
|
||||||
|
四项核心能力已经接入生产判断链:历史纪要增量关系图、45 天半衰期的近期/频率权重、
|
||||||
|
由确认纠错学习的定向音节混淆,以及带类型/置信度/原值记录的确定性数字规范化。个人
|
||||||
|
模糊音只对当前关系语境中的实体生效,不影响全局术语库。
|
||||||
|
|
||||||
|
移动交付层已加入:每份生产草稿(不论实体候选是否触发)都会把 `## 待你确认` 转成
|
||||||
|
逐项 Web 问题。顺序为通话背景确认(可填正确背景请求重跑草稿)、缺失声纹注册、内容
|
||||||
|
不确定项、草稿整体审核;通过既有 Hermes 签名 webhook 把一次性页面地址发到 Telegram。
|
||||||
|
页面可展开完整私有粗稿。全部回答后将审计记录留在 state,由独立 oneshot 调用 Sonnet,
|
||||||
|
通过正式 schema/Summary/来源校验后只把正式稿写到 `minutes/` 并 push。确认的匿名
|
||||||
|
speaker 会立即写入 frontmatter 并调用 10099 声纹注册,失败则留给每日任务重试。
|
||||||
|
SQLite 以不同正式会议为单位统计实体;未建卡人物/公司达到 2 场时在 Web 提议建保守
|
||||||
|
存根,只有 Kai 同意才创建。页面完成后 10 秒退出,打开后空闲 45 分钟退出,最长运行
|
||||||
|
2 小时;服务不随开机常驻。`dry-run` 已禁止写入人工确认队列。
|
||||||
@@ -10,11 +10,11 @@
|
|||||||
- 任务详情页内置支持 HTTP Range 的音频播放器、失败一键切换模型重试、草稿与纪要 Markdown 在线编辑。
|
- 任务详情页内置支持 HTTP Range 的音频播放器、失败一键切换模型重试、草稿与纪要 Markdown 在线编辑。
|
||||||
- `/review/` 提供低置信度词语、关系确认与说话人身份确认(本地 `qwen3cam` 自动注册声纹至 `:10099`,线上多说话人模型直接更新草稿与词库而不依赖 `:10099`)。
|
- `/review/` 提供低置信度词语、关系确认与说话人身份确认(本地 `qwen3cam` 自动注册声纹至 `:10099`,线上多说话人模型直接更新草稿与词库而不依赖 `:10099`)。
|
||||||
- `/lexicon` 提供热词、同音纠错、人物关系管理与 Obsidian 词库一键同步。
|
- `/lexicon` 提供热词、同音纠错、人物关系管理与 Obsidian 词库一键同步。
|
||||||
- `/settings` 展示全部 ASR 引擎、LiteLLM 网关、Obsidian 与 Hindsight 实时就绪状态。
|
- `/settings` 展示引擎是否就绪。网关地址和 API 密钥只显示「已配置 / 未配置」,页面不回显内网地址或密钥片段。
|
||||||
- 支持可选 `ASR_WEB_PASSWORD` Cookie 鉴权(外网 VPS 部署时自动保护 `/ui/*` 与 `/review/*`)。
|
- 浏览器界面默认关闭。必须设置 `ASR_WEB_PASSWORD` 后才能上传、审核和改配置。未设置口令时页面只提示需要配置该变量,不会整站可写。`ASR_WEB_ALLOW_ANON=1` 仅用于跑在本机且不可被其他主机访问的调试进程。状态变更还要带 CSRF 令牌。`/api/v1/*` 继续使用 `ASR_AGENT_TOKEN`。
|
||||||
|
|
||||||
2. **后台消费与修订队列(`asrservice.worker`)**
|
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()` 生成带结构化注释头的原始转写。
|
- 调用统一多引擎层 `asrpipe.providers.transcribe_audio()` 生成带结构化注释头的原始转写。
|
||||||
- 执行 `asr-refine draft`:结合 SQLite 词库、Obsidian Vault(`People/`、`Companies/`、`Projects/`、`minutes/`、`_glossary.md`)与 Hindsight(`personal-context`)进行分段去口语化、保真检查与会议纪要生成。
|
- 执行 `asr-refine draft`:结合 SQLite 词库、Obsidian Vault(`People/`、`Companies/`、`Projects/`、`minutes/`、`_glossary.md`)与 Hindsight(`personal-context`)进行分段去口语化、保真检查与会议纪要生成。
|
||||||
- 监控 `state/revision-queue/` 处理 Web 审核提交的修订重跑请求,确认通过后发布正式纪要至 Obsidian 并可选执行 Git 自动提交推送。
|
- 监控 `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`,按环境配置:
|
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`。
|
- **内网 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 登录保护。
|
- **外网 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. 使用 `compose.yaml` 启动 `asr-web`(默认 `:8788`)与 `asr-worker`。
|
2. `ASR_GIT_SYNC` 与 `ASR_NOTIFY` 缺省为关。要推送 vault 或发通知时显式设为 `1`,并配置 `ASR_NOTIFY_BACKEND`。
|
||||||
|
3. 使用 `compose.yaml` 启动 `asr-web`(默认 `:8788`)与 `asr-worker`。`deploy/systemd/` 是现网单元快照,路径仍指向旧工作目录,不要原样安装到本仓库。
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ def bootstrap_default_settings() -> None:
|
|||||||
"hindsight_bank": _env("HINDSIGHT_BANK", "personal-context"),
|
"hindsight_bank": _env("HINDSIGHT_BANK", "personal-context"),
|
||||||
"use_hindsight": "1" if _env("ASR_HINDSIGHT", "1") != "0" else "0",
|
"use_hindsight": "1" if _env("ASR_HINDSIGHT", "1") != "0" else "0",
|
||||||
"use_termbank": "1" if _env("ASR_TERMBANK", "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)
|
con = sqlite3.connect(db_path, timeout=15)
|
||||||
try:
|
try:
|
||||||
@@ -320,7 +320,7 @@ class Config:
|
|||||||
)
|
)
|
||||||
git_sync = _SettingAttr(
|
git_sync = _SettingAttr(
|
||||||
"git_sync",
|
"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"),
|
lambda s: str(s).lower() not in ("0", "false", "no", "off"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ class Config:
|
|||||||
|
|
||||||
# ---- behaviour ------------------------------------------------------
|
# ---- behaviour ------------------------------------------------------
|
||||||
notify: bool = field(
|
notify: bool = field(
|
||||||
default_factory=lambda: _env("ASR_NOTIFY", "1") != "0")
|
default_factory=lambda: _env("ASR_NOTIFY", "0") != "0")
|
||||||
cache_llm: bool = field(
|
cache_llm: bool = field(
|
||||||
default_factory=lambda: _env("ASR_CACHE", "0") != "0")
|
default_factory=lambda: _env("ASR_CACHE", "0") != "0")
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
"""Persistent mobile control plane for recording intake and human review."""
|
"""Persistent mobile control plane for recording intake and human review."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -14,7 +16,7 @@ from http import HTTPStatus
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import review
|
from . import review, webauth
|
||||||
from .config import CFG
|
from .config import CFG
|
||||||
from .log import log
|
from .log import log
|
||||||
|
|
||||||
@@ -166,6 +168,7 @@ def _shell(title: str, body: str, script: str = "") -> bytes:
|
|||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang="zh-CN"><head>
|
<html lang="zh-CN"><head>
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="csrf" content="{html.escape(webauth.csrf_token(), quote=True)}">
|
||||||
<meta name="color-scheme" content="light"><meta http-equiv="Cache-Control" content="no-store">
|
<meta name="color-scheme" content="light"><meta http-equiv="Cache-Control" content="no-store">
|
||||||
<title>{html.escape(title)}</title>
|
<title>{html.escape(title)}</title>
|
||||||
<style>
|
<style>
|
||||||
@@ -236,6 +239,7 @@ document.querySelector('#upload').addEventListener('submit',e=>{
|
|||||||
const x=new XMLHttpRequest(), b=document.querySelector('#send'), p=document.querySelector('#progress'), out=document.querySelector('#result');
|
const x=new XMLHttpRequest(), b=document.querySelector('#send'), p=document.querySelector('#progress'), out=document.querySelector('#result');
|
||||||
b.disabled=true;p.hidden=false;out.textContent='正在上传…';
|
b.disabled=true;p.hidden=false;out.textContent='正在上传…';
|
||||||
x.open('POST','/api/upload');x.setRequestHeader('Content-Type','application/octet-stream');
|
x.open('POST','/api/upload');x.setRequestHeader('Content-Type','application/octet-stream');
|
||||||
|
x.setRequestHeader('X-CSRF-Token', document.querySelector('meta[name=csrf]')?.content || '');
|
||||||
x.setRequestHeader('X-Filename',encodeURIComponent(f.name));
|
x.setRequestHeader('X-Filename',encodeURIComponent(f.name));
|
||||||
x.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
|
x.setRequestHeader('X-Title',encodeURIComponent(document.querySelector('#title').value));
|
||||||
x.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
|
x.setRequestHeader('X-Context',encodeURIComponent(document.querySelector('#context').value));
|
||||||
@@ -293,6 +297,7 @@ def _review_page(action: str, job: dict | None = None) -> bytes:
|
|||||||
body = f"""<section class="panel"><div class="meta">{html.escape(title)} · 剩余 {len(rows)} 个 · 问题 #{row['id']}</div>
|
body = f"""<section class="panel"><div class="meta">{html.escape(title)} · 剩余 {len(rows)} 个 · 问题 #{row['id']}</div>
|
||||||
<h1>{html.escape(row['question'])}</h1>{source_html}{draft_html}
|
<h1>{html.escape(row['question'])}</h1>{source_html}{draft_html}
|
||||||
<form method="post" action="{html.escape(action, quote=True)}">
|
<form method="post" action="{html.escape(action, quote=True)}">
|
||||||
|
<input type="hidden" name="csrf" value="{html.escape(webauth.csrf_token(), quote=True)}">
|
||||||
<input type="hidden" name="id" value="{row['id']}">{buttons}
|
<input type="hidden" name="id" value="{row['id']}">{buttons}
|
||||||
<div class="custom"><input name="custom" maxlength="120" autocomplete="off"
|
<div class="custom"><input name="custom" maxlength="120" autocomplete="off"
|
||||||
placeholder="{html.escape(placeholder, quote=True)}"><button type="submit">提交</button></div>
|
placeholder="{html.escape(placeholder, quote=True)}"><button type="submit">提交</button></div>
|
||||||
@@ -342,12 +347,43 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return True, _job(match.group(1))
|
return True, _job(match.group(1))
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
def _browser_ok(self) -> bool:
|
||||||
|
if webauth.allow_anon():
|
||||||
|
return True
|
||||||
|
if not webauth.password():
|
||||||
|
return False
|
||||||
|
return webauth.cookie_matches(self.headers.get("Cookie", ""))
|
||||||
|
|
||||||
|
def _login_page(self, err: str = "") -> bytes:
|
||||||
|
if not webauth.password() and not webauth.allow_anon():
|
||||||
|
err = err or "未配置 ASR_WEB_PASSWORD,控制面已关闭。"
|
||||||
|
token = html.escape(webauth.csrf_token(), quote=True)
|
||||||
|
alert = f"<p>{html.escape(err)}</p>" if err else ""
|
||||||
|
body = (
|
||||||
|
f'<section class="panel"><h1>访问验证</h1>{alert}'
|
||||||
|
f'<form method="post" action="/login">'
|
||||||
|
f'<input type="hidden" name="csrf" value="{token}">'
|
||||||
|
f'<label>访问口令</label><input name="password" type="password" required>'
|
||||||
|
f'<div class="actions"><button type="submit">登录</button></div></form></section>'
|
||||||
|
)
|
||||||
|
return _shell("登录", body)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = urllib.parse.urlsplit(self.path).path
|
path = urllib.parse.urlsplit(self.path).path
|
||||||
if path == "/":
|
|
||||||
return self._send(_home())
|
|
||||||
if path == "/health":
|
if path == "/health":
|
||||||
return self._json({"ok": True, "pending": len(review.pending(100))})
|
return self._json({"ok": True, "pending": len(review.pending(100))})
|
||||||
|
if path in {"/login", "/logout"}:
|
||||||
|
if path == "/logout":
|
||||||
|
self.send_response(HTTPStatus.SEE_OTHER)
|
||||||
|
self.send_header("Location", "/login")
|
||||||
|
self.send_header("Set-Cookie", "asr_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
return self._send(self._login_page())
|
||||||
|
if not self._browser_ok():
|
||||||
|
return self._send(self._login_page(), HTTPStatus.UNAUTHORIZED)
|
||||||
|
if path == "/":
|
||||||
|
return self._send(_home())
|
||||||
if path == "/api/jobs":
|
if path == "/api/jobs":
|
||||||
jobs = _jobs()
|
jobs = _jobs()
|
||||||
for j in jobs:
|
for j in jobs:
|
||||||
@@ -372,9 +408,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _upload(self):
|
def _upload(self):
|
||||||
try:
|
try:
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
length = 0
|
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
|
||||||
if length <= 0 or length > MAX_UPLOAD:
|
if length <= 0 or length > MAX_UPLOAD:
|
||||||
return self._json({"error": "invalid upload size"},
|
return self._json({"error": "invalid upload size"},
|
||||||
HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
|
||||||
@@ -408,7 +444,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
while remaining:
|
while remaining:
|
||||||
data = self.rfile.read(min(CHUNK, remaining))
|
data = self.rfile.read(min(CHUNK, remaining))
|
||||||
if not data:
|
if not data:
|
||||||
raise EOFError("upload ended early")
|
raise EOFError("incomplete upload")
|
||||||
f.write(data)
|
f.write(data)
|
||||||
remaining -= len(data)
|
remaining -= len(data)
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -426,16 +462,28 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
update_job(job_id, "queued", "录音已落盘,等待 watcher 接收")
|
update_job(job_id, "queued", "录音已落盘,等待 watcher 接收")
|
||||||
return self._json({"ok": True, "job_id": job_id,
|
return self._json({"ok": True, "job_id": job_id,
|
||||||
"stored_name": stored}, HTTPStatus.CREATED)
|
"stored_name": stored}, HTTPStatus.CREATED)
|
||||||
|
except EOFError:
|
||||||
|
part.unlink(missing_ok=True)
|
||||||
|
update_job(job_id, "failed", "上传未完成")
|
||||||
|
return self._json({"error": "incomplete upload"}, HTTPStatus.BAD_REQUEST)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
part.unlink(missing_ok=True)
|
part.unlink(missing_ok=True)
|
||||||
update_job(job_id, "failed", f"上传失败:{e}")
|
update_job(job_id, "failed", "上传失败")
|
||||||
return self._json({"error": str(e)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
log(f"upload failed: {e}")
|
||||||
|
return self._json({"error": "upload failed"}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
def _answer(self):
|
def _answer(self):
|
||||||
try:
|
try:
|
||||||
length = min(int(self.headers.get("Content-Length", "0")), 4096)
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("bad content-length") from exc
|
||||||
|
if length < 0 or length > 4096:
|
||||||
|
raise ValueError("bad content-length")
|
||||||
form = urllib.parse.parse_qs(
|
form = urllib.parse.parse_qs(
|
||||||
self.rfile.read(length).decode("utf-8"))
|
self.rfile.read(length).decode("utf-8"))
|
||||||
|
if not webauth.csrf_matches((form.get("csrf") or [""])[0]):
|
||||||
|
raise ValueError("csrf")
|
||||||
qid = int(form.get("id", ["0"])[0])
|
qid = int(form.get("id", ["0"])[0])
|
||||||
answer = (form.get("custom", [""])[0].strip()
|
answer = (form.get("custom", [""])[0].strip()
|
||||||
or form.get("answer", [""])[0].strip())
|
or form.get("answer", [""])[0].strip())
|
||||||
@@ -460,7 +508,31 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path = urllib.parse.urlsplit(self.path).path
|
path = urllib.parse.urlsplit(self.path).path
|
||||||
|
if path == "/login":
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or 0)
|
||||||
|
except ValueError:
|
||||||
|
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
if length < 0:
|
||||||
|
return self._json({"error": "bad content-length"}, HTTPStatus.BAD_REQUEST)
|
||||||
|
length = min(length, 4096)
|
||||||
|
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8", "ignore"))
|
||||||
|
pw = form.get("password", [""])[0]
|
||||||
|
if webauth.password() and hmac.compare_digest(pw, webauth.password()):
|
||||||
|
self.send_response(HTTPStatus.SEE_OTHER)
|
||||||
|
self.send_header("Location", "/")
|
||||||
|
self.send_header(
|
||||||
|
"Set-Cookie",
|
||||||
|
f"asr_session={webauth.session_token()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400",
|
||||||
|
)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
return self._send(self._login_page("口令错误或未配置 ASR_WEB_PASSWORD"), HTTPStatus.UNAUTHORIZED)
|
||||||
|
if not self._browser_ok():
|
||||||
|
return self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED)
|
||||||
if path in ("/api/upload", "/hook/intake"):
|
if path in ("/api/upload", "/hook/intake"):
|
||||||
|
if not webauth.csrf_matches(self.headers.get("X-CSRF-Token", "")):
|
||||||
|
return self._json({"error": "csrf"}, HTTPStatus.FORBIDDEN)
|
||||||
return self._upload()
|
return self._upload()
|
||||||
if self._review_job()[0]:
|
if self._review_job()[0]:
|
||||||
return self._answer()
|
return self._answer()
|
||||||
|
|||||||
@@ -44,14 +44,16 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .config import CFG
|
from .config import CFG
|
||||||
|
|
||||||
LAB = CFG.root
|
def _vault() -> Path:
|
||||||
BANK = CFG.termbank_dir
|
return CFG.vault
|
||||||
CACHE = CFG.cache_dir / "flagger"
|
|
||||||
VAULT = CFG.vault
|
|
||||||
|
|
||||||
HS_API = CFG.hindsight_api
|
|
||||||
HS_BANK = CFG.hindsight_bank
|
def _bank() -> Path:
|
||||||
HS_TIMEOUT = CFG.hindsight_timeout
|
return CFG.termbank_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _cache() -> Path:
|
||||||
|
return CFG.cache_dir / "flagger"
|
||||||
|
|
||||||
HAN = re.compile(r"[\u4e00-\u9fff]+")
|
HAN = re.compile(r"[\u4e00-\u9fff]+")
|
||||||
HAN_FULL = re.compile(r"^[\u4e00-\u9fff]+$")
|
HAN_FULL = re.compile(r"^[\u4e00-\u9fff]+$")
|
||||||
@@ -264,16 +266,16 @@ def _dir_sig(paths) -> str:
|
|||||||
|
|
||||||
def load_pool(use_termbank=True) -> dict[str, Cand]:
|
def load_pool(use_termbank=True) -> dict[str, Cand]:
|
||||||
"""Vault entities (cards + glossary) ∪ optional domain term bank."""
|
"""Vault entities (cards + glossary) ∪ optional domain term bank."""
|
||||||
CACHE.mkdir(parents=True, exist_ok=True)
|
_cache().mkdir(parents=True, exist_ok=True)
|
||||||
if CFG.use_lexicon:
|
if CFG.use_lexicon:
|
||||||
try:
|
try:
|
||||||
from . import lexicon
|
from . import lexicon
|
||||||
lexicon.sync_from_vault()
|
lexicon.sync_from_vault()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
sig = _dir_sig([VAULT / "People", VAULT / "Companies", VAULT / "minutes",
|
sig = _dir_sig([_vault() / "People", _vault() / "Companies", _vault() / "minutes",
|
||||||
CFG.lexicon_db])
|
CFG.lexicon_db])
|
||||||
cpath = CACHE / f"pool6-{sig}-{int(use_termbank)}.json"
|
cpath = _cache() / f"pool6-{sig}-{int(use_termbank)}.json"
|
||||||
if cpath.exists():
|
if cpath.exists():
|
||||||
d = json.loads(cpath.read_text())
|
d = json.loads(cpath.read_text())
|
||||||
return {k: Cand(**v) for k, v in d.items()}
|
return {k: Cand(**v) for k, v in d.items()}
|
||||||
@@ -331,7 +333,7 @@ def load_pool(use_termbank=True) -> dict[str, Cand]:
|
|||||||
ents = {}
|
ents = {}
|
||||||
if not ents: # lexicon disabled/broken
|
if not ents: # lexicon disabled/broken
|
||||||
for sub, kind in (("People", "人名"), ("Companies", "公司")):
|
for sub, kind in (("People", "人名"), ("Companies", "公司")):
|
||||||
d = VAULT / sub
|
d = _vault() / sub
|
||||||
if not d.exists():
|
if not d.exists():
|
||||||
continue
|
continue
|
||||||
for f in d.glob("*.md"):
|
for f in d.glob("*.md"):
|
||||||
@@ -346,7 +348,7 @@ def load_pool(use_termbank=True) -> dict[str, Cand]:
|
|||||||
add(form, c.canonical, c.kind, "variant", rule, c.owner, c.role, anchor)
|
add(form, c.canonical, c.kind, "variant", rule, c.owner, c.role, anchor)
|
||||||
|
|
||||||
if use_termbank:
|
if use_termbank:
|
||||||
tb = BANK / "terms.json"
|
tb = _bank() / "terms.json"
|
||||||
if tb.exists():
|
if tb.exists():
|
||||||
for term, e in json.loads(tb.read_text()).items():
|
for term, e in json.loads(tb.read_text()).items():
|
||||||
# Only the on-domain, distinctive slices are worth phonetic search;
|
# Only the on-domain, distinctive slices are worth phonetic search;
|
||||||
@@ -361,7 +363,7 @@ def load_pool(use_termbank=True) -> dict[str, Cand]:
|
|||||||
|
|
||||||
|
|
||||||
def load_allowlist() -> set[str]:
|
def load_allowlist() -> set[str]:
|
||||||
p = BANK / "allowlist.txt"
|
p = _bank() / "allowlist.txt"
|
||||||
return set(p.read_text().split()) if p.exists() else set()
|
return set(p.read_text().split()) if p.exists() else set()
|
||||||
|
|
||||||
|
|
||||||
@@ -388,13 +390,13 @@ def _minutes_entities(text: str) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _corpus() -> list[tuple[str, set[str], str]]:
|
def _corpus() -> list[tuple[str, set[str], str]]:
|
||||||
CACHE.mkdir(parents=True, exist_ok=True)
|
_cache().mkdir(parents=True, exist_ok=True)
|
||||||
sig = _dir_sig([VAULT / "minutes"])
|
sig = _dir_sig([_vault() / "minutes"])
|
||||||
cpath = CACHE / f"corpus-{sig}.json"
|
cpath = _cache() / f"corpus-{sig}.json"
|
||||||
if cpath.exists():
|
if cpath.exists():
|
||||||
return [(a, set(b), c) for a, b, c in json.loads(cpath.read_text())]
|
return [(a, set(b), c) for a, b, c in json.loads(cpath.read_text())]
|
||||||
out = []
|
out = []
|
||||||
for f in sorted((VAULT / "minutes").glob("*.md")):
|
for f in sorted((_vault() / "minutes").glob("*.md")):
|
||||||
txt = f.read_text(errors="ignore")
|
txt = f.read_text(errors="ignore")
|
||||||
m = re.search(r"^date:\s*(\d{4}-\d{2}-\d{2})", txt, re.M)
|
m = re.search(r"^date:\s*(\d{4}-\d{2}-\d{2})", txt, re.M)
|
||||||
out.append((f.name, _minutes_entities(txt), m.group(1) if m else ""))
|
out.append((f.name, _minutes_entities(txt), m.group(1) if m else ""))
|
||||||
@@ -414,11 +416,11 @@ def hindsight_recall(seed: list[str], extra_query: str = "") -> tuple[set[str],
|
|||||||
return set(), []
|
return set(), []
|
||||||
query = (" ".join(sorted(seed)) + " " + extra_query).strip() or "会议 相关人物 公司"
|
query = (" ".join(sorted(seed)) + " " + extra_query).strip() or "会议 相关人物 公司"
|
||||||
body = json.dumps({"query": query, "max_tokens": 1500}).encode()
|
body = json.dumps({"query": query, "max_tokens": 1500}).encode()
|
||||||
url = f"{HS_API}/v1/default/banks/{HS_BANK}/memories/recall"
|
url = f"{CFG.hindsight_api}/v1/default/banks/{CFG.hindsight_bank}/memories/recall"
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, data=body,
|
req = urllib.request.Request(url, data=body,
|
||||||
headers={"Content-Type": "application/json"})
|
headers={"Content-Type": "application/json"})
|
||||||
with urllib.request.urlopen(req, timeout=HS_TIMEOUT) as r:
|
with urllib.request.urlopen(req, timeout=CFG.hindsight_timeout) as r:
|
||||||
d = json.loads(r.read())
|
d = json.loads(r.read())
|
||||||
except Exception:
|
except Exception:
|
||||||
return set(), []
|
return set(), []
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ class Entity:
|
|||||||
|
|
||||||
def connect() -> sqlite3.Connection:
|
def connect() -> sqlite3.Connection:
|
||||||
CFG.state_dir.mkdir(parents=True, exist_ok=True)
|
CFG.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
con = sqlite3.connect(CFG.lexicon_db)
|
con = sqlite3.connect(CFG.lexicon_db, timeout=30)
|
||||||
|
con.execute("PRAGMA journal_mode=WAL")
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
con.executescript(SCHEMA)
|
con.executescript(SCHEMA)
|
||||||
# One-time compatibility migration for the 158 corrections learned before the
|
# One-time compatibility migration for the 158 corrections learned before the
|
||||||
@@ -527,7 +528,7 @@ def stats() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ Web UI CRUD
|
# ------------------------------------------------------------------ Web UI CRUD
|
||||||
def list_aliases(q: str = "", origin_filter: str = "", limit: int = 200) -> list[dict]:
|
def list_aliases(q: str = "", origin_filter: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
|
||||||
con = connect()
|
con = connect()
|
||||||
sql = "SELECT * FROM alias WHERE 1=1"
|
sql = "SELECT * FROM alias WHERE 1=1"
|
||||||
args: list = []
|
args: list = []
|
||||||
@@ -539,13 +540,30 @@ def list_aliases(q: str = "", origin_filter: str = "", limit: int = 200) -> list
|
|||||||
sql += " AND origin <> 'card'"
|
sql += " AND origin <> 'card'"
|
||||||
elif origin_filter == "card":
|
elif origin_filter == "card":
|
||||||
sql += " AND origin = 'card'"
|
sql += " AND origin = 'card'"
|
||||||
sql += " ORDER BY confirmed DESC, created_at DESC, alias ASC LIMIT ?"
|
sql += " ORDER BY confirmed DESC, created_at DESC, alias ASC LIMIT ? OFFSET ?"
|
||||||
args.append(limit)
|
args.extend((max(1, min(limit, 500)), max(0, offset)))
|
||||||
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
||||||
con.close()
|
con.close()
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def count_aliases(q: str = "", origin_filter: str = "") -> int:
|
||||||
|
con = connect()
|
||||||
|
sql = "SELECT count(*) FROM alias WHERE 1=1"
|
||||||
|
args: list = []
|
||||||
|
if q.strip():
|
||||||
|
pat = f"%{q.strip()}%"
|
||||||
|
sql += " AND (alias LIKE ? OR canonical LIKE ? OR note LIKE ?)"
|
||||||
|
args.extend([pat, pat, pat])
|
||||||
|
if origin_filter == "learned":
|
||||||
|
sql += " AND origin <> 'card'"
|
||||||
|
elif origin_filter == "card":
|
||||||
|
sql += " AND origin = 'card'"
|
||||||
|
n = int(con.execute(sql, args).fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def update_alias(
|
def update_alias(
|
||||||
old_alias: str,
|
old_alias: str,
|
||||||
old_canonical: str,
|
old_canonical: str,
|
||||||
@@ -590,7 +608,7 @@ def delete_alias(alias: str, canonical: str) -> bool:
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
def list_corrections(q: str = "", limit: int = 200) -> list[dict]:
|
def list_corrections(q: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
|
||||||
con = connect()
|
con = connect()
|
||||||
sql = "SELECT * FROM correction WHERE 1=1"
|
sql = "SELECT * FROM correction WHERE 1=1"
|
||||||
args: list = []
|
args: list = []
|
||||||
@@ -598,13 +616,26 @@ def list_corrections(q: str = "", limit: int = 200) -> list[dict]:
|
|||||||
pat = f"%{q.strip()}%"
|
pat = f"%{q.strip()}%"
|
||||||
sql += " AND (surface LIKE ? OR canonical LIKE ? OR note LIKE ?)"
|
sql += " AND (surface LIKE ? OR canonical LIKE ? OR note LIKE ?)"
|
||||||
args.extend([pat, pat, pat])
|
args.extend([pat, pat, pat])
|
||||||
sql += " ORDER BY confirmed DESC, n DESC, last_seen DESC LIMIT ?"
|
sql += " ORDER BY confirmed DESC, n DESC, last_seen DESC LIMIT ? OFFSET ?"
|
||||||
args.append(limit)
|
args.extend((max(1, min(limit, 500)), max(0, offset)))
|
||||||
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
||||||
con.close()
|
con.close()
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def count_corrections(q: str = "") -> int:
|
||||||
|
con = connect()
|
||||||
|
sql = "SELECT count(*) FROM correction WHERE 1=1"
|
||||||
|
args: list = []
|
||||||
|
if q.strip():
|
||||||
|
pat = f"%{q.strip()}%"
|
||||||
|
sql += " AND (surface LIKE ? OR canonical LIKE ? OR note LIKE ?)"
|
||||||
|
args.extend([pat, pat, pat])
|
||||||
|
n = int(con.execute(sql, args).fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def update_correction(
|
def update_correction(
|
||||||
old_surface: str,
|
old_surface: str,
|
||||||
old_canonical: str,
|
old_canonical: str,
|
||||||
@@ -651,7 +682,7 @@ def delete_correction(surface: str, canonical: str) -> bool:
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
def list_entities(q: str = "", kind: str = "", limit: int = 200) -> list[dict]:
|
def list_entities(q: str = "", kind: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
|
||||||
con = connect()
|
con = connect()
|
||||||
sql = "SELECT * FROM entity WHERE 1=1"
|
sql = "SELECT * FROM entity WHERE 1=1"
|
||||||
args: list = []
|
args: list = []
|
||||||
@@ -662,13 +693,29 @@ def list_entities(q: str = "", kind: str = "", limit: int = 200) -> list[dict]:
|
|||||||
if kind.strip():
|
if kind.strip():
|
||||||
sql += " AND kind=?"
|
sql += " AND kind=?"
|
||||||
args.append(kind.strip())
|
args.append(kind.strip())
|
||||||
sql += " ORDER BY (CASE WHEN card='' THEN 0 ELSE 1 END), canonical ASC LIMIT ?"
|
sql += " ORDER BY (CASE WHEN card='' THEN 0 ELSE 1 END), canonical ASC LIMIT ? OFFSET ?"
|
||||||
args.append(limit)
|
args.extend((max(1, min(limit, 500)), max(0, offset)))
|
||||||
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
||||||
con.close()
|
con.close()
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def count_entities(q: str = "", kind: str = "") -> int:
|
||||||
|
con = connect()
|
||||||
|
sql = "SELECT count(*) FROM entity WHERE 1=1"
|
||||||
|
args: list = []
|
||||||
|
if q.strip():
|
||||||
|
pat = f"%{q.strip()}%"
|
||||||
|
sql += " AND (canonical LIKE ? OR company LIKE ? OR role LIKE ? OR related LIKE ?)"
|
||||||
|
args.extend([pat, pat, pat, pat])
|
||||||
|
if kind.strip():
|
||||||
|
sql += " AND kind=?"
|
||||||
|
args.append(kind.strip())
|
||||||
|
n = int(con.execute(sql, args).fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def upsert_entity(
|
def upsert_entity(
|
||||||
canonical: str,
|
canonical: str,
|
||||||
kind: str = "人名",
|
kind: str = "人名",
|
||||||
@@ -723,7 +770,7 @@ def delete_entity(canonical: str) -> bool:
|
|||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
def list_relations(q: str = "", limit: int = 200) -> list[dict]:
|
def list_relations(q: str = "", limit: int = 200, offset: int = 0) -> list[dict]:
|
||||||
con = connect()
|
con = connect()
|
||||||
sql = "SELECT * FROM relation WHERE 1=1"
|
sql = "SELECT * FROM relation WHERE 1=1"
|
||||||
args: list = []
|
args: list = []
|
||||||
@@ -731,13 +778,26 @@ def list_relations(q: str = "", limit: int = 200) -> list[dict]:
|
|||||||
pat = f"%{q.strip()}%"
|
pat = f"%{q.strip()}%"
|
||||||
sql += " AND (subject LIKE ? OR predicate LIKE ? OR object LIKE ? OR source LIKE ?)"
|
sql += " AND (subject LIKE ? OR predicate LIKE ? OR object LIKE ? OR source LIKE ?)"
|
||||||
args.extend([pat, pat, pat, pat])
|
args.extend([pat, pat, pat, pat])
|
||||||
sql += " ORDER BY active DESC, confidence DESC, last_seen DESC LIMIT ?"
|
sql += " ORDER BY active DESC, confidence DESC, last_seen DESC LIMIT ? OFFSET ?"
|
||||||
args.append(limit)
|
args.extend((max(1, min(limit, 500)), max(0, offset)))
|
||||||
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
||||||
con.close()
|
con.close()
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def count_relations(q: str = "") -> int:
|
||||||
|
con = connect()
|
||||||
|
sql = "SELECT count(*) FROM relation WHERE 1=1"
|
||||||
|
args: list = []
|
||||||
|
if q.strip():
|
||||||
|
pat = f"%{q.strip()}%"
|
||||||
|
sql += " AND (subject LIKE ? OR predicate LIKE ? OR object LIKE ? OR source LIKE ?)"
|
||||||
|
args.extend([pat, pat, pat, pat])
|
||||||
|
n = int(con.execute(sql, args).fetchone()[0])
|
||||||
|
con.close()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
def update_relation(
|
def update_relation(
|
||||||
old_subject: str,
|
old_subject: str,
|
||||||
old_predicate: str,
|
old_predicate: str,
|
||||||
|
|||||||
@@ -49,10 +49,8 @@ _key: list[str] = []
|
|||||||
|
|
||||||
|
|
||||||
def _auth() -> str:
|
def _auth() -> str:
|
||||||
with _lock:
|
# Read on every call so a key saved from /settings is used without a restart.
|
||||||
if not _key:
|
return CFG.api_key()
|
||||||
_key.append(CFG.api_key())
|
|
||||||
return _key[0]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""One machine-wide lock for processes that consume the recording inbox."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_recording_lock(path: Path):
|
||||||
|
"""Return an open file holding an exclusive lock, or exit if another consumer has it.
|
||||||
|
|
||||||
|
The handle must stay referenced for the life of the process.
|
||||||
|
"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handle = path.open("a+")
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
handle.close()
|
||||||
|
raise SystemExit(f"another recording consumer holds {path}")
|
||||||
|
handle.seek(0)
|
||||||
|
handle.truncate()
|
||||||
|
handle.write(str(os.getpid()))
|
||||||
|
handle.flush()
|
||||||
|
return handle
|
||||||
@@ -93,7 +93,7 @@ KEYWORD_PROMPT = """从下面的会议转写摘选中,提取用于检索背景
|
|||||||
# (「谭凯(白帆生物总经理)」、「此项目负责人为 [[冯昆仑]](背景确认)」) even when the
|
# (「谭凯(白帆生物总经理)」、「此项目负责人为 [[冯昆仑]](背景确认)」) even when the
|
||||||
# recording never said them. The facts were often correct, but a meeting note that
|
# recording never said them. The facts were often correct, but a meeting note that
|
||||||
# cannot distinguish "said on the call" from "known from notes" is unsafe to finalize.
|
# cannot distinguish "said on the call" from "known from notes" is unsafe to finalize.
|
||||||
DRAFT_PROMPT = """你是会议录音整理助手,正在根据**带来源编号的事实卡片**产出「粗整理草稿」。
|
DRAFT_PROMPT = """你是会议录音整理助手,正在根据下方整理要点写出会议草稿。
|
||||||
|
|
||||||
严格遵循:
|
严格遵循:
|
||||||
1. 只产出粗整理草稿,**不是**正式纪要。
|
1. 只产出粗整理草稿,**不是**正式纪要。
|
||||||
@@ -133,8 +133,11 @@ DRAFT_PROMPT = """你是会议录音整理助手,正在根据**带来源编号
|
|||||||
(例如「徐总(疑似 [[徐劲前]] 或 [[邹洵]])」),而不是笼统写「身份不明」。
|
(例如「徐总(疑似 [[徐劲前]] 或 [[邹洵]])」),而不是笼统写「身份不明」。
|
||||||
13. 判定简称指向谁时使用背景里的任职信息,这属于**辨识**而非越界断言,
|
13. 判定简称指向谁时使用背景里的任职信息,这属于**辨识**而非越界断言,
|
||||||
不需要加〔背景〕;但如果你要额外陈述这个人的职务、公司归属本身,那就要加〔背景〕。
|
不需要加〔背景〕;但如果你要额外陈述这个人的职务、公司归属本身,那就要加〔背景〕。
|
||||||
14. 对匿名说话人,除非事实卡片已经给出有证据的候选,否则只写“身份待确认”;
|
14. 对匿名说话人,除非整理要点已经给出有证据的候选,否则只写“身份待确认”;
|
||||||
不要从背景中枚举可能的人名让用户猜。
|
不要从背景中枚举可能的人名让用户猜。
|
||||||
|
15. 正文和标题不要出现「卡片」「提示词」「系统指令」「粗整理」这些内部说法。
|
||||||
|
记录里没有的信息写「记录未写明」,不要写「卡片未提供」或「卡片提及」。
|
||||||
|
标题用业务名称,不要带「草稿」二字。
|
||||||
|
|
||||||
{speaker_note}
|
{speaker_note}
|
||||||
|
|
||||||
@@ -153,7 +156,7 @@ companies: [有把握的公司…]
|
|||||||
transcript: {transcript}
|
transcript: {transcript}
|
||||||
{extra_fm}---
|
{extra_fm}---
|
||||||
|
|
||||||
事实卡片:
|
整理要点:
|
||||||
{body}
|
{body}
|
||||||
|
|
||||||
只输出 markdown 草稿全文,不要任何额外说明。"""
|
只输出 markdown 草稿全文,不要任何额外说明。"""
|
||||||
|
|||||||
@@ -409,28 +409,45 @@ def probe_duration_seconds(audio: Path) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _encode_multipart(
|
def _multipart_framing(
|
||||||
fields: dict[str, str],
|
fields: dict[str, str],
|
||||||
file_field: str,
|
file_field: str,
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
) -> tuple[bytes, str]:
|
) -> tuple[bytes, bytes, str, int]:
|
||||||
|
"""Build multipart head/tail without reading the audio into memory."""
|
||||||
boundary = f"----ASRBoundary{uuid.uuid4().hex}"
|
boundary = f"----ASRBoundary{uuid.uuid4().hex}"
|
||||||
body = bytearray()
|
safe_name = file_path.name.replace('"', "").replace("\r", "").replace("\n", "") or "audio"
|
||||||
|
head = bytearray()
|
||||||
for name, val in fields.items():
|
for name, val in fields.items():
|
||||||
body.extend(f"--{boundary}\r\n".encode())
|
head.extend(f"--{boundary}\r\n".encode())
|
||||||
body.extend(
|
head.extend(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
||||||
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
head.extend(str(val).encode("utf-8"))
|
||||||
body.extend(str(val).encode("utf-8"))
|
head.extend(b"\r\n")
|
||||||
body.extend(b"\r\n")
|
mime = mimetypes.guess_type(safe_name)[0] or "application/octet-stream"
|
||||||
mime = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
head.extend(f"--{boundary}\r\n".encode())
|
||||||
body.extend(f"--{boundary}\r\n".encode())
|
head.extend(
|
||||||
body.extend(
|
|
||||||
f'Content-Disposition: form-data; name="{file_field}"; '
|
f'Content-Disposition: form-data; name="{file_field}"; '
|
||||||
f'filename="{file_path.name}"\r\n'.encode())
|
f'filename="{safe_name}"\r\n'.encode())
|
||||||
body.extend(f"Content-Type: {mime}\r\n\r\n".encode())
|
head.extend(f"Content-Type: {mime}\r\n\r\n".encode())
|
||||||
body.extend(file_path.read_bytes())
|
tail = f"\r\n--{boundary}--\r\n".encode()
|
||||||
body.extend(f"\r\n--{boundary}--\r\n".encode())
|
total = len(head) + file_path.stat().st_size + len(tail)
|
||||||
return bytes(body), f"multipart/form-data; boundary={boundary}"
|
return bytes(head), tail, f"multipart/form-data; boundary={boundary}", total
|
||||||
|
|
||||||
|
|
||||||
|
def _open_http(url: str, timeout: int) -> tuple[http.client.HTTPConnection, str]:
|
||||||
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise RuntimeError(f"unsupported ASR URL: {url}")
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
if parsed.scheme == "https":
|
||||||
|
conn: http.client.HTTPConnection = http.client.HTTPSConnection(
|
||||||
|
parsed.hostname, port, timeout=timeout)
|
||||||
|
else:
|
||||||
|
conn = http.client.HTTPConnection(parsed.hostname, port, timeout=timeout)
|
||||||
|
path = parsed.path or "/"
|
||||||
|
if parsed.query:
|
||||||
|
path = f"{path}?{parsed.query}"
|
||||||
|
return conn, path
|
||||||
|
|
||||||
|
|
||||||
def _http_multipart(
|
def _http_multipart(
|
||||||
@@ -443,29 +460,48 @@ def _http_multipart(
|
|||||||
timeout: int = 900,
|
timeout: int = 900,
|
||||||
retries: int = 3,
|
retries: int = 3,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
data, content_type = _encode_multipart(fields, file_field, file_path)
|
"""POST audio as multipart, streaming the file in 1 MiB chunks."""
|
||||||
req_headers = {"Content-Type": content_type, **(headers or {})}
|
head, tail, content_type, total = _multipart_framing(fields, file_field, file_path)
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
|
raw = ""
|
||||||
for attempt in range(1, max(1, retries) + 1):
|
for attempt in range(1, max(1, retries) + 1):
|
||||||
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
|
conn, path = _open_http(url, timeout)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
conn.putrequest("POST", path)
|
||||||
raw = resp.read().decode("utf-8", errors="ignore")
|
conn.putheader("Content-Type", content_type)
|
||||||
break
|
conn.putheader("Content-Length", str(total))
|
||||||
except urllib.error.HTTPError as exc:
|
for key, value in (headers or {}).items():
|
||||||
detail = exc.read().decode("utf-8", errors="ignore")[:300]
|
conn.putheader(key, value)
|
||||||
if exc.code in (502, 503, 504) and attempt < retries:
|
conn.endheaders()
|
||||||
last_exc = exc
|
conn.send(head)
|
||||||
|
with file_path.open("rb") as stream:
|
||||||
|
while True:
|
||||||
|
chunk = stream.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
conn.send(chunk)
|
||||||
|
conn.send(tail)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
raw = resp.read().decode("utf-8", errors="ignore")
|
||||||
|
status = resp.status
|
||||||
|
if status in (502, 503, 504) and attempt < retries:
|
||||||
|
last_exc = RuntimeError(f"ASR HTTP {status}")
|
||||||
time.sleep(2 * attempt)
|
time.sleep(2 * attempt)
|
||||||
continue
|
continue
|
||||||
raise RuntimeError(f"ASR HTTP {exc.code}: {detail}") from exc
|
if status >= 400:
|
||||||
except (urllib.error.URLError, http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
|
raise RuntimeError(f"ASR HTTP {status}: {raw[:300]}")
|
||||||
|
break
|
||||||
|
except RuntimeError:
|
||||||
|
raise
|
||||||
|
except (http.client.HTTPException, ConnectionError, TimeoutError, OSError) as exc:
|
||||||
last_exc = exc
|
last_exc = exc
|
||||||
if attempt < retries:
|
if attempt < retries:
|
||||||
log(f"multipart upload transient error (attempt {attempt}/{retries}): {exc}")
|
log(f"multipart upload transient error (attempt {attempt}/{retries}): {exc}")
|
||||||
time.sleep(2 * attempt)
|
time.sleep(2 * attempt)
|
||||||
continue
|
continue
|
||||||
raise RuntimeError(f"ASR upload connection error after {retries} attempts: {exc}") from exc
|
raise RuntimeError(f"ASR upload connection error after {retries} attempts: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(f"ASR upload failed: {last_exc}")
|
raise RuntimeError(f"ASR upload failed: {last_exc}")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -337,6 +337,18 @@ def normalize_chunks(chunks, cleaned_parts: list[str], flags: list[dict],
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_internal_wording(text: str) -> str:
|
||||||
|
"""Keep pipeline jargon out of text a person will read."""
|
||||||
|
for src, dst in (
|
||||||
|
("卡片未提供", "记录未写明"),
|
||||||
|
("卡片提及", "录音中提到"),
|
||||||
|
("事实卡片", "整理要点"),
|
||||||
|
("粗整理草稿", "整理稿"),
|
||||||
|
):
|
||||||
|
text = text.replace(src, dst)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def extract_evidence(parts: list[str], usage: Usage) -> str:
|
def extract_evidence(parts: list[str], usage: Usage) -> str:
|
||||||
jobs = [prompts.EVIDENCE_PROMPT.format(chunk_id=i, body=body)
|
jobs = [prompts.EVIDENCE_PROMPT.format(chunk_id=i, body=body)
|
||||||
for i, body in enumerate(parts)]
|
for i, body in enumerate(parts)]
|
||||||
@@ -347,7 +359,7 @@ def extract_evidence(parts: list[str], usage: Usage) -> str:
|
|||||||
max_tokens=1400, usage=usage)
|
max_tokens=1400, usage=usage)
|
||||||
except PartialFailure as pf:
|
except PartialFailure as pf:
|
||||||
outs = pf.outputs
|
outs = pf.outputs
|
||||||
return "\n".join(x for x in outs if x.strip())
|
return scrub_internal_wording("\n".join(x for x in outs if x.strip()))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ S4
|
# ------------------------------------------------------------------ S4
|
||||||
@@ -386,7 +398,7 @@ def write_draft(cleaned: str, meta: dict, context: str, date: str,
|
|||||||
speaker_note=prompts.SPEAKER_NOTE.get(backend, ""),
|
speaker_note=prompts.SPEAKER_NOTE.get(backend, ""),
|
||||||
context=ctx, date=date, source_detail=detail,
|
context=ctx, date=date, source_detail=detail,
|
||||||
transcript=transcript_path, extra_fm=extra, body=cleaned)
|
transcript=transcript_path, extra_fm=extra, body=cleaned)
|
||||||
return chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage)
|
return scrub_internal_wording(chat(CFG.draft_model, prompt, max_tokens=12000, usage=usage))
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ driver
|
# ------------------------------------------------------------------ driver
|
||||||
|
|||||||
@@ -323,6 +323,7 @@ def answer(question_id: int, value: str, *, allow_update: bool = False) -> dict:
|
|||||||
return result
|
return result
|
||||||
payload = json.loads(row["payload"] or "{}")
|
payload = json.loads(row["payload"] or "{}")
|
||||||
recording = row["recording"]
|
recording = row["recording"]
|
||||||
|
previous = str(row["answer"] or "")
|
||||||
con.execute(
|
con.execute(
|
||||||
"UPDATE review_question SET status='answered',answer=?,answered_at=? WHERE id=?",
|
"UPDATE review_question SET status='answered',answer=?,answered_at=? WHERE id=?",
|
||||||
(value, time.time(), question_id))
|
(value, time.time(), question_id))
|
||||||
@@ -343,9 +344,14 @@ def answer(question_id: int, value: str, *, allow_update: bool = False) -> dict:
|
|||||||
confidence=1.0, confirmed=1)
|
confidence=1.0, confirmed=1)
|
||||||
saved = False
|
saved = False
|
||||||
voiceprint_enrolled = False
|
voiceprint_enrolled = False
|
||||||
if payload.get("kind") == "voiceprint":
|
if payload.get("kind") == "voiceprint" and not (allow_update and value == previous):
|
||||||
voiceprint_enrolled = _confirm_voiceprint(
|
try:
|
||||||
payload.get("draft_path", ""), payload.get("speaker_id", ""), value)
|
voiceprint_enrolled = _confirm_voiceprint(
|
||||||
|
payload.get("draft_path", ""), payload.get("speaker_id", ""), value)
|
||||||
|
except Exception as exc:
|
||||||
|
from .log import log
|
||||||
|
log(f"voiceprint enroll failed after answer saved: {exc}", level="WARN")
|
||||||
|
voiceprint_enrolled = False
|
||||||
if payload.get("draft_path"):
|
if payload.get("draft_path"):
|
||||||
try:
|
try:
|
||||||
saved = _persist_draft_feedback(recording, payload["draft_path"])
|
saved = _persist_draft_feedback(recording, payload["draft_path"])
|
||||||
@@ -401,17 +407,32 @@ def reopen_question(question_id: int) -> bool:
|
|||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _question_matches(row, transcript_path: str, draft_path: str, job_id: str) -> bool:
|
||||||
|
payload = json.loads(row["payload"] or "{}")
|
||||||
|
return bool(
|
||||||
|
(transcript_path and row["recording"] == transcript_path)
|
||||||
|
or (draft_path and payload.get("draft_path") == draft_path)
|
||||||
|
or (job_id and payload.get("job_id") == job_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def delete_for_job(transcript_path: str = "", draft_path: str = "", job_id: str = "") -> int:
|
def delete_for_job(transcript_path: str = "", draft_path: str = "", job_id: str = "") -> int:
|
||||||
|
return _delete_questions(transcript_path, draft_path, job_id, pending_only=False)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_pending_for_job(transcript_path: str = "", draft_path: str = "", job_id: str = "") -> int:
|
||||||
|
"""Drop unanswered questions. Answered rows stay so a failed refine can be retried."""
|
||||||
|
return _delete_questions(transcript_path, draft_path, job_id, pending_only=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_questions(transcript_path: str, draft_path: str, job_id: str, *, pending_only: bool) -> int:
|
||||||
con = lexicon.connect()
|
con = lexicon.connect()
|
||||||
rows = con.execute("SELECT id, recording, payload FROM review_question").fetchall()
|
rows = con.execute("SELECT id, recording, payload, status FROM review_question").fetchall()
|
||||||
to_delete: list[int] = []
|
to_delete: list[int] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
payload = json.loads(r["payload"] or "{}")
|
if pending_only and r["status"] != "pending":
|
||||||
if (
|
continue
|
||||||
(transcript_path and r["recording"] == transcript_path)
|
if _question_matches(r, transcript_path, draft_path, job_id):
|
||||||
or (draft_path and payload.get("draft_path") == draft_path)
|
|
||||||
or (job_id and payload.get("job_id") == job_id)
|
|
||||||
):
|
|
||||||
to_delete.append(int(r["id"]))
|
to_delete.append(int(r["id"]))
|
||||||
if to_delete:
|
if to_delete:
|
||||||
marks = ",".join("?" for _ in to_delete)
|
marks = ",".join("?" for _ in to_delete)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Browser session helpers shared by the new workbench and the legacy control UI.
|
||||||
|
|
||||||
|
An empty ``ASR_WEB_PASSWORD`` closes the UI. Set ``ASR_WEB_ALLOW_ANON=1`` only
|
||||||
|
for a local process that is not reachable from the network.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
from http import cookies
|
||||||
|
|
||||||
|
|
||||||
|
from .config import get_setting
|
||||||
|
|
||||||
|
|
||||||
|
def allow_anon() -> bool:
|
||||||
|
return os.getenv("ASR_WEB_ALLOW_ANON", "").strip().lower() in {
|
||||||
|
"1", "true", "yes", "on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def password() -> str:
|
||||||
|
env_pw = os.getenv("ASR_WEB_PASSWORD")
|
||||||
|
if env_pw not in (None, ""):
|
||||||
|
return env_pw
|
||||||
|
return get_setting("web_password", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> bytes:
|
||||||
|
return (password() or os.getenv("ASR_AGENT_TOKEN", "") or "asr-web-unset").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def session_token() -> str:
|
||||||
|
return hmac.new(_secret(), b"asr-web-session-v1", hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_token() -> str:
|
||||||
|
return hmac.new(_secret(), b"asr-web-csrf-v1", hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def cookie_matches(header: str) -> bool:
|
||||||
|
if not password():
|
||||||
|
return False
|
||||||
|
jar = cookies.SimpleCookie()
|
||||||
|
try:
|
||||||
|
jar.load(header or "")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
morsel = jar.get("asr_session")
|
||||||
|
return bool(morsel and hmac.compare_digest(morsel.value, session_token()))
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_matches(supplied: str) -> bool:
|
||||||
|
if not supplied:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(supplied, csrf_token())
|
||||||
@@ -49,6 +49,8 @@ def connect() -> sqlite3.Connection:
|
|||||||
""")
|
""")
|
||||||
cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")}
|
cols = {r[1] for r in con.execute("PRAGMA table_info(intake_job)")}
|
||||||
default_prov = CFG.asr_provider or "qwen3cam"
|
default_prov = CFG.asr_provider or "qwen3cam"
|
||||||
|
if default_prov not in {"qwen3cam", "soniox", "gemini", "openai", "grok"}:
|
||||||
|
default_prov = "qwen3cam"
|
||||||
additions = {
|
additions = {
|
||||||
"draft": "TEXT NOT NULL DEFAULT ''",
|
"draft": "TEXT NOT NULL DEFAULT ''",
|
||||||
"result": "TEXT NOT NULL DEFAULT ''",
|
"result": "TEXT NOT NULL DEFAULT ''",
|
||||||
@@ -104,7 +106,7 @@ def update_job(job_id: str, status: str, detail: str = "", **fields: Any) -> boo
|
|||||||
raise ValueError(f"invalid status: {status}")
|
raise ValueError(f"invalid status: {status}")
|
||||||
allowed = {
|
allowed = {
|
||||||
"transcript", "draft", "result", "sha256", "attempts",
|
"transcript", "draft", "result", "sha256", "attempts",
|
||||||
"error_code", "asr_provider", "asr_model", "title", "context",
|
"error_code", "asr_provider", "asr_model", "title", "context", "stored_name",
|
||||||
}
|
}
|
||||||
changes = {k: v for k, v in fields.items() if k in allowed and v is not None}
|
changes = {k: v for k, v in fields.items() if k in allowed and v is not None}
|
||||||
sets = ["status=?", "updated_at=?", "detail=?"]
|
sets = ["status=?", "updated_at=?", "detail=?"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fcntl
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from asrpipe import graph, lexicon, providers, refine, review, revise, vault
|
from asrpipe import graph, lexicon, providers, refine, review, revise, vault
|
||||||
from asrpipe.config import CFG
|
from asrpipe.config import CFG
|
||||||
|
from asrpipe.locking import acquire_recording_lock
|
||||||
from . import db
|
from . import db
|
||||||
from .config import (
|
from .config import (
|
||||||
DATA, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS,
|
DATA, DRAFTS, FAILED, INBOX, LOGS, MAX_ATTEMPTS,
|
||||||
@@ -21,6 +21,8 @@ from .config import (
|
|||||||
)
|
)
|
||||||
from .notify import send
|
from .notify import send
|
||||||
|
|
||||||
|
_RECORDING_LOCK = None
|
||||||
|
|
||||||
|
|
||||||
def log(message: str) -> None:
|
def log(message: str) -> None:
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
@@ -70,6 +72,31 @@ def transcribe(
|
|||||||
return transcript, res
|
return transcript, res
|
||||||
|
|
||||||
|
|
||||||
|
def intake_header(payload: dict) -> str:
|
||||||
|
blob = json.dumps(payload, ensure_ascii=False).replace("--", "- -")
|
||||||
|
return f"<!-- intake: {blob} -->"
|
||||||
|
|
||||||
|
|
||||||
|
def replace_intake(raw: str, header: str) -> str:
|
||||||
|
if raw.startswith("<!-- intake:"):
|
||||||
|
_first, _sep, rest = raw.partition("\n")
|
||||||
|
return header + ("\n" + rest if _sep else "")
|
||||||
|
return header + "\n" + raw
|
||||||
|
|
||||||
|
|
||||||
|
def beat() -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
(STATE / "worker.heartbeat").write_text(str(time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
def _beat_loop(stop: threading.Event) -> None:
|
||||||
|
while not stop.wait(15):
|
||||||
|
try:
|
||||||
|
beat()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def process_audio(source: Path, job: dict) -> None:
|
def process_audio(source: Path, job: dict) -> None:
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
attempts = int(job.get("attempts") or 0) + 1
|
attempts = int(job.get("attempts") or 0) + 1
|
||||||
@@ -97,7 +124,7 @@ def process_audio(source: Path, job: dict) -> None:
|
|||||||
"asr_model": res.model,
|
"asr_model": res.model,
|
||||||
}
|
}
|
||||||
raw = transcript.read_text(encoding="utf-8")
|
raw = transcript.read_text(encoding="utf-8")
|
||||||
raw = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->\n" + raw
|
raw = replace_intake(raw, intake_header(intake))
|
||||||
transcript.write_text(raw, encoding="utf-8")
|
transcript.write_text(raw, encoding="utf-8")
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id, "refining", f"转写完成 ({res.provider}:{res.model}),正在生成整理稿",
|
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_provider": job.get("asr_provider") or CFG.asr_provider,
|
||||||
"asr_model": job.get("asr_model") or "",
|
"asr_model": job.get("asr_model") or "",
|
||||||
}
|
}
|
||||||
intake_line = f"<!-- intake: {json.dumps(intake, ensure_ascii=False)} -->"
|
raw = replace_intake(raw, intake_header(intake))
|
||||||
if re.match(r"^<!--\s*intake:\s*\{.*?\}\s*-->", raw):
|
|
||||||
raw = re.sub(r"^<!--\s*intake:\s*\{.*?\}\s*-->", intake_line, raw, count=1)
|
|
||||||
else:
|
|
||||||
raw = intake_line + "\n" + raw
|
|
||||||
transcript.write_text(raw, encoding="utf-8")
|
transcript.write_text(raw, encoding="utf-8")
|
||||||
|
|
||||||
db.update_job(job_id, "refining", "正在基于现有转写重新生成精修草稿")
|
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(
|
result = refine.refine(
|
||||||
raw, date=time.strftime("%Y-%m-%d"),
|
raw, date=time.strftime("%Y-%m-%d"),
|
||||||
transcript_path=str(transcript), queue_review=True,
|
transcript_path=str(transcript), queue_review=True,
|
||||||
)
|
)
|
||||||
draft = DRAFTS / f"{job_id}.md"
|
draft = DRAFTS / f"{job_id}.md"
|
||||||
vault.write_draft_file(draft, result.draft)
|
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)
|
questions = review.enqueue_draft(str(transcript), draft)
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id, "review", f"整理稿已重新生成,{questions} 个问题待确认",
|
job_id, "review", f"整理稿已重新生成,{questions} 个问题待确认",
|
||||||
@@ -176,6 +199,9 @@ def claim(path: Path) -> tuple[Path, dict]:
|
|||||||
if target.exists():
|
if target.exists():
|
||||||
target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}"
|
target = PROCESSING / f"{path.stem}-{job['id'][:8]}{path.suffix}"
|
||||||
path.replace(target)
|
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
|
return target, job
|
||||||
|
|
||||||
|
|
||||||
@@ -199,15 +225,54 @@ def scan_inbox() -> None:
|
|||||||
error_code=type(exc).__name__,
|
error_code=type(exc).__name__,
|
||||||
)
|
)
|
||||||
log(f"job={job['id']} failed type={type(exc).__name__} retry={retry} err={exc}")
|
log(f"job={job['id']} failed type={type(exc).__name__} retry={retry} err={exc}")
|
||||||
target = INBOX / source.name if retry else FAILED / source.name
|
try:
|
||||||
if source.exists():
|
if source.exists():
|
||||||
shutil.move(str(source), target)
|
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:
|
def process_revisions() -> None:
|
||||||
for marker in sorted(REVISION_QUEUE.glob("*.json")):
|
for marker in sorted(REVISION_QUEUE.glob("*.json")):
|
||||||
|
if marker.name.startswith("."):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
payload = json.loads(marker.read_text(encoding="utf-8"))
|
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")
|
action = str(payload.get("action") or "revise")
|
||||||
job_id = str(payload.get("job_id") or "")
|
job_id = str(payload.get("job_id") or "")
|
||||||
if action == "rerefine":
|
if action == "rerefine":
|
||||||
@@ -231,27 +296,45 @@ def process_revisions() -> None:
|
|||||||
)
|
)
|
||||||
marker.unlink(missing_ok=True)
|
marker.unlink(missing_ok=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"revision marker={marker.name} failed type={type(exc).__name__} err={exc}")
|
_defer_revision(marker, payload, exc)
|
||||||
try:
|
|
||||||
payload = json.loads(marker.read_text(encoding="utf-8"))
|
|
||||||
jid = str(payload.get("job_id") or "")
|
def _park_audio(path: Path) -> Path:
|
||||||
if jid:
|
dest = INBOX / path.name
|
||||||
db.update_job(jid, "failed", f"精修/修订失败:{exc}", error_code=type(exc).__name__)
|
if dest.exists():
|
||||||
except Exception:
|
dest = INBOX / f"{path.stem}-recovered{path.suffix}"
|
||||||
pass
|
shutil.move(str(path), dest)
|
||||||
marker.unlink(missing_ok=True)
|
old = time.time() - max(STABLE_SECONDS, 60)
|
||||||
|
os.utime(dest, (old, old))
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def recover() -> None:
|
def recover() -> None:
|
||||||
for job in db.pending_recovery():
|
"""Return audio left in processing/ to the inbox and requeue its job."""
|
||||||
if job["status"] in {"transcribing", "refining"}:
|
if not PROCESSING.exists():
|
||||||
candidate = next(
|
return
|
||||||
(p for p in PROCESSING.iterdir() if p.name == job["stored_name"]),
|
files = [p for p in PROCESSING.iterdir() if p.is_file()]
|
||||||
None,
|
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:
|
log(f"recovered job={match['id']} audio={dest.name}")
|
||||||
shutil.move(str(candidate), INBOX / candidate.name)
|
else:
|
||||||
db.update_job(job["id"], "queued", "worker 重启后重新排队")
|
log(f"recovered orphan audio={dest.name}")
|
||||||
|
|
||||||
|
|
||||||
def sync_vault_knowledge() -> None:
|
def sync_vault_knowledge() -> None:
|
||||||
@@ -281,24 +364,36 @@ def run_loop() -> None:
|
|||||||
os.environ.setdefault("ASR_ROLE", "worker")
|
os.environ.setdefault("ASR_ROLE", "worker")
|
||||||
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
|
os.environ.setdefault("ASR_REVISION_QUEUE_DIR", str(REVISION_QUEUE))
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
lock = (STATE / "worker.lock").open("w")
|
global _RECORDING_LOCK
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
lock_path = Path(os.getenv("ASR_CONSUMER_LOCK", str(STATE / "consumer.lock")))
|
||||||
|
_RECORDING_LOCK = acquire_recording_lock(lock_path)
|
||||||
recover()
|
recover()
|
||||||
sync_vault_knowledge()
|
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_backup = 0.0
|
||||||
last_vault_sync = time.time()
|
last_vault_sync = time.time()
|
||||||
while True:
|
while True:
|
||||||
scan_inbox()
|
try:
|
||||||
process_revisions()
|
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()
|
now = time.time()
|
||||||
if now - last_vault_sync >= 900:
|
if now - last_vault_sync >= 900:
|
||||||
sync_vault_knowledge()
|
sync_vault_knowledge()
|
||||||
last_vault_sync = now
|
last_vault_sync = now
|
||||||
if now - last_backup >= 3600:
|
if now - last_backup >= 3600:
|
||||||
backup_databases()
|
try:
|
||||||
|
backup_databases()
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"backup failed: {exc}")
|
||||||
last_backup = now
|
last_backup = now
|
||||||
(STATE / "worker.heartbeat").write_text(str(now))
|
beat()
|
||||||
time.sleep(POLL_SECONDS)
|
time.sleep(POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+19
@@ -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 "$@"
|
||||||
@@ -27,8 +27,8 @@ x-asr-environment: &asr-environment
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
asr-web:
|
asr-web:
|
||||||
image: tools-asr-pipeline:2026-11-02
|
image: ${ASR_IMAGE:-tools-asr-pipeline:latest}
|
||||||
pull_policy: never
|
pull_policy: ${ASR_PULL_POLICY:-never}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -38,12 +38,10 @@ services:
|
|||||||
<<: *asr-environment
|
<<: *asr-environment
|
||||||
ASR_ROLE: web
|
ASR_ROLE: web
|
||||||
ports:
|
ports:
|
||||||
- "192.168.50.49:8788:8788"
|
- "${ASR_BIND_ADDRESS:-192.168.50.49}:${ASR_PORT:-8788}:8788"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/truenas/personal/ASR-Pipeline:/data
|
- ${ASR_DATA_VOLUME:-/mnt/truenas/personal/ASR-Pipeline}:/data
|
||||||
- /opt/asr-pipeline/state:/state
|
- ${ASR_STATE_VOLUME:-/opt/asr-pipeline/state}:/state
|
||||||
- /opt/asr-pipeline/vault:/data/vault
|
|
||||||
- /opt/asr-pipeline/ssh:/root/.ssh:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8788/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -54,8 +52,8 @@ services:
|
|||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
asr-worker:
|
asr-worker:
|
||||||
image: tools-asr-pipeline:2026-11-02
|
image: ${ASR_IMAGE:-tools-asr-pipeline:latest}
|
||||||
pull_policy: never
|
pull_policy: ${ASR_PULL_POLICY:-never}
|
||||||
container_name: tools-asr-worker
|
container_name: tools-asr-worker
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -63,10 +61,8 @@ services:
|
|||||||
ASR_ROLE: worker
|
ASR_ROLE: worker
|
||||||
command: ["python", "-m", "asrservice.worker"]
|
command: ["python", "-m", "asrservice.worker"]
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/truenas/personal/ASR-Pipeline:/data
|
- ${ASR_DATA_VOLUME:-/mnt/truenas/personal/ASR-Pipeline}:/data
|
||||||
- /opt/asr-pipeline/state:/state
|
- ${ASR_STATE_VOLUME:-/opt/asr-pipeline/state}:/state
|
||||||
- /opt/asr-pipeline/vault:/data/vault
|
|
||||||
- /opt/asr-pipeline/ssh:/root/.ssh:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
|
test: ["CMD", "python", "-m", "asrservice.healthcheck"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 现网路径快照。模板参数 %I 与旧 asr-refine 路径保持原样,不要在本仓库直接启用。
|
||||||
|
[Unit]
|
||||||
|
Description=Prepare or publish Web-reviewed ASR draft %I
|
||||||
|
After=network-online.target asr-review-web.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline
|
||||||
|
EnvironmentFile=/home/claw/claude-workspace/OBSIDIAN/bin/.asr-refine.env
|
||||||
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/bin/asr-refine revise %I
|
||||||
|
TimeoutStartSec=900
|
||||||
|
UMask=0077
|
||||||
|
MemoryMax=512M
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 现网路径快照。ExecStart / EnvironmentFile / 工作目录保持原样,不要在本仓库直接启用。
|
||||||
|
[Unit]
|
||||||
|
Description=Persistent ASR recording intake and review UI
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline
|
||||||
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/.venv/bin/python -m asrpipe.control_web
|
||||||
|
Environment=PYTHONPATH=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline
|
||||||
|
Environment=ASR_RECORDINGS_ROOT=/home/claw/claude-workspace/OBSIDIAN/recordings
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
TimeoutStopSec=10
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/state
|
||||||
|
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/logs
|
||||||
|
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/recordings/inbox
|
||||||
|
ReadWritePaths=/home/claw/claude-workspace/OBSIDIAN/recordings/state
|
||||||
|
MemoryMax=192M
|
||||||
|
TasksMax=32
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 现网路径快照。EnvironmentFile 与 ExecStart 指向旧目录,保持原样,不要在本仓库直接启用。
|
||||||
|
[Unit]
|
||||||
|
Description=Refine new ASR transcripts into Obsidian drafts (standalone, no agent CLI)
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Credentials only. The program needs no agent CLI, no login state, no OAuth —
|
||||||
|
# just this token and a network route. Token rotation = edit this one file.
|
||||||
|
EnvironmentFile=/home/claw/claude-workspace/OBSIDIAN/bin/.asr-refine.env
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/home/claw/claude-workspace/OBSIDIAN/asr-pipeline/bin/asr-refine run-all
|
||||||
|
# A long meeting is ~90 chunks; S2 is parallel but S4 is one serial strong-model
|
||||||
|
# call, so allow a generous window for a batch of transcripts.
|
||||||
|
TimeoutStartSec=3600
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Watch Obsidian .asr-inbox for new finished transcripts
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
# Fires when a finished transcript file is mv'd into .asr-inbox/ (dir mtime changes).
|
||||||
|
# Markers under .state/ and staging in .incoming/ live in subdirs, so they don't refire.
|
||||||
|
PathModified=/home/claw/obsidian-vault/.asr-inbox
|
||||||
|
# 2026-07-25: switched from obsidian-asr-refine.service (headless `claude -p`) to the
|
||||||
|
# standalone pipeline. The old unit + obsidian-asr-refine.sh are kept intact; to roll
|
||||||
|
# back, point Unit= back at obsidian-asr-refine.service and daemon-reload.
|
||||||
|
Unit=obsidian-asr-refine-native.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 现网路径快照。ExecStart 与 MEETING_ASR_BACKEND 保持原样,不要在本仓库直接启用。
|
||||||
|
[Unit]
|
||||||
|
Description=Obsidian Meeting Recording Watcher (audio inbox -> ASR -> bridge to .asr-inbox)
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 /home/claw/claude-workspace/OBSIDIAN/recordings/bin/meeting-watch.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
WorkingDirectory=/home/claw/claude-workspace/OBSIDIAN/recordings
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=MEETING_INBOX_BASE=/home/claw/claude-workspace/OBSIDIAN/recordings
|
||||||
|
Environment=MEETING_WEBHOOK_URL=http://127.0.0.1:8644/webhooks/meeting-recording
|
||||||
|
# ASR backend trial (2026-06-26): route live pipeline through VibeVoice 分角色.
|
||||||
|
# Revert: remove this line (or set =qwen3) + daemon-reload + restart.
|
||||||
|
Environment=MEETING_ASR_BACKEND=qwen3cam
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# ASR Agent 解耦实施项目(v2)
|
||||||
|
|
||||||
|
日期:2026-09-18。状态:代码核查后的实施计划,尚未执行生产改造。
|
||||||
|
|
||||||
|
上游:[耦合 Review](agent-decoupling-review.md)、[v1 实施文档](agent-decoupling-implementation.md)。本文件保留原文作为历史依据,修订执行设计和验收方式;实际开发以当前代码和本计划为准。本文中的工作项不是已完成变更,也不以旧文档的“已授权”文字代替后续执行请求。
|
||||||
|
|
||||||
|
## 1. 项目目标与交付边界
|
||||||
|
|
||||||
|
让录音监听与 ASR 管线通过同一份本地通知接口发送状态;目标地址、密钥和后端由独立配置控制。核心整理程序继续使用 HTTPS 模型 API 和文件工作流,不依赖 Claude、Hermes、Codex 的运行时、登录态或私有配置。
|
||||||
|
|
||||||
|
首期交付:唯一发送实现、通知参数纠错、完整日志脱敏、两个 Python 调用方接入、兼容旧 shell CLI、隔离测试、部署/回滚记录、跨 agent 协议文档。通知目标尚未核实不阻塞代码交付,但“接口改造完成”与“真实消息送达”必须分别报告。
|
||||||
|
|
||||||
|
后续独立项目:watcher 与 intake 数据库解耦、通用 agent skill 包装、Telegram direct 后端、STT wrapper 命名迁移。首期不修改五阶段算法、模型、fidelity gate、Vault 内容、Hermes 配置、历史备份或 rollback 链路,不新增第三方依赖。
|
||||||
|
|
||||||
|
## 2. 代码核查结果与 v1 修订
|
||||||
|
|
||||||
|
下列行号对应 2026-09-18 核查快照,实施时以函数名定位。路径相对 `OBSIDIAN/`。
|
||||||
|
|
||||||
|
| 证据 | 结论及实施影响 |
|
||||||
|
|---|---|
|
||||||
|
| `bin/meeting-status-notify.py:17–19,22–48` | 地址写死、运行时读取 Hermes 私有订阅;与 watcher 重复签名发送。应收敛为唯一模块。 |
|
||||||
|
| `asr-pipeline/asrpipe/vault.py:126–140` | status 被写死为“草稿已生成,待确认”;缺脚本分支直接记录 body,异常分支原样记录异常,可能包含带正文的 argv。修参数时须同时修所有脱敏分支。 |
|
||||||
|
| `recordings/bin/meeting-watch.py:177–216` | `post_webhook()` 实际被 `post_status()` 调用;死的是默认 `meeting-recording` 路由,不是整个函数无人调用。先替换状态路径,再删通用发送函数。 |
|
||||||
|
| `recordings/bin/meeting-watch.py:142` | `hashlib.sha256()` 还用于音频 ID,删除通知签名后不能顺手删 `hashlib`。 |
|
||||||
|
| `asr-pipeline/asrpipe/cli.py:136–147` | dry-run 提前返回,不经过通知分支,不能证明通知降级正常。 |
|
||||||
|
| `asr-pipeline/asrpipe/cli.py:191–248` | doctor 会同步词库、访问 Hindsight 和调用模型;不作为通知重构的必跑验收。 |
|
||||||
|
| `asr-pipeline/tests/test_core.py:11–14` | 已隔离 state/vault/runtime,但未隔离 ASR_LOG;新测试必须补齐日志、通知配置和网络隔离。 |
|
||||||
|
| `asr-pipeline/asrpipe/control_web.py:341–367,457–464` | 有查询/上传/审核接口,没有 watcher 注册或状态更新 API。不能将“统一走现有 HTTP API”作为可直接执行的替换指令。 |
|
||||||
|
| `asr-pipeline/asrpipe/review_web.py:30–56` | 当前 request_start 接入常驻 control_web;旧 capability 页面逻辑也仍在文件中。协议必须区分真实生产入口与兼容路径,不能把所有审核 URL 都称为一次性地址。 |
|
||||||
|
| `recordings/bin/meeting-watch.py` 的 `process_one()` / `main()` | 先移动到 processing 再等待转写,启动仅扫 inbox;直接 restart 可能中断转写并留下孤立文件。部署必须排空任务。 |
|
||||||
|
|
||||||
|
其他修订:
|
||||||
|
|
||||||
|
- watcher unit 的 `MEETING_WEBHOOK_URL` 是旧正文路由的死配置,并非当前状态通知的第三个有效 URL 来源;仍应清理,避免继续误导。
|
||||||
|
|
||||||
|
- v1 建议保留失效地址并默认 webhook;v2 改为新模板默认 `none`,不在可执行默认值里保留已知失效 endpoint。已有部署配置必须先备份、显式迁移,不静默覆盖。
|
||||||
|
- v1 空密钥允许不签名;v2 webhook 缺有效密钥时降级为配置错误且不发送,避免将审核链接投往未认证的目标。未来需要匿名 webhook 时另定义显式协议。
|
||||||
|
- “通知失败不阻塞”指不影响业务成败;同步 HTTP 仍有等待。本期保留有界等待,不声称实现了异步或可靠消息队列。
|
||||||
|
- 旧文档的 `.48` SSH/端口结论属于历史记录;本轮没有重新探测远端,也未读取凭证。新地址、密钥和投递结果仍需部署时核实。
|
||||||
|
- 递归搜索只限活跃源文件及相关 unit,排除 `.venv`、备份和其他服务;不能把整个用户目录中出现 8644 或 HMAC 都认定为本项目失败。
|
||||||
|
|
||||||
|
## 3. 目标接口与配置决策
|
||||||
|
|
||||||
|
### 3.1 单一实现与加载方式
|
||||||
|
|
||||||
|
以 `OBSIDIAN/bin/meeting-status-notify.py` 为唯一配置解析、消息序列化、HMAC 和 HTTP 实现,不再另建插件框架或共享包。该文件必须能被 import,顶层不读取密钥、不发请求,CLI 放在 main guard 内。
|
||||||
|
|
||||||
|
建议接口:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def send(status: str, filename: str = "", detail: str = "", *,
|
||||||
|
backend: str | None = None) -> bool:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
前三个位置参数保持兼容;可选 backend 供 `CFG.notify_backend` 显式覆盖使用。只在对端返回 2xx 时返回 True;静音、配置错误和投递失败均返回 False,并用无敏感信息的原因码区分。函数捕获普通 Exception,不捕获 KeyboardInterrupt/SystemExit。CLI 仍接收 `(status, filename, detail)`,投递失败保持退出码 0;CLI 成功退出不代表送达成功。
|
||||||
|
|
||||||
|
两个调用方使用 `importlib.util.spec_from_file_location` 从绝对路径加载该模块,避免 sys.path 全局改动和引入管线依赖。vault 沿用 `CFG.notifier`;watcher 从 `__file__` 的项目目录定位。模块可缓存,配置每次 send 重新读取,配置修改无需重启;首次加载失败不永久缓存失败状态。加载与调用均置于降级保护中。
|
||||||
|
|
||||||
|
### 3.2 配置契约
|
||||||
|
|
||||||
|
生产文件 `OBSIDIAN/bin/notify-target.json`(0600),另新增不含真实凭证的 `notify-target.example.json`。配置更新采用同目录 0600 临时文件、写入后原子替换;不把密钥放进命令行、终端输出或文档。源码备份和凭证备份分开记录,凭证备份同样 0600,后续纳入版本控制时排除生产配置。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backend": "none",
|
||||||
|
"webhook": {
|
||||||
|
"url": "",
|
||||||
|
"secret": "",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 变量 / 字段 | 语义与优先级 |
|
||||||
|
|---|---|
|
||||||
|
| `MEETING_NOTIFY_CONFIG` | 配置路径覆盖;默认脚本同目录的 notify-target.json,不能依赖 cwd。 |
|
||||||
|
| `send(..., backend=...)` | 非空显式值最高优先;vault 从 `CFG.notify_backend` 传入。 |
|
||||||
|
| `ASR_NOTIFY_BACKEND` | 两调用方共用的 backend 环境覆盖;其后依次为 JSON、内置 none。 |
|
||||||
|
| `MEETING_STATUS_WEBHOOK_URL` | 非空值覆盖 JSON URL,保留 watcher 既有状态 URL 配置能力。 |
|
||||||
|
| `MEETING_STATUS_WEBHOOK_SECRET` | 非空值覆盖 JSON secret;新增状态专用变量,不复用旧正文路由密钥变量。 |
|
||||||
|
| `ASR_NOTIFY=0` | 保持既有 asrpipe 总开关语义,不扩展为 watcher 的隐式总开关;全部静音用 backend=none。 |
|
||||||
|
| `webhook.timeout` | 有限正数,约束在 0 < timeout <= 10 秒;非法值不发送。urllib 超时不是全链路严格墙钟上限,尤其 DNS 阶段,文档不得作更强保证。 |
|
||||||
|
|
||||||
|
未知 backend、损坏 JSON、非对象配置、无 URL/密钥、非法 timeout:安全原因码 + False,无网络请求。`none` 不要求 webhook 字段有效。backend=webhook 仅支持明确的 HTTP(S) URL,不把旧 `INSECURE_NO_AUTH` 当作有效共享密钥。环境变量只作为有记录的部署覆盖;正常运行不在各 unit 重复配置 endpoint。
|
||||||
|
|
||||||
|
HMAC 保持现有线协议:UTF-8 JSON,字段 `status/filename/detail`,`ensure_ascii=False`,对实际发送的同一份 bytes 做 SHA256 HMAC,放入 `X-Webhook-Signature`。拒绝自动重定向,避免把正文转发到其他目标;非 2xx 均判失败。无自动重试,不承诺 exactly-once,也不引入后台线程。
|
||||||
|
|
||||||
|
### 3.3 调用语义与日志
|
||||||
|
|
||||||
|
- `vault.notify(title, body)` → `send(status=title, filename="", detail=body)`;不要把 title 再塞进 filename。
|
||||||
|
- watcher 的 `post_status(status, filename, detail="")` 保持三个调用点形式和字段含义。
|
||||||
|
- legacy shell 仍用原三个位置参数运行脚本。
|
||||||
|
- `redact_log=True` 时,成功、静音、缺文件、加载失败、配置错误、网络异常各路径都不能出现 body。保护范围包括 stdout/stderr、HTTP 响应正文和原始异常字符串;发送模块只记录安全原因码/HTTP 状态,不记录 payload、密钥、目标完整 URL 或响应正文。
|
||||||
|
- 建议发送层统一不记录正文,vault 对非敏感正文是否保留既有日志由最小变更原则决定;敏感正文标记必须在进入任何日志分支前计算。
|
||||||
|
- 配置缺失/静音必须可诊断,但不改变录音、转写、草稿或发布成功状态。
|
||||||
|
|
||||||
|
## 4. 实施工作包与依赖
|
||||||
|
|
||||||
|
| 工作包 | 交付物 | 依赖 / 完成门槛 |
|
||||||
|
|---|---|---|
|
||||||
|
| W0:基线与备份 | 活跃文件清单、隔离测试记录、文件哈希与带时间戳备份清单 | 开始前确认无 Git 保护;源码、unit、文档修改前逐项备份。 |
|
||||||
|
| W1:唯一发送实现 | notify 脚本、示例配置、临时配置测试 | W0;none/签名/失败/CLI/脱敏全部通过,不触达真实目标。 |
|
||||||
|
| W2:调用方迁移 | vault.py、config.py、meeting-watch.py、必要的 doctor 静态检查调整 | W1;5 个 vault 调用点、3 个 watcher 调用点及 legacy CLI 兼容性通过。 |
|
||||||
|
| W3:协议与索引 | agent-protocol.md、HANDOFF/CLAUDE/skill 引用更新 | W2;区分当前与计划接口,移除过时排障入口的误导。 |
|
||||||
|
| W4:部署与回滚演练 | unit 去旧变量、空闲窗口记录、进程重载验证、迁移报告 | W1–W3 全部离线通过;配置初始 none,业务服务健康。 |
|
||||||
|
| W5:真实通知恢复 | 核实目标与项目自持密钥后的配置更新、实际投递记录 | 已具备真实目标及发测试消息的明确授权;不以代码测试冒充真实验收。 |
|
||||||
|
|
||||||
|
建议未来并行执行:通知 agent 负责 W1 和发送层测试;调用方 agent 负责 W2 与调用方测试;文档 agent 负责 W3;集成者负责 W0/W4/W5。W1/W2 先冻结第 3 节契约,避免同时修改同一测试文件。当前本轮子智能体仅完成只读探索,未执行这些开发包。
|
||||||
|
|
||||||
|
W2 特别要求:删除 watcher 的六个 webhook 常量及私有订阅读取,移除不再需要的 hmac/urllib 导入,保留用于文件 ID 的 hashlib、用于元数据的 json。保留 `CFG.notify`、`CFG.notifier`;新增 `CFG.notify_backend` 并明确传递,避免“声明配置字段却没生效”。doctor 的 notifier 存在性可保留,但不得把文件存在描述为通知链路健康。
|
||||||
|
|
||||||
|
## 5. 离线测试与验收矩阵
|
||||||
|
|
||||||
|
优先新增 `tests/test_notify.py` 与 watcher 适配测试,保留既有 unittest 风格。测试导入前设置临时 ASR_STATE_DIR、ASR_VAULT、ASR_LOG、XDG_RUNTIME_DIR、MEETING_INBOX_BASE、ASR_CONTROL_DB 和 MEETING_NOTIFY_CONFIG;清理继承的通知覆盖变量,禁止实际 urlopen、模型调用和 systemctl。测试不读取生产 notify-target.json 或 Hermes 配置。不要用修改生产 backend 的方式进行验收。
|
||||||
|
|
||||||
|
| 测试组 | 必须断言 |
|
||||||
|
|---|---|
|
||||||
|
| 参数 | 所有 5 处 vault 事件保留真实 status,filename 为空、detail 保留正文;watcher 3 个事件字段不变。 |
|
||||||
|
| 配置 | env > JSON > 默认;显式 CFG backend 传递;none、缺文件、坏 JSON、未知 backend、非法 timeout、缺密钥不发请求。 |
|
||||||
|
| 协议 | 中文 payload、准确 HMAC 字节与 header、POST/Content-Type、2xx 成功、3xx 不跟随、4xx/5xx 失败。 |
|
||||||
|
| 降级 | URLError、超时、import 失败、缺脚本、send 意外异常均不传到业务调用方。 |
|
||||||
|
| 脱敏 | 用唯一假 capability URL 作标记,遍历全部分支;标记不得出现在应用日志、stdout、stderr、异常回显中。 |
|
||||||
|
| 兼容 | CLI 0/1/2/3 参数默认值和旧 shell 三参数调用;投递失败 exit 0,但 send 返回 False。 |
|
||||||
|
| 配置重载 | 同一已加载模块中原子换临时配置,下次 send 使用新值;失败加载不会永久缓存。 |
|
||||||
|
| watcher | import 不启动循环;post_status 调用唯一模块;计算文件 ID 等非通知行为保持原状。 |
|
||||||
|
|
||||||
|
本轮实测基线:额外设置 `PYTHONDONTWRITEBYTECODE=1`、临时 `ASR_LOG` / `ASR_CACHE_DIR`、`ASR_NOTIFY=0`、`ASR_GIT_SYNC=0` 后,结果为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ran 28 tests in 0.122s
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
新增测试还应验证不同 cwd、未设置 PYTHONPATH 时两个入口的加载方式。统一 bootstrap 必须先于任何 `asrpipe` 导入,避免 unittest discovery 顺序导致单例 CFG 捕获另一个测试模块的环境;测试 backend 开关本身时在 mock transport 内局部解除总开关。
|
||||||
|
|
||||||
|
测试命令应在一个由测试运行器统一创建的临时环境下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
上面命令只是入口,隔离环境由运行器或测试 fixture 事先提供,不能裸命令后假定生产日志也已隔离。最终报告保留真实 `Ran N tests ... OK`,不硬编码最终数量。附加静态检查:`bash -n` 检查旧 shell;编译改动 Python 时把 pycache 定向临时目录;限定目标文件检查无 Hermes 私有配置读取、无旧 route 默认值、签名只保留一份。
|
||||||
|
|
||||||
|
doctor 和完整 dry-run 均不列入本项目必需验收:前者有数据库写和联网行为,后者不覆盖通知且会调用模型。若另需全流程集成测试,使用合成录音/转写和隔离状态目录,单独记录目的与副作用。
|
||||||
|
|
||||||
|
## 6. 部署与回滚
|
||||||
|
|
||||||
|
1. 在隔离副本完成代码和测试,部署前重新检查磁盘、备份、unit 与 drop-in 的实际生效设置;不输出含凭证的整个环境。旧报告中的 active/inactive 和队列为空只是一时快照。
|
||||||
|
2. 暂停新上传/投递的窗口内,确认 inbox/processing、watcher 子进程及 native/revise 作业没有在处理任务。先排空再停止相关进程;只看到 processing 为空不等于没有入队竞态。
|
||||||
|
3. 若 processing 已有孤立文件,记录并按现有恢复流程处理,不自动移动回 inbox,避免重复转写。未排空则推迟部署,不使用强制 restart。
|
||||||
|
4. 部署已验证代码和权限为 0600 的本地配置;首次无真实目标时保持 none,并在交付记录明确“通知尚未恢复”。生产凭证一次性迁移时只复制所需字段,不输出密钥;源文件缺失则不阻塞代码部署。
|
||||||
|
5. watcher unit 删除旧 `MEETING_WEBHOOK_URL`,保留 `MEETING_INBOX_BASE`、`MEETING_ASR_BACKEND` 和既有回滚注释;检查 drop-in 是否仍覆盖旧变量。daemon-reload 后在空闲窗口启动 watcher。
|
||||||
|
6. 检查常驻 control_web 是否已加载改动模块,安排在无审核提交、无正在上传的请求及 `.uploading/*.part` 活跃写入时重启以加载新代码;oneshot 下一次启动自然加载。不能假定仅重启 watcher 会更新全部 Python 进程。无需改 review/revise unit 的模型凭证设置。
|
||||||
|
7. 只读检查目标服务 active、进程入口和脱敏日志;真实消息测试留给 W5。新地址更新只更换独立配置,不再改源码/unit。
|
||||||
|
|
||||||
|
回滚按同一空闲流程恢复成套文件(发送脚本、调用方、unit、配置),daemon-reload 并启动相关服务;记录恢复的哈希和权限。不要回滚生产数据库或手工复制运行中 SQLite 来“恢复状态”;本期不改变其 schema。旧版本通知本来可能不可用,回滚恢复的是业务行为,不能宣称修复消息投递。源码备份不得清理原有历史 `.bak.*`。
|
||||||
|
|
||||||
|
## 7. 协议文档与通用 agent 接入
|
||||||
|
|
||||||
|
`docs/agent-protocol.md` 作为通知和 agent 接入契约来源;HANDOFF 保留管线架构,control-web.md 保留现有 HTTP 接口说明。其他索引链接到这些来源,不再复制整套阶段表。
|
||||||
|
|
||||||
|
协议至少写清:send/CLI 参数、配置优先级、送达与静音语义、故障定位、敏感日志规则、真实现有的查询/上传路由,以及“审核回答由人完成,agent 不代填确认”。当前 HTTP 服务没有通用公网认证,不能描述为可直接暴露的 agent API;旧一次性页面与当前任务页的边界须准确。
|
||||||
|
|
||||||
|
更新 `OBSIDIAN/CLAUDE.md` 与 `HANDOFF.md` 的通知说明;HANDOFF 仍有“粗稿 commit/push”和旧临时 Web 生命周期描述,核实后标注历史或改为当前行为,保留算法历史数据。`~/.claude/skills/memo-inbox/references/webhook-toolset-diagnostics.md` 标历史归档并指向协议;修改前读该 skill,去重其他引用时保留“先粗整理、人工确认后正式发布”等纪律。修改 skill 必须在报告中列明。
|
||||||
|
|
||||||
|
文档同步还必须覆盖下列 v1 漏项:
|
||||||
|
|
||||||
|
- `~/.claude/skills/memo-inbox/SKILL.md:51–52,217–221`:替换 active 的 `claude -p → minutes/_drafts` 描述及旧 webhook-agent/toolset 必查入口。
|
||||||
|
- `~/.claude/skills/memo-inbox/references/meeting-recording-inbox.md`:更新或明确归档旧 `.hermes-meetings`、正文 webhook 和私有配置排障路径。
|
||||||
|
- `~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md:16–20`:修正粗稿入 Vault、commit/push、Mac 审阅的旧时序,保留该技能独有的人工确认纪律。
|
||||||
|
|
||||||
|
首期交付跨 agent 可直接读取的协议,不把“创建 Markdown”冒充“已安装通用 skill”。如另做通用 skill,先使用 skill-creator,做薄入口引用上述契约,不复制业务规则、不读取私有 agent 状态、不授予自动审核或发消息的新权限。
|
||||||
|
|
||||||
|
## 8. 后续项目:任务状态与数据库解耦
|
||||||
|
|
||||||
|
先盘点 watcher、cli、review_web、control_web 的全部 intake_job 读写,明确哪些是同一服务的内部实现、哪些是真正跨进程契约;不要把所有内部函数调用都 HTTP 化。
|
||||||
|
|
||||||
|
优先评估提取唯一 intake store/schema 模块,由本机组件共享,先去重复 DDL;如确需 watcher 进程与控制服务独立部署,再引入版本化内部 API。后者必须先设计注册、状态迁移、幂等键、重复/乱序更新、认证和服务不可用时的持久重试。候选路由是未来设计,不是现有接口。网页服务中断不能新增为转录中断条件;未解决断网状态恢复前不移除 watcher 的当前写入路径。
|
||||||
|
|
||||||
|
验收至少覆盖:Web 上传与直接投文件、重复 fid、控制服务暂时不可用、转录/桥接失败、进程重启恢复、草稿/发布状态关联、禁止旧状态覆盖新状态。该项目单独评审和交付,不与首期通知上线绑定。
|
||||||
|
|
||||||
|
## 9. 完成定义与执行报告
|
||||||
|
|
||||||
|
- 代码完成:唯一发送实现、参数正确、完整脱敏、原调用契约兼容、隔离回归通过。
|
||||||
|
- 部署完成:已排空并加载新代码,配置权限正确,服务健康,有可执行的成套回滚记录。
|
||||||
|
- 通知恢复:已核实真实 endpoint/共享密钥,并在获得测试消息发送授权后确认端到端投递;只有 HTTP 2xx 时记录“后端接受”,未确认接收端则不写“用户已收到”。
|
||||||
|
- 待决策:真实 webhook 地址/密钥来源;是否另开 direct 后端项目。direct 不等价于自动复用 Hermes bot token 的授权。
|
||||||
|
|
||||||
|
报告列出改动与备份路径、测试原文、参数及脱敏测试名称、部署窗口、未覆盖项、真实通知状态和 skill 变更。对不满足的门槛明确标为未完成,不用“重构完成”掩盖投递尚未恢复。
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
# ASR 管线 Agent 解耦 — 重构实施文档
|
||||||
|
|
||||||
|
**版本**:v1(2026-09-18)
|
||||||
|
**上游 review**:`docs/agent-decoupling-review.md`
|
||||||
|
**执行者**:子 agent(Claude Code / Codex / 其他具备文件与 shell 能力的 agent)
|
||||||
|
**授权边界**:本文档 Stage 1–4 为已授权范围。超出范围的改动(尤其是 §8 禁止项)必须先回报 Kai 并取得确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 给执行 agent 的前置须知(先读完本节再动手)
|
||||||
|
|
||||||
|
### 0.1 这套系统正在生产运行,且无 git 保护
|
||||||
|
|
||||||
|
`/home/claw/claude-workspace` **不是 git 仓库**(已验证:`git rev-parse` 返回 `fatal: not a git repository`)。这意味着:
|
||||||
|
|
||||||
|
- **没有 `git checkout` 可以回滚**。任何改动前必须手动备份。
|
||||||
|
- 备份约定沿用本项目既有风格(目录中已有 7 个 `*.bak.*` 文件):
|
||||||
|
```bash
|
||||||
|
cp -a <file> <file>.bak.$(date +%Y%m%d-%H%M%S)
|
||||||
|
```
|
||||||
|
- 磁盘余量充足(43G available),备份成本可忽略。
|
||||||
|
|
||||||
|
同时注意:`~/obsidian-vault` **是**独立 git 仓库,管线会向它 commit+push。本次重构**不应触碰 vault 内容**,只改 `OBSIDIAN/` 下的基础设施代码与 systemd unit。
|
||||||
|
|
||||||
|
### 0.2 当前 baseline(改动前请先自行复现,作为对照)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/claw/claude-workspace/OBSIDIAN/asr-pipeline
|
||||||
|
PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
|
||||||
|
# 期望:Ran 28 tests ... OK
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:venv 里**没有 pytest**,必须用 `unittest`。这 28 个测试全部离线(`test_core.py` 在 import 前就把 `ASR_STATE_DIR`/`ASR_VAULT` 重定向到 tempdir),可以安全反复跑。
|
||||||
|
|
||||||
|
**任何 stage 结束时这 28 个测试必须仍然全绿**,且新增功能需补充测试(见各 stage 的验收标准)。
|
||||||
|
|
||||||
|
### 0.3 当前运行状态(改动前实测,供对照)
|
||||||
|
|
||||||
|
| 单元 | 状态 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `obsidian-recording-watch.service` | active | 音频监听 + 转录 + 桥接 |
|
||||||
|
| `obsidian-asr-refine.path` | active | 监听 `.asr-inbox/` |
|
||||||
|
| `asr-review-web.service` | active | Web 审核/上传 UI,`.145:8765` |
|
||||||
|
| `obsidian-asr-refine.service` | inactive | 旧 `claude -p` 链路,**保留供 rollback** |
|
||||||
|
|
||||||
|
`127.0.0.1:8644` 已确认无监听(`ss -tlnp | grep 8644` 为空)。`.48` ICMP 可达,但 8644 等端口从 `.145` 探测为 closed/filtered,且 **SSH 到 `.48` 被拒(publickey)**——执行 agent 无法自行登录 `.48` 查看其配置。**这是本次重构的关键约束,见 §1.3。**
|
||||||
|
|
||||||
|
### 0.4 上游 review 的一处补充(执行时必须一并修掉)
|
||||||
|
|
||||||
|
在准备本实施文档时复核代码,发现 review 未记录的一个**真实 bug**,与本次解耦同属一处代码,应合并修复:
|
||||||
|
|
||||||
|
`asrpipe/vault.py:135` 把第一个位置参数写死成字符串 `"草稿已生成,待确认"`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
r = subprocess.run(["python3", str(n), "草稿已生成,待确认", title, body], ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
而 `meeting-status-notify.py` 的 argv 契约是 `(status, filename, detail)`。于是所有 5 处 `vault.notify()` 调用的语义都被错位丢弃:
|
||||||
|
|
||||||
|
| 调用点 | 调用方本意(第 1 参数) | 实际发出的 status |
|
||||||
|
|---|---|---|
|
||||||
|
| `revise.py:273` | "ASR 正式记录已发布" | 草稿已生成,待确认 ❌ |
|
||||||
|
| `cli.py:109` | "ASR Web 审核启动失败" | 草稿已生成,待确认 ❌ |
|
||||||
|
| `cli.py:160` | "ASR Web 审核启动失败" | 草稿已生成,待确认 ❌ |
|
||||||
|
| `review_web.py:49` | "ASR 快速确认" | 草稿已生成,待确认 ❌ |
|
||||||
|
| `review_web.py:271` | "ASR 快速确认" | 草稿已生成,待确认 ❌ |
|
||||||
|
|
||||||
|
即:**"发布成功"和"启动失败"在 Telegram 上都显示为"草稿已生成,待确认"**。调用方传的 `title` 被挤到 `filename` 位、`body` 被挤到 `detail` 位。这个 bug 目前被 8644 不通掩盖着(消息根本没发出去),一旦按本文档恢复通知,它会立刻变成可见的错误告警。**Stage 2 必须连带修掉。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 重构目标与设计约束
|
||||||
|
|
||||||
|
### 1.1 目标
|
||||||
|
|
||||||
|
把"管线向人(Kai)发通知"这件事,从**"调用 Hermes 网关某个 IP:port 上的私有 webhook"**,改造成**"调用一个稳定的本地通知接口,Hermes 只是它的一种后端"**。
|
||||||
|
|
||||||
|
判据(重构完成后应当成立):
|
||||||
|
|
||||||
|
1. Hermes 再次换主机/换端口/换协议时,**只改一个配置文件,不改任何一行 Python、不改任何 systemd unit**。
|
||||||
|
2. 通知目标不可用时,管线行为与今天一致:**记日志、继续跑、不阻塞、不丢数据**。
|
||||||
|
3. 通知发送逻辑(HMAC 签名 + POST)在代码库里**只有一份实现**。
|
||||||
|
4. 管线**不再读取 `~/.hermes/` 下的任何文件**。
|
||||||
|
5. 上述 4 条对两个调用方(`meeting-watch.py` 和 `asrpipe`)同时成立。
|
||||||
|
|
||||||
|
### 1.2 设计风格必须与现有代码一致
|
||||||
|
|
||||||
|
这个项目有很清晰的既有风格,重构**必须沿用**而不是另立一套:
|
||||||
|
|
||||||
|
- 配置集中在 `asrpipe/config.py` 的 `Config` dataclass,每项都是 `_env("ASR_XXX", default)` 形式,env 可覆盖(见该文件已有的 `use_termbank` / `use_hindsight` / `gate_enabled` 等)。
|
||||||
|
- 失败降级优先于抛异常:通知失败只 `log(..., level="WARN")`,绝不中断管线(`vault.py:139` 已是此风格,`meeting-status-notify.py` 的 docstring 也明确写了 "Silent no-op (exit 0) ... never block the pipeline")。
|
||||||
|
- 注释说明"为什么"而非"是什么",且保留实测数据(参考 `config.py` 里 clean_model 的选型注释、`llm.py` 的 temperature 注释)。中文注释与英文注释在本项目中混用,**按所改文件的既有语言为准**。
|
||||||
|
- 凭证走 600 权限的独立 env 文件,不进代码、不进 git(`bin/.asr-refine.env` 是既有先例)。
|
||||||
|
|
||||||
|
### 1.3 关键约束:`.48` 的新地址当前未知
|
||||||
|
|
||||||
|
执行 agent **无法** SSH 到 `.48`,因此**无法自行确定 Hermes webhook 的新地址**。
|
||||||
|
|
||||||
|
这不阻塞重构——恰恰相反,这正是要做接口层的理由。处理方式:
|
||||||
|
|
||||||
|
- Stage 1–3 的全部工作**不需要知道 `.48` 的地址**,只需要把地址变成"一个配置文件里的一个字段"。
|
||||||
|
- 配置文件的初始值填 `.145` 的旧地址(即当前的 `http://127.0.0.1:8644/...`),并在文件里注释清楚"此地址在 2026-09-15 Hermes 迁移至 .48 后已失效,待 Kai 提供新地址后更新此处"。
|
||||||
|
- 重构完成后,**由 Kai 填入 `.48` 的真实地址**,或按 Stage 4 的 `direct` 后端绕过 Hermes。
|
||||||
|
- **禁止猜测端口后写进配置当作"已修复"**。宁可保留失效地址 + 明确注释,也不要写一个看似能跑实际错误的值。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 实施总览
|
||||||
|
|
||||||
|
| Stage | 内容 | 依赖 | 是否需要 Kai 输入 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **1** | 建立通知配置层 + 单一发送实现 | — | 否 |
|
||||||
|
| **2** | 两个调用方收敛到该实现 + 修 argv 错位 bug | 1 | 否 |
|
||||||
|
| **3** | 清理 systemd 硬编码 + 文档同步 | 2 | 否 |
|
||||||
|
| **4** | (可选)`direct` 后端,彻底不依赖 Hermes | 3 | 是(需 token 授权决定) |
|
||||||
|
|
||||||
|
Stage 1–3 应**一镜到底连续执行**,中间不要停下来问;每个 stage 结束跑一次测试即可。Stage 4 涉及凭证使用决策,**必须停下来问 Kai**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stage 1 — 建立通知配置层与单一发送实现
|
||||||
|
|
||||||
|
### 3.1 新增配置文件 `OBSIDIAN/bin/notify-target.json`
|
||||||
|
|
||||||
|
这是本次重构的**唯一真相源**。结构建议(执行时可按需微调字段名,但必须保持"后端可切换 + 目标可配置"两个语义):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backend": "webhook",
|
||||||
|
"_comment": "backend: webhook | none | direct(见 Stage 4)。切换后端不需要改代码。",
|
||||||
|
|
||||||
|
"webhook": {
|
||||||
|
"url": "http://127.0.0.1:8644/webhooks/meeting-recording-status",
|
||||||
|
"_url_comment": "⚠️ 此地址在 2026-09-15 Hermes 由 .145 迁移到 .48 后已失效。待 Kai 提供 .48 上的新地址后更新此字段。保留失效值而非猜测端口,是为了让故障显式可见。",
|
||||||
|
"secret": "",
|
||||||
|
"_secret_comment": "HMAC-SHA256 共享密钥。留空则不签名(对端若校验会拒绝)。历史上此值取自 ~/.hermes/webhook_subscriptions.json 的 meeting-recording-status.secret;现在改为本项目自持,不再读 Hermes 私有配置。",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
权限必须是 600(含密钥):
|
||||||
|
```bash
|
||||||
|
chmod 600 /home/claw/claude-workspace/OBSIDIAN/bin/notify-target.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**迁移密钥**:从 `~/.hermes/webhook_subscriptions.json` 的 `meeting-recording-status.secret` 复制一次到上面的 `secret` 字段。这是**一次性拷贝**,之后管线不再读 Hermes 的文件。(该文件当前结构已确认为 `{route: {secret, deliver, deliver_extra:{chat_id}, ...}}`。)
|
||||||
|
|
||||||
|
### 3.2 改造 `OBSIDIAN/bin/meeting-status-notify.py` 为唯一实现
|
||||||
|
|
||||||
|
保持它现有的**双重身份**——既是 CLI(供 shell 脚本调用),也要能被 Python import(供 `asrpipe` 直接调用,省掉一次 subprocess)。要求:
|
||||||
|
|
||||||
|
- 读 `notify-target.json` 决定后端与目标;**删除**对 `~/.hermes/webhook_subscriptions.json` 的读取(`SUBSCRIPTIONS` 常量与 `secret()` 函数)。
|
||||||
|
- 保留 env 覆盖能力,优先级 `env > notify-target.json > 内置兜底`。env 变量名沿用既有前缀风格(如 `MEETING_STATUS_WEBHOOK_URL` 已在 `meeting-watch.py` 中使用,继续复用同名,不要另起新名)。
|
||||||
|
- `backend: "none"` 时直接记日志返回,不发网络请求——这让"临时静音"不需要改代码,也让测试可以完全离线。
|
||||||
|
- 暴露一个可 import 的函数,签名保持与 CLI 契约一致:
|
||||||
|
```python
|
||||||
|
def send(status: str, filename: str = "", detail: str = "") -> bool
|
||||||
|
```
|
||||||
|
返回是否送达成功;**内部吞掉所有异常**,绝不向调用方抛(维持"never block the pipeline")。
|
||||||
|
- `__main__` 分支保持 `argv = (status, filename, detail)` 契约不变——`bin/obsidian-asr-refine.sh:45` 仍以此方式调用它(旧 rollback 链路),**不要破坏该契约**。
|
||||||
|
|
||||||
|
### 3.3 验收标准
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/claw/claude-workspace/OBSIDIAN
|
||||||
|
|
||||||
|
# 1. backend=none 时静默成功、不发网络请求
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, pathlib
|
||||||
|
p = pathlib.Path("bin/notify-target.json")
|
||||||
|
d = json.loads(p.read_text()); d["backend"] = "none"
|
||||||
|
p.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||||
|
EOF
|
||||||
|
python3 bin/meeting-status-notify.py "测试" "f.mp3" "detail" # 期望:退出码 0,日志说明 backend=none
|
||||||
|
|
||||||
|
# 2. backend=webhook 且目标不通时,仍然退出码 0(不阻塞)
|
||||||
|
# (把 backend 改回 webhook 后重跑,期望打印类似 "skipped: Connection refused",退出码仍为 0)
|
||||||
|
echo "exit=$?"
|
||||||
|
|
||||||
|
# 3. 确认不再引用 Hermes 私有配置
|
||||||
|
grep -rn "webhook_subscriptions\|\.hermes" bin/meeting-status-notify.py
|
||||||
|
# 期望:无输出(注释里提及历史来源可以,但不能有真实读取)
|
||||||
|
|
||||||
|
# 4. 旧链路 CLI 契约未破坏
|
||||||
|
bash -n bin/obsidian-asr-refine.sh && echo "refine.sh syntax ok"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stage 2 — 两个调用方收敛 + 修 argv 错位
|
||||||
|
|
||||||
|
### 4.1 `asrpipe` 侧
|
||||||
|
|
||||||
|
**`asrpipe/config.py`**:
|
||||||
|
|
||||||
|
- 新增 `notify_backend` 字段,风格与既有项一致:`notify_backend: str = _env("ASR_NOTIFY_BACKEND", "")`(空 = 交由 `notify-target.json` 决定;显式设置则覆盖)。
|
||||||
|
- 保留现有 `notify: bool`(`ASR_NOTIFY=0` 的总开关)——这是既有行为,`doctor` 和文档都依赖它,不要删。
|
||||||
|
- `notifier` property 保留(`cli.py:207` 的 doctor 检查在用),但其含义从"要 subprocess 调用的脚本"变为"通知实现模块的位置"。若改动了 doctor 的检查语义,需同步更新 `cli.py` 的 `chk("notifier", ...)`。
|
||||||
|
|
||||||
|
**`asrpipe/vault.py` 的 `notify()`**:
|
||||||
|
|
||||||
|
- **修掉 argv 错位 bug**:不再把 `"草稿已生成,待确认"` 写死为 status。调用方传入的第一个参数(语义上就是 status/事件名)必须原样传递。
|
||||||
|
- 建议直接 import `meeting-status-notify.py` 暴露的 `send()`,取代 `subprocess.run(["python3", ...])`——省一次进程启动,也让类型契约显式。若因文件名带连字符不便 import,可用 `importlib.util.spec_from_file_location`(路径来自 `CFG.notifier`),或把共享逻辑提取为 `OBSIDIAN/bin/notify_lib.py` 再由两边 import。**三种都可接受,选一种并在注释里说明理由。**
|
||||||
|
- 保留 `redact_log` 参数行为(`review_web.py:271` 传了 `redact_log=True`,因为 body 含一次性 capability URL,**绝不能进日志**)。这是安全相关行为,不要动。
|
||||||
|
- 保留"notifier 不存在时只记日志"的降级分支。
|
||||||
|
|
||||||
|
**5 处调用点**(`revise.py:273`、`cli.py:109`、`cli.py:160`、`review_web.py:49`、`review_web.py:271`):修好 `notify()` 后语义即自动正确,**调用点本身不需要改**。改完后请逐一确认它们发出的 status 与调用方本意一致(对照 §0.4 的表格)。
|
||||||
|
|
||||||
|
### 4.2 `meeting-watch.py` 侧
|
||||||
|
|
||||||
|
- 删除自建的 HMAC 签名与 POST 逻辑:`webhook_secret()`(L177)、`post_webhook()`(L187)、`post_status()`(L205)。
|
||||||
|
- `post_status` 改为薄封装,转调 Stage 1 的单一实现;3 个调用点(L272 "开始转录"、L286 "转录完成,正在生成粗整理"、L304 "处理失败")的调用形式**保持不变**。
|
||||||
|
- 删除 `WEBHOOK_URL` / `WEBHOOK_ROUTE` / `WEBHOOK_SECRET` / `STATUS_WEBHOOK_URL` / `STATUS_WEBHOOK_ROUTE` / `SUBSCRIPTIONS` 这些常量(L32–L37)。
|
||||||
|
|
||||||
|
**关于 `meeting-recording` 路由(携带正文那条)**:`post_webhook()` 在当前代码里**只被 `post_status()` 调用过**,即 `meeting-recording` 路由从未真正被 POST(已 grep 确认)。它是旧 Hermes agent 精修架构的残留,`CLAUDE.md` 明确记载该架构"已**取代**"。**删除这条死路径**,不要为它保留接口。(`~/.hermes/webhook_subscriptions.json` 里那条带 `prompt`/`skills: [memo-inbox]` 的 `meeting-recording` 订阅同属旧架构遗留,但那是 Hermes 侧的配置,**不在本次授权改动范围**,只在 §7 报告里提一句即可。)
|
||||||
|
|
||||||
|
另外注意 `meeting-recording-status` 订阅带 `deliver_only: True`——它只做消息投递、不唤起 agent。这印证了"状态通知本质上只是一条消息,不需要 agent 参与",也是 Stage 4 `direct` 后端在语义上完全等价的依据。
|
||||||
|
|
||||||
|
### 4.3 补充测试(必须新增)
|
||||||
|
|
||||||
|
在 `asr-pipeline/tests/test_core.py` 中新增(沿用该文件既有的 `unittest` + `mock` 风格,注意它已在 import 前把 state/vault 重定向到 tempdir):
|
||||||
|
|
||||||
|
1. **status 透传测试**(覆盖 §0.4 的 bug):mock 掉实际发送,断言 `vault.notify("ASR 正式记录已发布", "body")` 传出的 status 是 `"ASR 正式记录已发布"`,**不是** `"草稿已生成,待确认"`。这是防止该 bug 回归的关键测试。
|
||||||
|
2. **降级测试**:目标不可达(mock 抛 `URLError`)时 `vault.notify()` 不抛异常、正常返回。
|
||||||
|
3. **backend=none 测试**:不产生任何网络调用。
|
||||||
|
4. **`redact_log` 测试**:`redact_log=True` 时日志中不含 body 原文(防止 capability URL 泄漏进日志)。
|
||||||
|
|
||||||
|
### 4.4 验收标准
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/claw/claude-workspace/OBSIDIAN/asr-pipeline
|
||||||
|
PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
|
||||||
|
# 期望:28 + 新增用例,全绿
|
||||||
|
|
||||||
|
# 单一实现确认:HMAC 签名逻辑只应出现在一个文件里
|
||||||
|
cd /home/claw/claude-workspace/OBSIDIAN
|
||||||
|
grep -rn "hmac.new" --include="*.py" . | grep -v -E "__pycache__|\.venv|\.bak\."
|
||||||
|
# 期望:只有 1 处(notify 实现所在文件)
|
||||||
|
|
||||||
|
# 管线不再读 Hermes 私有配置
|
||||||
|
grep -rn "webhook_subscriptions" --include="*.py" --include="*.sh" . | grep -v -E "__pycache__|\.venv|\.bak\."
|
||||||
|
# 期望:无真实读取(注释提及历史来源可接受)
|
||||||
|
|
||||||
|
# doctor 仍通过(会联网测两个模型,属正常)
|
||||||
|
asr-pipeline/bin/asr-refine doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Stage 3 — systemd 与文档同步
|
||||||
|
|
||||||
|
### 5.1 systemd
|
||||||
|
|
||||||
|
`~/.config/systemd/user/obsidian-recording-watch.service` 删除这一行(第三处硬编码):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
Environment=MEETING_WEBHOOK_URL=http://127.0.0.1:8644/webhooks/meeting-recording
|
||||||
|
```
|
||||||
|
|
||||||
|
理由:该 URL 对应的 `meeting-recording` 路由已在 Stage 2 删除;状态通知目标现由 `notify-target.json` 统一提供,不应再由 unit 注入。
|
||||||
|
|
||||||
|
**保留** `MEETING_INBOX_BASE` 与 `MEETING_ASR_BACKEND=qwen3cam`——这两个是真实生效的部署配置,与本次解耦无关(后者还带着"ASR backend trial"的回滚注释,不要动)。
|
||||||
|
|
||||||
|
生效并验证:
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart obsidian-recording-watch.service
|
||||||
|
systemctl --user is-active obsidian-recording-watch.service # 期望 active
|
||||||
|
journalctl --user -u obsidian-recording-watch.service -n 30 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
`asr-review-web.service` 与 `asr-review-revise@.service` **不需要改**(它们通过 `asrpipe` 间接受益;后者的 `EnvironmentFile=.asr-refine.env` 只管模型凭证)。
|
||||||
|
|
||||||
|
### 5.2 文档
|
||||||
|
|
||||||
|
按"一份权威来源 + 其余引用"的结构收敛(与 vault 侧 `AGENTS.md` 作索引的模式一致):
|
||||||
|
|
||||||
|
| 文件 | 动作 |
|
||||||
|
|---|---|
|
||||||
|
| `asr-pipeline/docs/agent-protocol.md` | **新建**。本次解耦的权威文档:通知接口契约(status/filename/detail 三段语义)、`notify-target.json` 字段含义、后端切换方式、"Hermes 只是一种后端"的定位、任何 agent 想接入时该读什么。Kai 早在 2026-07-17 就规划过这份共用协议文档但从未落地——这次补上。 |
|
||||||
|
| `asr-pipeline/HANDOFF.md` | 第 71 行 "Telegram 通知(hermes-gateway :8644 签名 webhook)" 改为指向通知接口层,不再把 Hermes 端口写进设计文档;链接到 `agent-protocol.md`。 |
|
||||||
|
| `OBSIDIAN/CLAUDE.md` | 管线段落中的 `meeting-status-notify.py` 描述更新为"可切换后端的通知接口";补一行指向 `agent-protocol.md`。保持该文件"只留 Claude + 本服务器专属基础设施"的既有定位,不要把协议细节搬进来。 |
|
||||||
|
| `~/.claude/skills/memo-inbox/references/webhook-toolset-diagnostics.md` | **标注废弃**。它描述的"webhook agent 直接处理 `meeting-recording` 路由 + 需要 `hermes-webhook`/`file`/`skills` toolset"架构已于 2026-07-25 被取代,该路由在 Stage 2 已删除。建议在文件顶部加醒目废弃声明 + 指向 `agent-protocol.md`,而不是直接删除(保留历史排障脉络)。**注意:这是 skill 目录,改动会影响 Claude 的行为,改完在 §7 里明确告知 Kai。** |
|
||||||
|
| `~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md` | 五阶段表格等与 `HANDOFF.md` 逐字重复的内容改为链接引用;**保留** skill 独有的纪律性内容("第一轮只出粗整理,不直接写正式纪要"、S3 fidelity gate 存在的理由与实测数据、模型选型不得随意改动的警告)——这些是 Kai 的工作纪律,不是可去重的冗余。 |
|
||||||
|
|
||||||
|
### 5.3 验收标准
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 确认 8644 不再出现在任何活跃代码/配置里(文档中作为历史说明可保留)
|
||||||
|
cd /home/claw/claude-workspace/OBSIDIAN
|
||||||
|
grep -rn "8644" --include="*.py" --include="*.sh" . ~/.config/systemd/user/*.service 2>/dev/null \
|
||||||
|
| grep -v -E "__pycache__|\.venv|\.bak\."
|
||||||
|
# 期望:仅 notify-target.json 的待更新地址 + 注释
|
||||||
|
|
||||||
|
# 端到端(不触碰 vault、不 commit)
|
||||||
|
asr-pipeline/bin/asr-refine dry-run <任一 .asr-inbox/*.txt>
|
||||||
|
# 期望:五阶段跑通,产物落 asr-pipeline/dry-run/<slug>/,通知步骤降级但不报错
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stage 4 —(可选,需 Kai 决策)`direct` 后端
|
||||||
|
|
||||||
|
Stage 1–3 完成后,通知链路仍然经过 Hermes:管线 → Hermes 网关 webhook → Telegram。只要 Hermes 不在线,Kai 就收不到通知。
|
||||||
|
|
||||||
|
**可选的彻底解耦**:增加 `backend: "direct"`,直接调 Telegram Bot API(`https://api.telegram.org/bot<token>/sendMessage`),不经过 Hermes。
|
||||||
|
|
||||||
|
技术上已确认可行:
|
||||||
|
|
||||||
|
- `~/.hermes/.env` 中存在 `TELEGRAM_BOT_TOKEN`;`chat_id`(`1093241065`)在 `webhook_subscriptions.json` 的 `deliver_extra` 里已确认。
|
||||||
|
- `meeting-recording-status` 订阅标记为 `deliver_only: True`,即 Hermes 对这条消息**只做投递、不唤起 agent**。因此 `direct` 后端在语义上与现状**完全等价**,不损失任何 agent 能力。
|
||||||
|
|
||||||
|
**但这需要 Kai 决策,不要擅自实施**,因为:
|
||||||
|
|
||||||
|
1. 要把 Telegram bot token 复制进本项目自持的凭证文件(或新增一份 600 env 文件)。这扩大了 token 的存放面,是安全决策,须 Kai 同意。
|
||||||
|
2. 绕过 Hermes 后,Telegram 侧的消息格式/限流/重试不再由 Hermes 统一处理,需自行承担。
|
||||||
|
3. Kai 可能有意保留"所有对外消息都经 Hermes"的架构约定。
|
||||||
|
|
||||||
|
**执行方式**:Stage 3 完成后,在 §7 报告中列出此选项与上述权衡,**等 Kai 明确答复再动手**。届时若 Kai 同意,`direct` 只是 `notify-target.json` 里加一个后端分支 + 一个 sender 函数,改动很小——这正是 Stage 1 接口层带来的收益。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 完成后的回报要求
|
||||||
|
|
||||||
|
Stage 1–3 结束后,向 Kai 报告,包含:
|
||||||
|
|
||||||
|
1. **改了哪些文件**,每个文件一句话说明,附备份文件名。
|
||||||
|
2. **测试结果原文**(`Ran N tests ... OK`)。若有任何失败,**如实说明失败项与原因,不要含糊**。
|
||||||
|
3. **§0.4 的 argv 错位 bug 已修复的证明**:新增测试的名称与断言内容。
|
||||||
|
4. **仍然待办的事**,明确区分:
|
||||||
|
- 需要 Kai 提供 `.48` 上 Hermes webhook 新地址,填入 `notify-target.json` 的 `webhook.url`(在此之前通知仍不通,但已降级为"改一个配置字段")。
|
||||||
|
- Stage 4 `direct` 后端的决策请求(附 §6 的三点权衡)。
|
||||||
|
- Hermes 侧 `~/.hermes/webhook_subscriptions.json` 中 `meeting-recording` 那条旧订阅(带 `prompt`/`skills: [memo-inbox]`)属旧架构遗留、已无调用方,建议 Kai 自行清理——**本次未改动,因为不在授权范围**。
|
||||||
|
5. **动过 `~/.claude/skills/` 的明确告知**(若执行了 §5.2 的 skill 文档改动)——这会影响 Claude 后续行为,Kai 需要知道。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 明确禁止(越界前必须先问)
|
||||||
|
|
||||||
|
- ❌ **不要猜 `.48` 的端口后填进配置当作"已修复"**。宁可留失效地址 + 注释。SSH 到 `.48` 已确认被拒,无法自行核实。
|
||||||
|
- ❌ **不要往 `asrpipe/` 核心五阶段(`segment`/`flagger`/`refine`/`fidelity`/`revise`)里加任何 agent 相关代码**。`cli.py` 与 `config.py` 头部的 "no Claude Code, no opencode, no agent runtime" 声明是这套系统最值得保留的架构资产。
|
||||||
|
- ❌ **不要改模型选型**(`clean_model` / `draft_model`)。`config.py` 与 `HANDOFF.md` 都记录了实测依据(flash-lite 闸门失败 19/87 vs haiku 78/87,且 haiku 有 69 次过度压缩)。与本次解耦无关。
|
||||||
|
- ❌ **不要关闭或削弱 S3 fidelity gate**。它防的是静默信息丢失(无闸门时英文术语丢失达 22%、数字 11%)。
|
||||||
|
- ❌ **不要删除 rollback 链路**(`bin/obsidian-asr-refine.sh` + `obsidian-asr-refine.service`)。它们当前 inactive 但被 `obsidian-asr-refine.path` 的注释和 `HANDOFF.md` 记录为回滚路径。只需保证 `refine.sh:45` 调用 `meeting-status-notify.py` 的 CLI 契约不被破坏。
|
||||||
|
- ❌ **不要动 `~/obsidian-vault/` 的内容**,也不要在本次重构中产生 vault commit。本次只改 `OBSIDIAN/` 基础设施与 systemd unit。
|
||||||
|
- ❌ **不要清理那 7 个 `*.bak.*` 文件**。它们是 2026-07-26 重构的历史留存,清理属独立决策,需 Kai 单独批准。
|
||||||
|
- ❌ **不要引入新的第三方依赖**。`asr-pipeline/.venv` 目前只有 pypinyin / rapidfuzz / numpy,通知功能用标准库 `urllib.request` + `hmac` + `json` 足够(现有实现已证明)。
|
||||||
|
- ❌ **不要为解耦搭建通用消息总线/插件框架**。当前只有一个通知需求,`notify-target.json` 里一个 `backend` 字段 + 几个分支就够。过度工程化会让下一个维护者更难读。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 执行顺序速查
|
||||||
|
|
||||||
|
```
|
||||||
|
0. 读 §0 全部 → 跑 baseline 测试(28 OK)→ 备份将改动的文件
|
||||||
|
1. Stage 1:notify-target.json + meeting-status-notify.py 单一实现 → 验收 §3.3
|
||||||
|
2. Stage 2:config.py / vault.py(修 argv bug)/ meeting-watch.py + 4 个新测试 → 验收 §4.4
|
||||||
|
3. Stage 3:systemd 去硬编码 + agent-protocol.md + 4 份文档同步 → 验收 §5.3
|
||||||
|
4. 回报 Kai(§7),等 .48 地址与 Stage 4 决策
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 1–3 连续执行,不要中途停下来确认;Stage 4 必须等 Kai 答复。
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# ASR 管线 × Agent 耦合点 Review
|
||||||
|
|
||||||
|
**日期**:2026-09-18
|
||||||
|
**背景**:Hermes 网关从 .145 迁移到 .48 后,`meeting-status-notify.py` 因硬编码 `127.0.0.1:8644` 而 Telegram 通知失效(核心转录/整理流程本身不受影响)。这次故障暴露的不是孤立 bug,而是整条管线里 Claude/Hermes 两个 agent 以"硬编码地址/私有配置文件/直接 shell out"方式接入,缺乏稳定接口层。本文档目标:**列出所有耦合点,诊断问题,提出"预留接口 + 通用 skill"的解耦方案**,供下一轮重构使用。不在本文档内直接修改代码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 涉及文件清单
|
||||||
|
|
||||||
|
### 1.1 生产代码(按耦合程度分类)
|
||||||
|
|
||||||
|
| 文件 | 耦合对象 | 耦合方式 | 严重度 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `OBSIDIAN/bin/meeting-status-notify.py` | Hermes | 硬编码 `http://127.0.0.1:8644/webhooks/...`;直接读 `~/.hermes/webhook_subscriptions.json` 取 HMAC secret | 🔴 高(本次故障根源) |
|
||||||
|
| `OBSIDIAN/recordings/bin/meeting-watch.py` | Hermes | `post_webhook()`/`post_status()` **重新实现**了一遍同样的 HMAC 签名 + 硬编码 URL 逻辑(与上面文件重复代码,两处维护) | 🔴 高(重复实现同一故障点) |
|
||||||
|
| `OBSIDIAN/asr-pipeline/asrpipe/vault.py` (`notify()`) | Hermes(间接) | 不直接硬编码地址,但通过 `CFG.notifier` 固定 `subprocess.run(["python3", meeting-status-notify.py, ...])`,位置参数契约无 schema | 🟡 中(依赖上面两个文件之一才能工作) |
|
||||||
|
| `OBSIDIAN/asr-pipeline/asrpipe/config.py` (`review_public_host`) | 部署环境 | 默认值硬编码 `192.168.50.145`(本机 IP),可用 env 覆盖 | 🟢 低(有 env 覆盖,且 review-web 确实还在 .145,不是错误,只是"写死默认值"的一贯风格问题) |
|
||||||
|
| `OBSIDIAN/bin/obsidian-asr-refine.sh`(**已停用**,仅供 rollback) | Claude Code | 直接 shell out `$HOME/.local/bin/claude -p`,依赖交互登录态 | 🟡 中(非活跃路径,但 rollback 会重新引入该耦合) |
|
||||||
|
| `OBSIDIAN/bin/obsidian-asr-hotwords.py`、`OBSIDIAN/bin/obsidian-asr-intake.sh` | 无(命名遗留) | 调用 `~/bin/hermes-qwen3-asr-smart`——**这是个纯 STT wrapper shell 脚本,跟 Hermes agent 运行时完全无关**,只是历史命名带 "hermes" 前缀,容易被误读为依赖 agent | 🟢 低(认知负担,不是真耦合,建议重命名去混淆) |
|
||||||
|
| `~/.config/systemd/user/obsidian-recording-watch.service` | Hermes | `Environment=MEETING_WEBHOOK_URL=http://127.0.0.1:8644/...` 在 systemd 层再硬编码一次 | 🔴 高(第三处需要同步修改的地方) |
|
||||||
|
| `asr-pipeline/asrpipe/llm.py` | Claude(仅模型选择) | 字符串形式引用 `anthropic/claude-sonnet-5` 等模型名做定价/no-temperature 判断 | 🟢 低(纯 API 层配置,非 agent 运行时耦合,正常) |
|
||||||
|
| `asr-pipeline/asrpipe/cli.py`、`config.py` 文件头注释 | 无 | 明确声明"no Claude Code, no opencode, no agent runtime"——这是**架构上已经做对的部分**,核心管线是纯 HTTPS+文件驱动,值得保留和强化 | 🟢 无耦合(正面案例) |
|
||||||
|
|
||||||
|
### 1.2 文档/Skill 层(描述与现状不一致,需要一并清理)
|
||||||
|
|
||||||
|
| 文件 | 问题 |
|
||||||
|
|---|---|
|
||||||
|
| `~/.claude/skills/memo-inbox/references/webhook-toolset-diagnostics.md` | 描述的是**已被取代的旧架构**:假设有一个"webhook agent"(Hermes)直接处理 `meeting-recording` 路由、需要 `hermes-webhook`/`file`/`skills` toolset、把转录内容发给 Hermes agent 做 memo-inbox 整理。但 `CLAUDE.md` 明确写着"已**取代**旧的 Hermes→Telegram agent 精修",且 `meeting-watch.py` 里 `post_webhook()`(对应 `meeting-recording` 路由,携带正文)从未被实际调用,只有 `post_status()`(状态 ping)在用。**这份 skill 文档描述的故障排查对象已经不存在,但仍在 memo-inbox skill 里被索引,可能误导下一次排障。** |
|
||||||
|
| `~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md` | 与 `CLAUDE.md`、`HANDOFF.md` 三份文档描述同一条管线,各有侧重但存在信息重复(例如五阶段说明在两份文档里几乎逐字重复)。不是错误,但增加了"改一处忘改另一处"的维护成本——这次 8644 端口就是同一类问题在配置层面的重演。 |
|
||||||
|
| `OBSIDIAN/asr-pipeline/HANDOFF.md` | 明确写了"Telegram 通知(hermes-gateway :8644 签名 webhook)",是**当前唯一还把 Hermes 网关端口写进设计文档**的地方,本身没错但需要随实现一起更新。 |
|
||||||
|
| 目前**不存在** `agent-protocol.md`(据 hindsight 记忆,2026-07-17 曾规划把 obsidian skill 拆分出一份 Hermes/Codex/Claude Code 共用的 `agent-protocol.md`,统一七步协议) | 说明"通用协议文档"这个方向此前已经被认可过,但从未真正落地到 ASR 通知这一层,这次故障正好是没有该抽象层的直接后果。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 问题诊断
|
||||||
|
|
||||||
|
### 2.1 核心问题:通知通道是唯一的"横切关注点",却用点对点硬编码实现
|
||||||
|
|
||||||
|
真正跨 Claude/Hermes 边界的交互只有一件事:**管线要把"状态变化"告诉人(Kai),而人在 Telegram 上**。当前实现把这件事等同于"调用 Hermes 网关在某个 IP:port 上开的私有 webhook",导致:
|
||||||
|
|
||||||
|
- 通知目标地址在**三个物理位置**各写一份(`meeting-status-notify.py`、`meeting-watch.py`、systemd unit 的 `Environment=`),Hermes 一换主机全部失效,还都要单独改。
|
||||||
|
- 认证方式依赖读取 **Hermes 自己的私有配置文件** `~/.hermes/webhook_subscriptions.json`,管线对 Hermes 的内部文件结构做了假设——Hermes 的实现细节变了,管线也会跟着坏,而管线本身的 git 仓库里完全看不出这个依赖关系。
|
||||||
|
- `meeting-watch.py` 里的 `post_webhook`/`post_status` 和 `meeting-status-notify.py` 是**同一逻辑的两份实现**(都做 HMAC 签名 + POST),维护者容易改一处忘改另一处。
|
||||||
|
|
||||||
|
### 2.2 次要问题:文档层的"多真相源"
|
||||||
|
|
||||||
|
`CLAUDE.md`(基础设施说明)、`HANDOFF.md`(设计交接)、`asr-refine-pipeline.md`(skill 引用)三份文档对同一条管线各写一遍架构图和五阶段表格,`webhook-toolset-diagnostics.md` 甚至在描述一个已经退役的架构分支。这不是本次故障的直接原因,但预示着**如果解耦时只改代码不改文档索引,同样的"多处不同步"问题会在文档层重演**。
|
||||||
|
|
||||||
|
### 2.3 正面案例,值得保留的设计
|
||||||
|
|
||||||
|
- `asrpipe/` 核心包(S0-S4 五阶段 + fidelity gate + termbank)本身**已经是纯 HTTPS + 文件驱动**,不依赖任何 agent CLI 或登录态,`cli.py`/`config.py` 头部注释明确声明了这个边界——这是解耦该学的样子。
|
||||||
|
- `asr-review-web`/`control_web.py` 是一个独立 HTTP 服务,路由/状态机/上传都通过标准 HTTP 暴露,没有假设调用方是谁——**这个"HTTP 接口面"是天然可以承载 Claude/Hermes 通用交互的地方**,不需要另起炉灶。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 解耦方案建议(预留接口 + 通用 skill)
|
||||||
|
|
||||||
|
### 3.1 接口层:把"通知"做成可插拔的抽象,而不是"调用 Hermes"
|
||||||
|
|
||||||
|
建议参考 `config.py` 里其余配置项的风格(`use_termbank`/`use_hindsight` 等 boolean 开关 + env 覆盖),把通知也做成同一等级的可插拔能力:
|
||||||
|
|
||||||
|
- 在 `asrpipe/config.py` 增加 `notify_backend`(如 `webhook` / `none` / 未来的 `stdout`/`file` 等),而不是隐式认定"notifier = 某个写死路径的 python 脚本"。
|
||||||
|
- `meeting-status-notify.py` 改造成**唯一**的通知发送实现(`meeting-watch.py` 不再自己重新实现 HMAC 签名,改为 shell out 到同一份脚本,或提取成共享库函数),并且:
|
||||||
|
- 目标地址、密钥来源全部走 env / 独立配置文件(例如 `OBSIDIAN/bin/notify-target.json`),**不直接读 `~/.hermes/*`**——管线自己的配置与 Hermes 的私有配置解耦,Hermes 换主机/换协议时只改这一份 target 配置,不改代码。
|
||||||
|
- 把"目标是 Hermes 网关的 webhook"这件事,退化为该抽象接口的**一种实现**,而不是唯一实现——未来即使 Hermes 换成完全不同的通知方式(比如直接调 Telegram Bot API),也只是换一个 backend,管线代码不用动。
|
||||||
|
|
||||||
|
### 3.2 协议层:让 Claude 和 Hermes 通过同一份"通用 skill/协议文档"交互,而不是各自看各的私有细节
|
||||||
|
|
||||||
|
现状是 Hermes 一侧靠私有 webhook 配置、Claude 一侧靠读 `CLAUDE.md`/`HANDOFF.md`,两边对同一条管线的认知来自不同文档,容易出现你这次遇到的情况——迁移动作只在 Hermes 侧发生,ASR 侧的假设没人同步更新。
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
1. **落地此前已经规划过的 `agent-protocol.md`**(hindsight 记忆里 2026-07-17 就提出过这个方向,但目前尚未创建)。把"任何 agent(Hermes/Claude Code/Codex)如何得知 ASR 管线的状态变化、如何触发人工确认、通知目标怎么配置"写成一份三方共用的协议文档,而不是分散在 `CLAUDE.md` + `HANDOFF.md` + memo-inbox skill 引用里各写一份。
|
||||||
|
2. 把 `control_web.py` 已有的 HTTP 接口面正式定义为**唯一**对外交互入口:任何 agent 需要查询任务状态/触发处理,都走这个 HTTP API,而不是直接读 sqlite(`intake.db`)或直接改文件。目前 `meeting-watch.py` 直接读写 `CONTROL_DB`(sqlite),这本身也是一种"绕过接口层直连内部状态"的耦合,建议后续统一收敛到 `control_web.py` 的 API 之后。
|
||||||
|
3. 清理 `webhook-toolset-diagnostics.md`:要么标注为"历史架构,已废弃于 2026-07-25",要么直接删除/归档,避免下次排障时被过时描述误导。
|
||||||
|
4. `CLAUDE.md` / `HANDOFF.md` / `asr-refine-pipeline.md` 三份重叠内容建议收敛为"一份权威来源 + 其余引用链接"的结构(类似 vault 侧 `AGENTS.md` 作为 SOP 索引、具体细则各自一份的模式),而不是三份都写一遍五阶段表格。
|
||||||
|
|
||||||
|
### 3.3 命名去混淆
|
||||||
|
|
||||||
|
`~/bin/hermes-qwen3-asr-smart` 建议评估是否重命名(例如 `qwen3-asr-transcribe-wrapper`)——它不依赖 Hermes agent 运行时,纯粹是历史命名,留着容易让人(或下一轮重构的 LLM)误判为真实耦合点而做无用功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 优先级建议
|
||||||
|
|
||||||
|
| 优先级 | 事项 | 原因 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 | 通知 backend 抽象化 + 三处硬编码收敛为一处配置 | 直接对应本次故障,且是唯一真正跨 agent 边界的运行时依赖 |
|
||||||
|
| P1 | 落地 `agent-protocol.md`,清理 `webhook-toolset-diagnostics.md` 等过时文档 | 防止"文档多真相源"重演同类故障 |
|
||||||
|
| P2 | `meeting-watch.py` 收敛直连 sqlite 为走 `control_web.py` API | 减少内部状态的多点直连,但当前未造成故障,可延后 |
|
||||||
|
| P3 | `hermes-qwen3-asr-smart` 重命名 | 纯认知成本,无功能风险,可随手做 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 明确不建议做的事
|
||||||
|
|
||||||
|
- 不建议把核心 `asrpipe/` 五阶段管线本身引入任何 agent 依赖——它当前"纯 HTTPS + 文件驱动"的设计是对的,解耦应该聚焦在通知/协议这一层,不要往核心管线里加 Claude/Hermes 相关代码。
|
||||||
|
- 不建议为了解耦而过度设计一套通用消息总线/插件系统——当前只有"一个通知需求 + 一个 HTTP 状态查询需求",用简单的 backend 开关 + 现有 HTTP 接口面就够,避免过度工程化。
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# ASR Pipeline v2
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The pipeline recovers useful meeting facts and traces rather than pretending that
|
||||||
|
speech can always be reconstructed word for word. The original transcript remains
|
||||||
|
the audit source. A draft may omit low-value repetition, but must not invent a
|
||||||
|
subject, number, decision, or relationship.
|
||||||
|
|
||||||
|
## Stages
|
||||||
|
|
||||||
|
1. S0 parses speaker turns and collapses decoder loops.
|
||||||
|
2. S1 generates candidates from the untouched transcript. Phonetics, observed ASR
|
||||||
|
errors, aliases, entity relationships, and recency are independent signals.
|
||||||
|
3. S2 only removes fillers and repairs punctuation. It receives no Hindsight context
|
||||||
|
and makes no entity decisions.
|
||||||
|
4. S3 applies settled mappings deterministically. Flash Lite sees only unresolved
|
||||||
|
candidates and Chinese-number spans.
|
||||||
|
5. S4 extracts source-labelled evidence cards per chunk.
|
||||||
|
6. S5 Sonnet groups those cards by topic and writes a rough draft.
|
||||||
|
|
||||||
|
## Personal fact store
|
||||||
|
|
||||||
|
`state/lexicon.db` is intentionally reusable outside ASR:
|
||||||
|
|
||||||
|
- `entity`, `alias`, `correction`: identity and observed surface forms.
|
||||||
|
- `relation`: person/company/project relations with source, confidence, and dates.
|
||||||
|
- `interaction`: contact recency and frequency.
|
||||||
|
- `observation`: what a transcript emitted and how it was resolved.
|
||||||
|
- `phonetic_confusion`: confirmed personal accent/ASR confusion pairs.
|
||||||
|
- `review_question`: transport-independent human review queue.
|
||||||
|
- `source_document`: incremental indexing state for historical minutes.
|
||||||
|
|
||||||
|
Obsidian remains a curated source. Hindsight supplies broad recall. SQLite holds
|
||||||
|
small deterministic facts that applications can safely query.
|
||||||
|
|
||||||
|
## Human review loop
|
||||||
|
|
||||||
|
The pipeline never blocks waiting for an answer. Ambiguous high-impact entity
|
||||||
|
candidates are queued and the draft keeps an uncertainty marker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/asr-refine review pending
|
||||||
|
bin/asr-refine review answer 12 万亚坤
|
||||||
|
```
|
||||||
|
|
||||||
|
A confirmed candidate is learned as an alias, correction, and observation. The
|
||||||
|
candidate-pool cache includes the database timestamp, so the next recording uses it
|
||||||
|
immediately.
|
||||||
|
|
||||||
|
Pi, Telegram, and the web UI are thin transports over this queue:
|
||||||
|
|
||||||
|
1. Read pending rows and display the question, source sentence, and choices.
|
||||||
|
2. Submit `question_id + answer`.
|
||||||
|
3. Call the same `review.answer()` function or CLI command.
|
||||||
|
4. Optionally request a replay of S3-S5 for the affected recording.
|
||||||
|
|
||||||
|
The production web transport is a persistent mobile control plane:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/asr-refine review serve # normally started by systemd
|
||||||
|
bin/asr-refine review state # print the active one-time URL
|
||||||
|
```
|
||||||
|
|
||||||
|
- Every generated draft is a Web delivery, even when entity candidate detection
|
||||||
|
finds no ambiguity. Review order is: inferred call background (with a corrected
|
||||||
|
background field that requests a redraft), unnamed voiceprints, the draft's
|
||||||
|
`## 待你确认` list, then one overall review item.
|
||||||
|
- It listens on `192.168.50.145:8765`. `/` accepts streamed audio uploads,
|
||||||
|
shows recent job state, and atomically hands completed files to the existing
|
||||||
|
recording watcher. `/hook/intake` exposes the same raw-upload contract for a
|
||||||
|
future reverse proxy or external uploader.
|
||||||
|
- Telegram receives a 256-bit capability URL. The token exists only in Telegram and
|
||||||
|
the mode-0600 runtime state file; it is redacted from service/request logs.
|
||||||
|
- The page exposes only one queued question at a time: candidate buttons, an
|
||||||
|
explicit "无法确认", a short custom answer, and an expandable full-draft view.
|
||||||
|
- Rough drafts live under `state/drafts/`, outside the Vault and Git. After all
|
||||||
|
questions are answered, an auditable feedback block is saved privately.
|
||||||
|
`asr-review-revise@.service` applies those answers with Sonnet, validates the
|
||||||
|
formal schema/Summary/provenance, then writes and pushes exactly one formal note
|
||||||
|
under `minutes/`. The private reviewed source moves to `state/published/`.
|
||||||
|
- A confirmed unnamed `speaker_N` is written to `voiceprint_speakers`, marked for
|
||||||
|
retry by the daily enrollment job, and immediately submitted to the persistent
|
||||||
|
`:10099/voiceprints/enroll` API.
|
||||||
|
- SQLite records one `entity_mention` per entity per formal minute. When the current
|
||||||
|
minute would bring an uncarded person/company to two distinct minutes, Web offers
|
||||||
|
a conservative card scaffold. No card or judgment field is created without an
|
||||||
|
explicit human choice.
|
||||||
|
- Upload metadata (title and optional user-supplied background) is kept separate
|
||||||
|
from the acoustic transcript. The title survives into the task slug; the
|
||||||
|
background is explicitly labelled as user-supplied context for drafting.
|
||||||
|
- `asr-review-web.service` is enabled at boot and restarts on failure. A successful
|
||||||
|
private draft ensures it is running and sends its stable review URL through
|
||||||
|
Telegram. `dry-run` never queues questions.
|
||||||
|
|
||||||
|
The service unit lives at `deploy/asr-review-web.service`. Telegram may later gain
|
||||||
|
inline choice buttons, but neither transport owns facts or writes SQLite directly.
|
||||||
|
|
||||||
|
## Confidence policy
|
||||||
|
|
||||||
|
- Confirmed: voiceprint, explicit human mapping, or multiple independent strong
|
||||||
|
signals.
|
||||||
|
- Probable: context and relation graph agree, but identity is inferred.
|
||||||
|
- Pending: weak or conflicting evidence, including numbers whose type/unit is not
|
||||||
|
clear.
|
||||||
|
|
||||||
|
Only confirmed facts become deterministic aliases automatically.
|
||||||
|
|
||||||
|
## Graph and recency
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bin/asr-refine graph sync
|
||||||
|
bin/asr-refine graph stats
|
||||||
|
```
|
||||||
|
|
||||||
|
The graph incrementally scans finalized minutes, imports person/company/project
|
||||||
|
frontmatter and known wikilinks, records meeting interactions, creates low-confidence
|
||||||
|
co-occurrence edges, and promotes explicit linked relations such as 投资/负责/合作 at a
|
||||||
|
higher confidence. Deleted or changed notes remove only facts owned by that source.
|
||||||
|
|
||||||
|
Candidate context weight combines relation confidence, repeated support, last contact,
|
||||||
|
and a 45-day half-life. It influences ranking but cannot activate the personal
|
||||||
|
phonetic-confusion channel outside entities already in the current graph context.
|
||||||
|
|
||||||
|
## Numbers
|
||||||
|
|
||||||
|
The deterministic number pass classifies percentage, date, money, quantity, and
|
||||||
|
project/code speech. Every decision records raw value, normalized value, type,
|
||||||
|
confidence, source span, and nearby context. Ambiguous forms such as `两万五九份`
|
||||||
|
remain unchanged and are passed to the uncertainty stage instead of being guessed.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 录音上传与审核工作台
|
||||||
|
|
||||||
|
## 入口
|
||||||
|
|
||||||
|
- Web:`http://192.168.50.145:8765/`
|
||||||
|
- 健康检查:`GET /health`
|
||||||
|
- 最近任务:`GET /api/jobs`
|
||||||
|
- 人工确认:`GET /review/`
|
||||||
|
|
||||||
|
服务只面向 LAN 与 tailnet,没有实现公网身份认证。未来暴露到公网时,应在反向代理层
|
||||||
|
增加认证、上传大小限制和请求速率限制,不要直接转发当前端口。
|
||||||
|
|
||||||
|
## 工作流
|
||||||
|
|
||||||
|
1. 浏览器把录音以原始二进制流上传到隐藏临时目录。
|
||||||
|
2. 文件完整写入并 `fsync` 后,原子移动到 `recordings/inbox/`。
|
||||||
|
3. 既有 `obsidian-recording-watch.service` 接管转写、CAM++ 分角色和声纹识别。
|
||||||
|
4. 转写落入 `.asr-inbox/`,独立整理程序生成私有草稿。
|
||||||
|
5. Telegram 发送确认链接;Web 回答写入 SQLite。
|
||||||
|
6. 全部确认后生成正式纪要并写入 Obsidian。
|
||||||
|
|
||||||
|
上传时填写的背景单独保存在任务元数据中,进入整理提示时会标为“用户提供的背景”,
|
||||||
|
不会混入或伪装成 ASR 转写内容。
|
||||||
|
|
||||||
|
## Hook
|
||||||
|
|
||||||
|
`POST /hook/intake` 与 Web 上传使用相同的流式协议,便于以后在外网部署一个很薄的上传
|
||||||
|
前端或反向代理:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail-with-body \
|
||||||
|
-X POST \
|
||||||
|
-H 'Content-Type: application/octet-stream' \
|
||||||
|
-H 'X-Filename: call.m4a' \
|
||||||
|
-H 'X-Title: 项目通话' \
|
||||||
|
-H 'X-Context: 参与人、项目和希望重点确认的事项' \
|
||||||
|
--data-binary @call.m4a \
|
||||||
|
http://192.168.50.145:8765/hook/intake
|
||||||
|
```
|
||||||
|
|
||||||
|
响应中的 `job_id` 是任务标识。状态可从 `/api/jobs` 查询。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status asr-review-web.service
|
||||||
|
systemctl --user restart asr-review-web.service
|
||||||
|
journalctl --user -u asr-review-web.service -n 100 --no-pager
|
||||||
|
curl -fsS http://127.0.0.1:8765/health
|
||||||
|
```
|
||||||
|
|
||||||
|
持久数据:
|
||||||
|
|
||||||
|
- `asr-pipeline/state/intake.db`:上传任务与状态。
|
||||||
|
- `recordings/state/uploads/`:上传标题、背景和任务关联。
|
||||||
|
- `recordings/inbox/.uploading/`:未完成上传;正常完成后为空。
|
||||||
|
|
||||||
|
默认单文件上限为 4 GiB,可通过 `ASR_UPLOAD_MAX_BYTES` 调整。
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 当前流程与独立项目交接
|
||||||
|
|
||||||
|
核对日期:2026-11-02,Asia/Shanghai。
|
||||||
|
|
||||||
|
## 已验证
|
||||||
|
|
||||||
|
原代码:`/home/claw/claude-workspace/OBSIDIAN/asr-pipeline`。
|
||||||
|
原音频编排:`/home/claw/claude-workspace/OBSIDIAN/recordings/bin`。
|
||||||
|
原通知脚本:`/home/claw/claude-workspace/OBSIDIAN/bin/meeting-status-notify.py`。
|
||||||
|
|
||||||
|
本机 systemd:
|
||||||
|
|
||||||
|
| 单元 | 本次状态 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| asr-review-web.service | running | 8765 常驻上传、最近任务、审核界面 |
|
||||||
|
| obsidian-recording-watch.service | running | 监听录音并请求外部 ASR |
|
||||||
|
| obsidian-asr-refine.path | waiting | 监听 vault/.asr-inbox |
|
||||||
|
| obsidian-asr-refine-native.service | inactive | 按需整理转写,当前未运行 |
|
||||||
|
| asr-review-revise@.service | 模板 | 人工确认后生成/发布 |
|
||||||
|
|
||||||
|
`GET http://127.0.0.1:8765/health` 返回 `ok=true, pending=3`。
|
||||||
|
这说明审核队列存在待确认项,不证明三个录音都在推理,也不证明通知正常。
|
||||||
|
本轮不导出待确认正文,不生成新稿、不注册声纹、不写 vault。
|
||||||
|
|
||||||
|
## 代码确认的链路
|
||||||
|
|
||||||
|
- 监听单元显式选择 `MEETING_ASR_BACKEND=qwen3cam`。
|
||||||
|
- 对应脚本默认请求 `http://192.168.50.100:10099/asr`,整文件上传,
|
||||||
|
`language=Chinese`、`save_voiceprint_session=true`;服务内部切片。
|
||||||
|
本轮未对 GPU 服务发起真实录音测试,不据此宣称其健康或当前镜像版本。
|
||||||
|
- 原始转写保留声纹 session 与 speaker;归档后桥接到 vault/.asr-inbox。
|
||||||
|
- 独立 Python 管线包含模糊音/别名、关系与近期权重、数字归一、纯清洗与纠错分阶段。
|
||||||
|
- 初稿和中间状态在程序私有目录,正式纪要等待 Web 审核通过。
|
||||||
|
- 人物/公司卡提议与声纹确认存在代码路径;不等于当前外部注册服务验证通过。
|
||||||
|
|
||||||
|
## 运行数据边界
|
||||||
|
|
||||||
|
- `asr-pipeline/state/lexicon.db`:人工确认、关系、纠错、审核队列,不能重建后替代。
|
||||||
|
- `asr-pipeline/state/intake.db`:上传任务与状态。
|
||||||
|
- `state/drafts/`、`state/published/`:私有稿与发布回执。
|
||||||
|
- `recordings/{inbox,processing,processed,failed,transcripts,state}`:音频及编排状态。
|
||||||
|
- `vault/.asr-inbox/.state`:整理处理标记,切换漏迁可能造成重复处理。
|
||||||
|
- `.asr-refine.env`:私有凭据,未读取值、未复制、未入 Git。
|
||||||
|
|
||||||
|
本次只提取源代码、专业词库、现有测试、文档、unit 快照。没有复制生产音频、
|
||||||
|
个人 SQLite、缓存、会话、凭据或 `.venv`。历史设计文档有过时路径和模型,保留作来源。
|
||||||
|
|
||||||
|
## 已发现的风险(尚未改生产)
|
||||||
|
|
||||||
|
1. 通知脚本默认 `127.0.0.1:8644` 和 `~/.hermes/webhook_subscriptions.json`。
|
||||||
|
结合 Hermes 已迁走的资产记录,这是通知失效风险;本次未验证远端 webhook。
|
||||||
|
2. 本仓库代码的模型缺省已是 LiteLLM(清洗 `aistudio/gemini-flash-lite-latest`,成稿 `codex/gpt-sol-latest`),不是 ZenMux,也不是 Sonnet 5。生产 unit 若仍加载旧 `.asr-refine.env`,以那个文件为准;本次未读取凭据,不把代码缺省当成现网正在用的路由。
|
||||||
|
3. 路径耦合旧工作目录;本项目配置根目录、通知器、监听数据库已可外置,
|
||||||
|
但声纹地址、审核 systemd 单元名等仍有历史耦合。`deploy/systemd/` 保持现网快照,没有改 ExecStart。
|
||||||
|
4. 历史 HANDOFF 中写的是一次性页面。代码里有两个常驻界面:旧 `asrpipe.control_web`(快照单元,默认 8765)和新 `asrservice.web`(compose,默认 8788)。旧文档不作现态。
|
||||||
|
5. 产物删除和在线编辑会拒绝允许根以外的路径。发布到 `minutes/`、`People/`、`Companies/` 仍没有对照 vault SOP 的不可绕过白名单。本轮不冒充已审计完成。
|
||||||
|
|
||||||
|
## 代码已修复、生产未切换
|
||||||
|
|
||||||
|
下面这些是仓库里的新行为。现网单元没有改,不能当成已经在 8765/8788 的生产进程上生效。
|
||||||
|
|
||||||
|
- 未设置 `ASR_WEB_PASSWORD` 时,两个浏览器界面都拒绝上传、审核和改配置。临时放开只认 `ASR_WEB_ALLOW_ANON=1`。
|
||||||
|
- 登录后的写操作校验 CSRF。设置页不回显内网地址和密钥片段。
|
||||||
|
- `asrservice.worker` 与 `recordings/bin/meeting-watch.py` 抢同一把 `consumer.lock`。
|
||||||
|
- `ASR_GIT_SYNC`、`ASR_NOTIFY` 缺省为关。
|
||||||
|
- 修订失败会保留队列标记并重试;重新精修失败不会先删掉已回答的审核记录。
|
||||||
|
- 时间在界面上按 Asia/Shanghai 显示。词库列表按页读取,不再一次只截断前 180 条却不说明总数。
|
||||||
|
|
||||||
|
## 后续安全切换
|
||||||
|
|
||||||
|
1. 先确定模型路由与通知适配器,检查 vault SOP 与正式输出路径。
|
||||||
|
2. 建独立 venv、冻结依赖,使用合成数据跑回归,再选真实录音单测。
|
||||||
|
3. 停接新上传,确认没有转写/整理/审核发布在运行,再停相关 user units。
|
||||||
|
4. 对 SQLite 使用 backup API 生成一致性备份,保留原件;迁移录音状态与 inbox 标记,
|
||||||
|
不能对运行中数据库只复制主文件。
|
||||||
|
5. 路径变更还涉及数据库内草稿/转写绝对路径;要审计和映射,不能仅改 unit。
|
||||||
|
6. 生成新 unit 并逐个启用,确认只存在一个消费者;保留旧代码和备份以便回滚。
|
||||||
|
7. 验证上传 → 转写 → 确认链接 → 审核 → 正式纪要 → Git 同步完整链路后再移除旧目录。
|
||||||
|
|
||||||
|
本轮未执行上述生产切换,不承诺已独立上线。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 独立 WebUI 项目路线
|
||||||
|
|
||||||
|
## 定位
|
||||||
|
|
||||||
|
独立的会议、电话录音处理应用。WebUI 是主要操作界面,Telegram 只做可选通知,
|
||||||
|
不依附任何 Agent CLI。当前源码已抽出,但仍是旧实现基线,尚未独立部署上线。
|
||||||
|
|
||||||
|
## 目标架构
|
||||||
|
|
||||||
|
浏览器 → 应用 API → 持久化任务队列 → 后台 Worker → 外部 ASR / LLM / Hindsight。
|
||||||
|
SQLite 保存任务、处理阶段、审核、实体词库和修改历史;原音频、中间产物单独保存。
|
||||||
|
Obsidian 是可选发布目标,不是应用内部的任务队列或运行数据库。
|
||||||
|
|
||||||
|
ASR GPU 服务继续位于 unRaid,模型调用优先接入统一 LiteLLM。
|
||||||
|
应用状态、配置和存储必须通过环境配置,不绑定特定机器或个人工作区。
|
||||||
|
|
||||||
|
## 核心页面
|
||||||
|
|
||||||
|
1. 任务列表:上传、背景输入、进度、失败原因、重试、最近任务。
|
||||||
|
2. 任务详情:原音频播放、带说话人与时间轴的原文、处理版本、来源。
|
||||||
|
3. 审核:背景确认、候选词、说话人身份、关键歧义、最终纪要确认。
|
||||||
|
4. 词库:实体、别名、模糊音规则、关系和人工确认历史。
|
||||||
|
5. 设置:ASR、LLM、Hindsight、通知、输出路径和数据保留策略。
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
### 第一阶段:独立运行闭环
|
||||||
|
|
||||||
|
- 配置与运行数据分离;迁移个人词库、审核状态时使用一致性备份。
|
||||||
|
- 将 systemd 路径监听/模板任务改为应用内部持久化队列与 Worker。
|
||||||
|
- 实现任务互斥、幂等、重启恢复、超时和阶段重试,不重跑已确认步骤。
|
||||||
|
- 修复独立通知适配器,停止依赖本机 Hermes 状态文件。
|
||||||
|
- 对照现有 vault SOP,审计输出路径;只有确认后的正式稿可发布。
|
||||||
|
- 上传、转写、审核、发布全链路通过后,再切换生产消费者。
|
||||||
|
|
||||||
|
### 第二阶段:WebUI 完整化
|
||||||
|
|
||||||
|
- 保留现有纯 Python 页面作过渡,先明确 API 和状态模型,再选前端框架。
|
||||||
|
- 增加播放器、原文对照、编辑、版本回退与清晰的阶段状态。
|
||||||
|
- 把说话人确认、词库维护及上下文确认放入一致的任务工作流。
|
||||||
|
|
||||||
|
### 第三阶段:Docker 发布
|
||||||
|
|
||||||
|
- API/WebUI 和 Worker 可分别运行;持久卷承载 SQLite 与音频。
|
||||||
|
- 不在容器内运行 systemd;不依赖宿主 Agent home。
|
||||||
|
- Obsidian 发布器可持有专用 Git checkout,不直接修改主工作副本。
|
||||||
|
- 确定 Dockerfile 与验收测试后,再接 GitHub Actions + GHCR 构建发布。
|
||||||
|
|
||||||
|
## 验收原则
|
||||||
|
|
||||||
|
原文不可覆盖;候选纠错须有依据;背景不伪装为录音事实;重要歧义交人工确认。
|
||||||
|
以关键内容和行动线索为目标,不追求伪精确的逐字恢复。
|
||||||
|
合成测试与真实录音端到端验收分开,不能以单元测试通过代替上线验收。
|
||||||
|
|
||||||
|
本文件是后续开发计划,本轮不新增推理请求、不切换生产服务、不自动部署容器。
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "meeting-asr"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "Standalone meeting transcription, terminology correction and human-reviewed minutes web application"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = ["pypinyin>=0.51,<1", "rapidfuzz>=3,<4", "numpy>=1.26,<3"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["asrpipe*", "asrservice*"]
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INPUT=${1:?audio file required}
|
||||||
|
OUT=${2:?output txt required}
|
||||||
|
ASR_URL=${MEETING_ASR_URL:-http://192.168.50.100:8000/v1/audio/transcriptions}
|
||||||
|
ASR_MODEL=${MEETING_ASR_MODEL:-qwen3-asr}
|
||||||
|
SEGMENT_SECONDS=${MEETING_ASR_SEGMENT_SECONDS:-540}
|
||||||
|
LANGUAGE=${MEETING_ASR_LANGUAGE:-zh}
|
||||||
|
GLOSSARY=${MEETING_ASR_GLOSSARY:-$HOME/obsidian-vault/minutes/_glossary.md}
|
||||||
|
# Base dir derived from this script's location (recordings/bin/asr-transcribe.sh -> recordings/),
|
||||||
|
# overridable via MEETING_INBOX_BASE. Migration-proof.
|
||||||
|
BASE="${MEETING_INBOX_BASE:-$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)}"
|
||||||
|
LOG_DIR="$BASE/logs"
|
||||||
|
LOG="$LOG_DIR/asr-transcribe.log"
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR" "$(dirname "$OUT")"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ! -f "$INPUT" ]]; then
|
||||||
|
echo "input file not found: $INPUT" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Cloud / Unified Provider branch (soniox / gemini / openai / grok) ------
|
||||||
|
case "${MEETING_ASR_BACKEND:-}" in
|
||||||
|
soniox|gemini|openai|grok)
|
||||||
|
PROJECT_ROOT="$(cd "$BASE/.." && pwd)"
|
||||||
|
log "START backend=${MEETING_ASR_BACKEND} input=$INPUT out=$OUT"
|
||||||
|
if PYTHONPATH="$PROJECT_ROOT:${PYTHONPATH:-}" python3 - "$INPUT" "$OUT" "${MEETING_ASR_BACKEND}" "${MEETING_ASR_MODEL:-}" <<'PY' 2>> "$LOG"
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from asrpipe.providers import transcribe_audio
|
||||||
|
|
||||||
|
audio = Path(sys.argv[1])
|
||||||
|
out = Path(sys.argv[2])
|
||||||
|
provider = sys.argv[3]
|
||||||
|
model = sys.argv[4] if len(sys.argv) > 4 else ""
|
||||||
|
res = transcribe_audio(audio, provider=provider, model=model)
|
||||||
|
out.write_text(res.transcript_text, encoding="utf-8")
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
log "DONE backend=${MEETING_ASR_BACKEND} out=$OUT chars=$(wc -c < "$OUT")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "${MEETING_ASR_BACKEND} transcription failed (see $LOG)" >&2
|
||||||
|
log "ERROR backend=${MEETING_ASR_BACKEND} failed"
|
||||||
|
exit 13
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ---- Backend switch (trial): VibeVoice 分角色后端 ----------------------------
|
||||||
|
# Default backend stays qwen3 (logic below). Set MEETING_ASR_BACKEND=vibevoice
|
||||||
|
# (e.g. in obsidian-recording-watch.service) to route the live pipeline through
|
||||||
|
# vibevoice-transcribe.py, producing a diarized transcript (timestamps + S#).
|
||||||
|
# Interface is preserved: this branch writes the same single $OUT txt the bridge
|
||||||
|
# expects. Fully reversible: unset the env / restore the .bak.
|
||||||
|
if [[ "${MEETING_ASR_BACKEND:-qwen3}" == "vibevoice" ]]; then
|
||||||
|
VIBE_PY="$BASE/bin/vibevoice-transcribe.py"
|
||||||
|
CHUNK="${MEETING_ASR_VIBE_CHUNK:-200}"
|
||||||
|
if [[ ! -f "$VIBE_PY" ]]; then
|
||||||
|
echo "vibevoice backend selected but $VIBE_PY missing" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
mkdir -p "$BASE/processing"
|
||||||
|
VOUT=$(mktemp -d "$BASE/processing/vibe.XXXXXX")
|
||||||
|
log "START backend=vibevoice input=$INPUT chunk=${CHUNK}s out=$OUT"
|
||||||
|
base=$(basename "$INPUT"); base="${base%.*}"
|
||||||
|
if python3 "$VIBE_PY" "$INPUT" --out "$VOUT" --chunk "$CHUNK" >> "$LOG" 2>&1 \
|
||||||
|
&& [[ -f "$VOUT/$base-diarized.txt" ]]; then
|
||||||
|
cp "$VOUT/$base-diarized.txt" "$OUT"
|
||||||
|
log "DONE backend=vibevoice out=$OUT chars=$(wc -c < "$OUT")"
|
||||||
|
rm -rf "$VOUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "vibevoice transcription failed or produced no output (see $LOG)" >&2
|
||||||
|
log "ERROR backend=vibevoice produced no diarized.txt"
|
||||||
|
rm -rf "$VOUT"
|
||||||
|
exit 11
|
||||||
|
fi
|
||||||
|
# ---- Backend: Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹 (:10099) ------------------
|
||||||
|
# MEETING_ASR_BACKEND=qwen3cam (别名 paraformer 向后兼容) 路由到分角色服务。整文件一次
|
||||||
|
# 请求(FSMN-VAD 内部切段),无客户端分段。输出带说话人标签;声纹命中显示真名,否则
|
||||||
|
# speaker_N。可用 env 切换回退。热词/postprocess 均已退役(解码软偏置服务端关闭、
|
||||||
|
# legacy hotword 不生效、确定性预替换抢跑污染),实体纠错统一在 refine 单点处理。
|
||||||
|
if [[ "${MEETING_ASR_BACKEND:-qwen3}" == "qwen3cam" || "${MEETING_ASR_BACKEND:-qwen3}" == "paraformer" ]]; then
|
||||||
|
DIAR_URL="${MEETING_DIARIZE_URL:-${MEETING_PARAFORMER_URL:-http://192.168.50.100:10099/asr}}"
|
||||||
|
log "START backend=qwen3cam input=$INPUT out=$OUT"
|
||||||
|
RESP=$(mktemp)
|
||||||
|
# 中文会议固定 Chinese,避免远场噪声触发粤语/日语漂移;英文缩写仍可正常识别。
|
||||||
|
args=( -sS --max-time 900 -F "file=@$INPUT" -F 'language=Chinese' -F 'save_voiceprint_session=true' )
|
||||||
|
if curl "${args[@]}" "$DIAR_URL" > "$RESP" 2>> "$LOG"; then
|
||||||
|
if python3 - "$RESP" "$OUT" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
d = json.load(open(sys.argv[1], encoding='utf-8'))
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"qwen3cam json parse failed: {e}\n"); sys.exit(1)
|
||||||
|
if 'detail' in d or not d.get('segments'):
|
||||||
|
sys.stderr.write(f"qwen3cam no segments: {str(d)[:160]}\n"); sys.exit(1)
|
||||||
|
vp = d.get('voiceprint', {})
|
||||||
|
matched = [f"{m['speaker_local']}={m['person_name']}" for m in vp.get('matches', []) if m.get('status') == 'matched']
|
||||||
|
sid = vp.get('session_id') or ''
|
||||||
|
# machine-readable line for the voiceprint auto-enroll tool (survives to .asr-inbox)
|
||||||
|
nclusters = len({s.get('speaker') for s in d['segments']})
|
||||||
|
with open(sys.argv[2], 'w', encoding='utf-8') as out:
|
||||||
|
out.write(f"<!-- voiceprint_session: {sid} clusters: {nclusters} -->\n")
|
||||||
|
hdr = "# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)"
|
||||||
|
if matched:
|
||||||
|
hdr += " | 声纹命名: " + ", ".join(matched)
|
||||||
|
out.write(hdr + "\n\n")
|
||||||
|
turns = []
|
||||||
|
for s in d['segments']:
|
||||||
|
spk = s.get('speaker', '?'); t = (s.get('text') or '').strip()
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
if turns and turns[-1][0] == spk:
|
||||||
|
turns[-1][1] += t
|
||||||
|
else:
|
||||||
|
turns.append([spk, t])
|
||||||
|
for spk, t in turns:
|
||||||
|
out.write(f"【{spk}】{t}\n\n")
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
log "DONE backend=qwen3cam out=$OUT chars=$(wc -c < "$OUT")"
|
||||||
|
rm -f "$RESP"; exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "qwen3cam transcription failed (see $LOG)" >&2
|
||||||
|
log "ERROR backend=qwen3cam failed"
|
||||||
|
rm -f "$RESP"; exit 12
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- default Qwen3 path follows --------------------------------------------
|
||||||
|
|
||||||
|
case "$SEGMENT_SECONDS" in
|
||||||
|
''|*[!0-9]*) echo "MEETING_ASR_SEGMENT_SECONDS must be integer" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
if (( SEGMENT_SECONDS < 480 || SEGMENT_SECONDS > 600 )); then
|
||||||
|
log "WARN segment seconds $SEGMENT_SECONDS outside recommended 480-600; continuing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v ffprobe >/dev/null 2>&1; then
|
||||||
|
echo "ffprobe not found" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
echo "ffmpeg not found" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
echo "curl not found" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
echo "python3 not found" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
HOTWORD_GEN="$HOME/claude-workspace/OBSIDIAN/bin/obsidian-asr-hotwords.py"
|
||||||
|
build_prompt() {
|
||||||
|
local terms
|
||||||
|
# Shared generator: cards ∪ glossary entity terms, mapping wrong-sides excluded,
|
||||||
|
# ranked by mention count. Fall back to a minimal inline reader if unavailable.
|
||||||
|
if [[ -f "$HOTWORD_GEN" ]]; then
|
||||||
|
terms=$(python3 "$HOTWORD_GEN" --budget 1800 2>/dev/null | tr ',' '、')
|
||||||
|
fi
|
||||||
|
if [[ -z "$terms" ]]; then
|
||||||
|
terms=$(python3 - "$GLOSSARY" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path(sys.argv[1]).expanduser()
|
||||||
|
if not p.exists():
|
||||||
|
sys.exit(0)
|
||||||
|
out, seen = [], set()
|
||||||
|
for raw in p.read_text(errors='ignore').splitlines():
|
||||||
|
s = raw.strip()
|
||||||
|
if not s.startswith(('-', '*')) or re.search(r'[→⇒]|->', s):
|
||||||
|
continue # mapping lines carry the WRONG form on the left — never bias to it
|
||||||
|
s = re.split(r'\s[—–-]\s|[::]', re.sub(r'^[-*+\d\.\)\s]+', '', s), maxsplit=1)[0].strip()
|
||||||
|
s = re.sub(r'\[\[|\]\]', '', s).strip()
|
||||||
|
if s and s not in seen and len(s) <= 30:
|
||||||
|
seen.add(s); out.append(s)
|
||||||
|
print('、'.join(out[:200]))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
[[ -z "$terms" ]] && return 0
|
||||||
|
printf '以下是会议中可能出现的人名、公司、项目、产品和术语,请优先准确识别这些正确写法:%s' "$terms"
|
||||||
|
}
|
||||||
|
|
||||||
|
DURATION=$(ffprobe -v error -show_entries format=duration -of default=nk=1:nw=1 "$INPUT" | awk '{printf "%d", ($1 == int($1) ? $1 : int($1)+1)}')
|
||||||
|
if [[ -z "$DURATION" || "$DURATION" == "0" ]]; then
|
||||||
|
echo "failed to detect duration: $INPUT" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BASE/processing"
|
||||||
|
TMPDIR=$(mktemp -d "$BASE/processing/asr.XXXXXX")
|
||||||
|
cleanup() { rm -rf "$TMPDIR"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
PROMPT_TEXT=$(build_prompt || true)
|
||||||
|
: > "$OUT"
|
||||||
|
log "START input=$INPUT duration=${DURATION}s segment=${SEGMENT_SECONDS}s out=$OUT"
|
||||||
|
|
||||||
|
segments=$(( (DURATION + SEGMENT_SECONDS - 1) / SEGMENT_SECONDS ))
|
||||||
|
failures=0
|
||||||
|
for ((i=0; i<segments; i++)); do
|
||||||
|
start=$(( i * SEGMENT_SECONDS ))
|
||||||
|
end=$(( start + SEGMENT_SECONDS ))
|
||||||
|
if (( end > DURATION )); then end=$DURATION; fi
|
||||||
|
seg="$TMPDIR/segment_$((i+1)).mp3"
|
||||||
|
txt="$TMPDIR/segment_$((i+1)).txt"
|
||||||
|
printf -- '--- 第%d段 [%02d:%02d - %02d:%02d] ---\n' "$((i+1))" "$((start/60))" "$((start%60))" "$((end/60))" "$((end%60))" >> "$OUT"
|
||||||
|
ffmpeg -hide_banner -loglevel error -y -ss "$start" -t "$SEGMENT_SECONDS" -i "$INPUT" -ar 16000 -ac 1 -b:a 64k "$seg"
|
||||||
|
log "SEGMENT $((i+1))/$segments file=$seg size=$(stat -c%s "$seg")"
|
||||||
|
curl_args=(
|
||||||
|
-sS --fail-with-body --max-time 600
|
||||||
|
-F "model=$ASR_MODEL"
|
||||||
|
-F "file=@$seg"
|
||||||
|
)
|
||||||
|
if [[ -n "$LANGUAGE" ]]; then
|
||||||
|
curl_args+=( -F "language=$LANGUAGE" )
|
||||||
|
fi
|
||||||
|
if [[ -n "$PROMPT_TEXT" ]]; then
|
||||||
|
curl_args+=( -F "prompt=$PROMPT_TEXT" )
|
||||||
|
fi
|
||||||
|
if curl "${curl_args[@]}" "$ASR_URL" > "$txt" 2>> "$LOG"; then
|
||||||
|
python3 - "$txt" >> "$OUT" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
s=Path(sys.argv[1]).read_text(errors='ignore').strip()
|
||||||
|
try:
|
||||||
|
obj=json.loads(s)
|
||||||
|
print((obj.get('text') or obj.get('transcript') or s).strip())
|
||||||
|
except Exception:
|
||||||
|
print(s)
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
failures=$((failures+1))
|
||||||
|
printf '[第%d段转录失败]\n' "$((i+1))" >> "$OUT"
|
||||||
|
log "ERROR segment $((i+1)) failed"
|
||||||
|
fi
|
||||||
|
printf '\n\n' >> "$OUT"
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( failures > 0 )); then
|
||||||
|
log "DONE_WITH_FAILURES failures=$failures out=$OUT"
|
||||||
|
exit 10
|
||||||
|
fi
|
||||||
|
log "DONE out=$OUT chars=$(wc -c < "$OUT")"
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT = Path(__file__).resolve().parents[2]
|
||||||
|
BASE = Path(os.environ.get("MEETING_INBOX_BASE", str(PROJECT / "recordings")))
|
||||||
|
INBOX = BASE / "inbox"
|
||||||
|
PROCESSING = BASE / "processing"
|
||||||
|
PROCESSED = BASE / "processed"
|
||||||
|
FAILED = BASE / "failed"
|
||||||
|
TRANSCRIPTS = BASE / "transcripts"
|
||||||
|
STATE = BASE / "state"
|
||||||
|
LOGS = BASE / "logs"
|
||||||
|
ASR = Path(os.environ.get("MEETING_ASR_SCRIPT", str(PROJECT / "recordings" / "bin" / "asr-transcribe.sh")))
|
||||||
|
SEEN_FILE = STATE / "seen.json"
|
||||||
|
UPLOAD_META = STATE / "uploads"
|
||||||
|
CONTROL_DB = Path(os.environ.get(
|
||||||
|
"ASR_CONTROL_DB",
|
||||||
|
str(Path(os.environ.get("ASR_STATE_DIR", str(PROJECT / "state"))) / "intake.db")))
|
||||||
|
LOG = LOGS / "meeting-watch.log"
|
||||||
|
POLL_SECONDS = int(os.environ.get("MEETING_WATCH_POLL_SECONDS", "30"))
|
||||||
|
STABLE_AGE_SECONDS = int(os.environ.get("MEETING_WATCH_STABLE_AGE_SECONDS", "60"))
|
||||||
|
WEBHOOK_URL = os.environ.get("MEETING_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording")
|
||||||
|
WEBHOOK_ROUTE = os.environ.get("MEETING_WEBHOOK_ROUTE", "meeting-recording")
|
||||||
|
WEBHOOK_SECRET = os.environ.get("MEETING_WEBHOOK_SECRET", "")
|
||||||
|
STATUS_WEBHOOK_URL = os.environ.get("MEETING_STATUS_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording-status")
|
||||||
|
STATUS_WEBHOOK_ROUTE = os.environ.get("MEETING_STATUS_WEBHOOK_ROUTE", "meeting-recording-status")
|
||||||
|
SUBSCRIPTIONS = Path.home() / ".hermes" / "webhook_subscriptions.json"
|
||||||
|
SUPPORTED = {".m4a", ".mp3", ".wav", ".mp4", ".aac", ".ogg", ".opus", ".webm", ".flac"}
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
LOGS.mkdir(parents=True, exist_ok=True)
|
||||||
|
with LOG.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(time.strftime("[%Y-%m-%d %H:%M:%S] ") + msg + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def load_seen():
|
||||||
|
try:
|
||||||
|
return json.loads(SEEN_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_seen(seen):
|
||||||
|
STATE.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = SEEN_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(seen, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(SEEN_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_meta(filename):
|
||||||
|
try:
|
||||||
|
return json.loads((UPLOAD_META / f"{filename}.json").read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def update_job(meta, status, detail="", transcript=""):
|
||||||
|
job_id = str(meta.get("job_id") or "")
|
||||||
|
if not job_id or not CONTROL_DB.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
con = sqlite3.connect(CONTROL_DB, timeout=15)
|
||||||
|
fields, vals = ["status=?", "updated_at=?"], [status, time.time()]
|
||||||
|
if detail:
|
||||||
|
fields.append("detail=?"); vals.append(detail[:1000])
|
||||||
|
if transcript:
|
||||||
|
fields.append("transcript=?"); vals.append(transcript)
|
||||||
|
vals.append(job_id)
|
||||||
|
con.execute(f"UPDATE intake_job SET {','.join(fields)} WHERE id=?", vals)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"CONTROL_STATUS_FAILED job={job_id} status={status} error={exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_job(path, fid):
|
||||||
|
"""Create a visible control-plane task for files dropped outside Web upload."""
|
||||||
|
job_id = f"fs-{fid}"
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
CONTROL_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
con = sqlite3.connect(CONTROL_DB, timeout=15)
|
||||||
|
con.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS intake_job(
|
||||||
|
id TEXT PRIMARY KEY, original_name TEXT NOT NULL,
|
||||||
|
stored_name TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
|
||||||
|
context TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL, detail TEXT NOT NULL DEFAULT '',
|
||||||
|
transcript TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL, draft TEXT NOT NULL DEFAULT '',
|
||||||
|
result TEXT NOT NULL DEFAULT ''
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
columns = {row[1] for row in con.execute("PRAGMA table_info(intake_job)")}
|
||||||
|
for name in ("draft", "result"):
|
||||||
|
if name not in columns:
|
||||||
|
con.execute(
|
||||||
|
f"ALTER TABLE intake_job ADD COLUMN {name} "
|
||||||
|
"TEXT NOT NULL DEFAULT ''")
|
||||||
|
con.execute(
|
||||||
|
"INSERT OR IGNORE INTO intake_job("
|
||||||
|
"id,original_name,stored_name,title,context,size,status,detail,"
|
||||||
|
"transcript,created_at,updated_at,draft,result"
|
||||||
|
") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(job_id, path.name, path.name, path.stem, "", path.stat().st_size,
|
||||||
|
"queued", "监控目录已接收", "", now, now, "", ""))
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
return {
|
||||||
|
"job_id": job_id, "title": path.stem, "context": "",
|
||||||
|
"original_name": path.name, "stored_name": path.name,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"CONTROL_JOB_CREATE_FAILED file={path} error={exc}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def duration_seconds(path):
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output([
|
||||||
|
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||||
|
"-of", "default=nk=1:nw=1", str(path)
|
||||||
|
], text=True, timeout=30).strip()
|
||||||
|
return int(float(out) + 0.999) if out else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def file_id(path):
|
||||||
|
st = path.stat()
|
||||||
|
h = hashlib.sha256()
|
||||||
|
h.update(str(path.name).encode())
|
||||||
|
h.update(str(st.st_size).encode())
|
||||||
|
h.update(str(int(st.st_mtime)).encode())
|
||||||
|
if st.st_size <= 256 * 1024 * 1024:
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def stable(path):
|
||||||
|
try:
|
||||||
|
st1 = path.stat()
|
||||||
|
if time.time() - st1.st_mtime < STABLE_AGE_SECONDS:
|
||||||
|
return False
|
||||||
|
time.sleep(2)
|
||||||
|
st2 = path.stat()
|
||||||
|
return st1.st_size == st2.st_size and int(st1.st_mtime) == int(st2.st_mtime)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def unique_path(directory, name):
|
||||||
|
p = directory / name
|
||||||
|
if not p.exists():
|
||||||
|
return p
|
||||||
|
stem, suffix = p.stem, p.suffix
|
||||||
|
for i in range(1, 10000):
|
||||||
|
q = directory / f"{stem}-{i}{suffix}"
|
||||||
|
if not q.exists():
|
||||||
|
return q
|
||||||
|
raise RuntimeError(f"cannot allocate unique path for {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_secret(route=WEBHOOK_ROUTE):
|
||||||
|
if route == WEBHOOK_ROUTE and WEBHOOK_SECRET:
|
||||||
|
return WEBHOOK_SECRET
|
||||||
|
try:
|
||||||
|
data = json.loads(SUBSCRIPTIONS.read_text(encoding="utf-8"))
|
||||||
|
return str(data.get(route, {}).get("secret", ""))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def post_webhook(payload, url=WEBHOOK_URL, route=WEBHOOK_ROUTE, label="WEBHOOK", timeout=30):
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
secret = webhook_secret(route)
|
||||||
|
if secret and secret != "INSECURE_NO_AUTH":
|
||||||
|
headers["X-Webhook-Signature"] = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read(1000).decode("utf-8", errors="ignore")
|
||||||
|
log(f"{label} status={resp.status} body={body[:300]!r}")
|
||||||
|
return resp.status
|
||||||
|
|
||||||
|
|
||||||
|
def post_status(status, filename, detail=""):
|
||||||
|
payload = {"status": status, "filename": filename, "detail": detail}
|
||||||
|
try:
|
||||||
|
return post_webhook(
|
||||||
|
payload,
|
||||||
|
url=STATUS_WEBHOOK_URL,
|
||||||
|
route=STATUS_WEBHOOK_ROUTE,
|
||||||
|
label="STATUS_WEBHOOK",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"STATUS_WEBHOOK_FAILED status={status} file={filename} error={exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_CONSUMER_LOCK = None
|
||||||
|
|
||||||
|
ASR_INBOX = Path(os.environ.get("ASR_VAULT", str(Path.home() / "obsidian-vault"))) / ".asr-inbox"
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_to_asr_inbox(transcript, stem, title=""):
|
||||||
|
"""Atomically drop the finished transcript into the Obsidian .asr-inbox so the
|
||||||
|
Claude refine pipeline picks it up. Slug carries date+time for the draft name."""
|
||||||
|
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})[_-](\d{2})[_-](\d{2})[_-](\d{2})", stem)
|
||||||
|
if m:
|
||||||
|
y, mo, d, hh, mm, ss = m.groups()
|
||||||
|
tail = stem[m.end():].strip("_- ")
|
||||||
|
safe_tail = re.sub(
|
||||||
|
r"[^\w一-鿿-]+", "_", title or tail).strip("_-")
|
||||||
|
safe_tail = re.sub(r"-[0-9a-f]{8}$", "", safe_tail)
|
||||||
|
label = safe_tail or "录音"
|
||||||
|
slug = f"{y}-{mo}-{d}-{hh}{mm}{ss}-{label}"
|
||||||
|
else:
|
||||||
|
safe = re.sub(r"[^\w一-鿿-]+", "_", stem).strip("_") or "录音"
|
||||||
|
slug = f"{time.strftime('%Y-%m-%d-%H%M%S')}-{safe}"
|
||||||
|
incoming = ASR_INBOX / ".incoming"
|
||||||
|
incoming.mkdir(parents=True, exist_ok=True)
|
||||||
|
(ASR_INBOX / ".state").mkdir(parents=True, exist_ok=True)
|
||||||
|
# Collision guard: never overwrite an unprocessed transcript.
|
||||||
|
dest = ASR_INBOX / f"{slug}.txt"
|
||||||
|
n = 2
|
||||||
|
while dest.exists():
|
||||||
|
dest = ASR_INBOX / f"{slug}-{n}.txt"
|
||||||
|
n += 1
|
||||||
|
tmp = incoming / dest.name
|
||||||
|
shutil.copyfile(str(transcript), str(tmp))
|
||||||
|
tmp.replace(dest) # atomic within the vault filesystem -> triggers the path unit
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def process(path):
|
||||||
|
seen = load_seen()
|
||||||
|
fid = file_id(path)
|
||||||
|
intake = upload_meta(path.name) or ensure_job(path, fid)
|
||||||
|
if fid in seen:
|
||||||
|
log(f"SKIP already seen file={path} id={fid}")
|
||||||
|
return
|
||||||
|
PROCESSING.mkdir(parents=True, exist_ok=True)
|
||||||
|
TRANSCRIPTS.mkdir(parents=True, exist_ok=True)
|
||||||
|
PROCESSED.mkdir(parents=True, exist_ok=True)
|
||||||
|
FAILED.mkdir(parents=True, exist_ok=True)
|
||||||
|
proc = unique_path(PROCESSING, path.name)
|
||||||
|
try:
|
||||||
|
shutil.move(str(path), str(proc))
|
||||||
|
except FileNotFoundError:
|
||||||
|
log(f"SKIP disappeared before claim file={path}")
|
||||||
|
return
|
||||||
|
transcript = TRANSCRIPTS / f"{fid}-{proc.stem}.txt"
|
||||||
|
dur = duration_seconds(proc)
|
||||||
|
log(f"PROCESS file={proc} id={fid} duration={dur}")
|
||||||
|
update_job(intake, "transcribing", f"录音时长约 {dur}s" if dur else "正在转写")
|
||||||
|
post_status("开始转录", proc.name, f"时长约 {dur}s;完成后会继续发送粗整理。")
|
||||||
|
try:
|
||||||
|
subprocess.run([str(ASR), str(proc), str(transcript)], check=True, timeout=None)
|
||||||
|
if intake:
|
||||||
|
original = transcript.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
marker = json.dumps({
|
||||||
|
"job_id": intake.get("job_id", ""),
|
||||||
|
"title": intake.get("title", ""),
|
||||||
|
"context": intake.get("context", ""),
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
transcript.write_text(f"<!-- intake: {marker} -->\n" + original,
|
||||||
|
encoding="utf-8")
|
||||||
|
done = unique_path(PROCESSED, proc.name)
|
||||||
|
shutil.move(str(proc), str(done))
|
||||||
|
post_status("转录完成,正在生成粗整理", done.name, f"转录文件:{transcript}")
|
||||||
|
# Refinement is handled by the Claude Code pipeline (not the Hermes agent
|
||||||
|
# webhook). Bridge the finished transcript into the Obsidian .asr-inbox so
|
||||||
|
# obsidian-asr-refine (systemd path unit) produces a rough-outline draft.
|
||||||
|
bridged = bridge_to_asr_inbox(
|
||||||
|
transcript, proc.stem, str(intake.get("title") or ""))
|
||||||
|
update_job(intake, "refining", "转写完成,正在生成整理稿",
|
||||||
|
transcript=str(transcript))
|
||||||
|
log(f"BRIDGED transcript -> {bridged}")
|
||||||
|
seen[fid] = {"filename": done.name, "audio_path": str(done), "transcript_path": str(transcript), "ts": int(time.time())}
|
||||||
|
save_seen(seen)
|
||||||
|
log(f"DONE file={done} transcript={transcript}")
|
||||||
|
except Exception as exc:
|
||||||
|
fail = unique_path(FAILED, proc.name)
|
||||||
|
if proc.exists():
|
||||||
|
shutil.move(str(proc), str(fail))
|
||||||
|
err = FAILED / f"{fail.name}.error.txt"
|
||||||
|
err.write_text(str(exc), encoding="utf-8")
|
||||||
|
post_status("处理失败", fail.name, f"错误:{exc}")
|
||||||
|
update_job(intake, "failed", f"处理失败:{exc}")
|
||||||
|
log(f"FAILED file={path} error={exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def once():
|
||||||
|
for p in sorted(INBOX.iterdir() if INBOX.exists() else []):
|
||||||
|
if not p.is_file() or p.suffix.lower() not in SUPPORTED:
|
||||||
|
continue
|
||||||
|
if stable(p):
|
||||||
|
process(p)
|
||||||
|
|
||||||
|
|
||||||
|
def hold_consumer_lock():
|
||||||
|
"""Share ASR_STATE_DIR/consumer.lock with asrservice.worker. Exit if it is taken."""
|
||||||
|
path = Path(os.environ.get("ASR_CONSUMER_LOCK", str(CONTROL_DB.parent / "consumer.lock")))
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handle = path.open("a+")
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
handle.close()
|
||||||
|
log(f"EXIT another recording consumer holds {path}")
|
||||||
|
raise SystemExit(1)
|
||||||
|
handle.seek(0)
|
||||||
|
handle.truncate()
|
||||||
|
handle.write(str(os.getpid()))
|
||||||
|
handle.flush()
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global _CONSUMER_LOCK
|
||||||
|
_CONSUMER_LOCK = hold_consumer_lock()
|
||||||
|
for d in [INBOX, PROCESSING, PROCESSED, FAILED, TRANSCRIPTS, STATE, LOGS]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not ASR.exists():
|
||||||
|
raise SystemExit(f"missing ASR script: {ASR}")
|
||||||
|
log("START watcher")
|
||||||
|
if "--once" in sys.argv:
|
||||||
|
once()
|
||||||
|
return
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
once()
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"LOOP_ERROR {exc}")
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ECAPA-TDNN 声纹 + KMeans 全局说话人统一(在 ASR venv 里跑)。
|
||||||
|
把 VibeVoice 每块独立的 S# 标签换成全场统一的 G# ID。
|
||||||
|
|
||||||
|
用法(由 vibevoice-transcribe.py --unify 调用,也可单独跑):
|
||||||
|
<venv>/bin/python vibevoice-speaker-unify.py <audio> <segs.json> <out.md> [K]
|
||||||
|
|
||||||
|
局限(见 vault: ASR服务横评-2026-06):单麦远场录音里声纹只能可靠锁"主讲人",
|
||||||
|
次要说话人分不干净。K 默认 4(K>=5 会劈裂主讲人)。
|
||||||
|
"""
|
||||||
|
import json, os, sys, subprocess, numpy as np
|
||||||
|
import torch, soundfile as sf
|
||||||
|
from speechbrain.inference.speaker import EncoderClassifier
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
audio = sys.argv[1]
|
||||||
|
segs_path = sys.argv[2]
|
||||||
|
out_md = sys.argv[3]
|
||||||
|
K = int(sys.argv[4]) if len(sys.argv) > 4 else 4
|
||||||
|
MIN_DUR = 1.2
|
||||||
|
|
||||||
|
segs = [s for s in json.load(open(segs_path)) if s.get("Content", "").strip()]
|
||||||
|
work = os.path.join(os.path.dirname(segs_path), "_segwav"); os.makedirs(work, exist_ok=True)
|
||||||
|
|
||||||
|
clf = EncoderClassifier.from_hparams(source="speechbrain/spkrec-ecapa-voxceleb",
|
||||||
|
savedir=os.path.expanduser("~/.cache/ecapa_model"),
|
||||||
|
run_opts={"device": "cpu"})
|
||||||
|
emb, idx = [], []
|
||||||
|
for i, s in enumerate(segs):
|
||||||
|
if s["End"] - s["Start"] < MIN_DUR:
|
||||||
|
continue
|
||||||
|
wp = os.path.join(work, f"{i:04d}.wav")
|
||||||
|
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", str(s["Start"]),
|
||||||
|
"-t", str(s["End"] - s["Start"]), "-i", audio, "-ar", "16000", "-ac", "1", wp], check=True)
|
||||||
|
try:
|
||||||
|
wav, sr = sf.read(wp)
|
||||||
|
if len(wav) < 16000 * 0.8:
|
||||||
|
continue
|
||||||
|
e = clf.encode_batch(torch.tensor(wav, dtype=torch.float32).unsqueeze(0)).squeeze().detach().numpy()
|
||||||
|
emb.append(e); idx.append(i)
|
||||||
|
except Exception as ex:
|
||||||
|
print("embed fail", i, ex)
|
||||||
|
emb = np.array(emb)
|
||||||
|
X = emb / np.linalg.norm(emb, axis=1, keepdims=True)
|
||||||
|
print(f"embedded {len(emb)} segments; clustering K={K}", flush=True)
|
||||||
|
|
||||||
|
km = KMeans(n_clusters=K, n_init=10, random_state=0).fit(X)
|
||||||
|
gid = {idx[k]: int(km.labels_[k]) for k in range(len(idx))}
|
||||||
|
es = sorted(gid)
|
||||||
|
for i, s in enumerate(segs):
|
||||||
|
s["G"] = gid.get(i) if i in gid else gid[min(es, key=lambda j: abs(segs[j]["Start"] - segs[i]["Start"]))]
|
||||||
|
# rank G by talk-time (G0 = most)
|
||||||
|
t = Counter()
|
||||||
|
for s in segs: t[s["G"]] += s["End"] - s["Start"]
|
||||||
|
rank = {old: new for new, (old, _) in enumerate(t.most_common())}
|
||||||
|
for s in segs: s["G"] = rank[s["G"]]
|
||||||
|
tt = Counter()
|
||||||
|
for s in segs: tt[s["G"]] += s["End"] - s["Start"]
|
||||||
|
total = sum(tt.values()) or 1
|
||||||
|
print("talk-time:", {f"G{k}": f"{round(v)}s/{v/total*100:.0f}%" for k, v in sorted(tt.items())})
|
||||||
|
|
||||||
|
def hms(x): x = int(x); return f"{x//60:02d}:{x%60:02d}"
|
||||||
|
lines = [f"# {os.path.basename(audio)} · 全局统一说话人 (ECAPA+KMeans K={K})",
|
||||||
|
"> G0=说话最多 · 括号内为 VibeVoice 原始每块标签 · 单麦远场次要说话人分离有限 · 仅草稿\n"]
|
||||||
|
for s in sorted(segs, key=lambda x: x["Start"]):
|
||||||
|
lines.append(f"- `{hms(s['Start'])}` **G{s['G']}** (S{s['Speaker']}): {s['Content']}")
|
||||||
|
open(out_md, "w").write("\n".join(lines))
|
||||||
|
print(f"wrote {out_md}", flush=True)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
VibeVoice 分角色转录(手动按需后端,非自动管线)。
|
||||||
|
把任意音频转成带说话人标签 + 时间戳的草稿。默认管线仍是 Qwen3(asr-transcribe.sh);
|
||||||
|
这个工具用于"需要谁说了什么"的场景。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
vibevoice-transcribe.py <audio> [--out DIR] [--chunk 200] [--unify]
|
||||||
|
|
||||||
|
输出:
|
||||||
|
<out>/<basename>-diarized.md # 带 `mm:ss` 时间戳 + S# 说话人(每块独立编号)
|
||||||
|
<out>/<basename>-diarized.txt # 纯文本版
|
||||||
|
--unify 时额外 -global.md # ECAPA+KMeans 全局统一说话人(需 venv,单麦只能锁主讲)
|
||||||
|
|
||||||
|
注意(踩坑见 vault: ASR服务横评-2026-06):
|
||||||
|
- 服务端 segments 是 JSON 字符串,需 json.loads;非语音段无 Speaker 键。
|
||||||
|
- 200s 是最佳分段(被服务端 max_time 限,单请求 ~280s 音频上限)。
|
||||||
|
- 仅出草稿,正式纪要仍需 Kai 确认(memo-inbox 纪律)。
|
||||||
|
"""
|
||||||
|
import json, subprocess, os, math, re, sys, argparse
|
||||||
|
|
||||||
|
URL = os.environ.get("VIBE_URL", "http://192.168.50.100:10112/v1/audio/transcriptions")
|
||||||
|
MODEL = "vibevoice-asr-hf-nf4"
|
||||||
|
GLOSSARY = os.path.expanduser("~/obsidian-vault/minutes/_glossary.md")
|
||||||
|
SEG_RE = re.compile(r'\{"Start":\s*([\d.]+)\s*,\s*"End":\s*([\d.]+)\s*,\s*"Speaker":\s*(\d+|null)\s*,\s*"Content":\s*"((?:[^"\\]|\\.)*)"\s*\}')
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt():
|
||||||
|
"""Reuse glossary wikilinks + terms as a hotword prompt (same spirit as asr-transcribe.sh)."""
|
||||||
|
terms = []
|
||||||
|
p = GLOSSARY
|
||||||
|
if os.path.exists(p):
|
||||||
|
for raw in open(p, errors="ignore").read().splitlines():
|
||||||
|
s = raw.strip()
|
||||||
|
if not s or s.startswith("#") or s.startswith(">"):
|
||||||
|
continue
|
||||||
|
terms += [m.strip() for m in re.findall(r'\[\[([^\]|#]+)', s)]
|
||||||
|
s = re.sub(r'^[-*+\d\.\)\s]+', '', s)
|
||||||
|
s = re.split(r'\s[—–-]\s|[::]', s, maxsplit=1)[0].strip()
|
||||||
|
s = re.sub(r'\[\[|\]\]', '', s).strip()
|
||||||
|
if s and len(s) <= 80:
|
||||||
|
terms.append(s)
|
||||||
|
seen, out = set(), []
|
||||||
|
for t in terms:
|
||||||
|
if t and t not in seen:
|
||||||
|
seen.add(t); out.append(t)
|
||||||
|
if not out:
|
||||||
|
return ""
|
||||||
|
return "以下是会议中可能出现的人名、公司、项目、产品和术语,请优先按这些词纠正识别错误:" + " ".join(out[:200])
|
||||||
|
|
||||||
|
|
||||||
|
def parse_segs(d):
|
||||||
|
raw = d.get("segments")
|
||||||
|
arr = raw if isinstance(raw, list) else None
|
||||||
|
if arr is None and isinstance(raw, str):
|
||||||
|
try: arr = json.loads(raw)
|
||||||
|
except Exception: arr = None
|
||||||
|
if arr is None:
|
||||||
|
body = re.sub(r'^\s*assistant\s*', '', d.get("text", ""))
|
||||||
|
arr = [{"Start": float(a), "End": float(b),
|
||||||
|
"Speaker": (None if c == "null" else int(c)), "Content": w}
|
||||||
|
for a, b, c, w in SEG_RE.findall(body)]
|
||||||
|
out = []
|
||||||
|
for s in arr:
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
continue
|
||||||
|
cc = (s.get("Content") or "").strip()
|
||||||
|
if not cc:
|
||||||
|
continue
|
||||||
|
out.append({"Start": float(s.get("Start", 0)), "End": float(s.get("End", 0)),
|
||||||
|
"Speaker": s.get("Speaker"), "Content": cc})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def is_loop(cc):
|
||||||
|
return len(cc) > 200 and cc[:18] and cc.count(cc[:18]) > 8
|
||||||
|
|
||||||
|
|
||||||
|
def hms(x):
|
||||||
|
x = int(x); return f"{x // 60:02d}:{x % 60:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("audio")
|
||||||
|
ap.add_argument("--out", default=None, help="output dir (default: alongside audio)")
|
||||||
|
ap.add_argument("--chunk", type=int, default=200)
|
||||||
|
ap.add_argument("--unify", action="store_true", help="also run ECAPA global speaker unification")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
audio = os.path.abspath(os.path.expanduser(args.audio))
|
||||||
|
if not os.path.isfile(audio):
|
||||||
|
sys.exit(f"audio not found: {audio}")
|
||||||
|
base = re.sub(r'\.[^.]+$', '', os.path.basename(audio))
|
||||||
|
outdir = os.path.abspath(args.out) if args.out else os.path.dirname(audio)
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
work = os.path.join(outdir, f".{base}.vibework"); os.makedirs(work, exist_ok=True)
|
||||||
|
|
||||||
|
prompt = build_prompt()
|
||||||
|
dur = float(subprocess.check_output(
|
||||||
|
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||||
|
"-of", "default=nk=1:nw=1", audio]).decode().strip())
|
||||||
|
n = math.ceil(dur / args.chunk)
|
||||||
|
print(f"[vibevoice] {os.path.basename(audio)} dur={dur:.0f}s -> {n} chunks of {args.chunk}s", flush=True)
|
||||||
|
|
||||||
|
all_segs = []
|
||||||
|
for i in range(n):
|
||||||
|
start = i * args.chunk
|
||||||
|
length = min(args.chunk, dur - start)
|
||||||
|
mp3 = os.path.join(work, f"c{i:02d}.mp3")
|
||||||
|
subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", str(start),
|
||||||
|
"-t", str(length), "-i", audio, "-ar", "16000", "-ac", "1", "-b:a", "64k", mp3], check=True)
|
||||||
|
out_json = os.path.join(work, f"c{i:02d}.json")
|
||||||
|
mnt = min(4096, 1024 + args.chunk * 12)
|
||||||
|
rc = subprocess.run(["curl", "-sS", "--max-time", "260", "-F", f"model={MODEL}",
|
||||||
|
"-F", f"file=@{mp3}", "-F", f"prompt={prompt}",
|
||||||
|
"-F", f"max_new_tokens={mnt}", URL, "-o", out_json]).returncode
|
||||||
|
try:
|
||||||
|
d = json.load(open(out_json)); segs = parse_segs(d)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" chunk {i+1}/{n} FAIL ({e}) rc={rc}", flush=True); continue
|
||||||
|
loops = sum(1 for s in segs if is_loop(s["Content"]))
|
||||||
|
last = max((s["End"] for s in segs), default=0)
|
||||||
|
for s in segs:
|
||||||
|
cc = s["Content"]
|
||||||
|
if len(cc) > 400:
|
||||||
|
cc = cc[:120] + " …[复读截断]"
|
||||||
|
all_segs.append({"Start": start + s["Start"], "End": start + s["End"],
|
||||||
|
"Speaker": s["Speaker"], "Content": cc})
|
||||||
|
tag = f" ⚠loop×{loops}" if loops else ""
|
||||||
|
print(f" chunk {i+1}/{n} @{start:.0f}s: {len(segs)} segs, cov->{last:.0f}/{length:.0f}s{tag}", flush=True)
|
||||||
|
|
||||||
|
all_segs.sort(key=lambda x: x["Start"])
|
||||||
|
md = [f"# {base} · VibeVoice 分角色草稿",
|
||||||
|
f"> 音频 {dur/60:.0f}min · {args.chunk}s 切块 · 说话人 S# **每块独立编号**(块间需对齐) · 仅草稿待确认\n"]
|
||||||
|
txt = []
|
||||||
|
for s in all_segs:
|
||||||
|
spk = f"S{s['Speaker']}" if s["Speaker"] is not None else "·"
|
||||||
|
md.append(f"- `{hms(s['Start'])}` **{spk}**: {s['Content']}")
|
||||||
|
txt.append(f"[{hms(s['Start'])}] {spk}: {s['Content']}")
|
||||||
|
md_path = os.path.join(outdir, f"{base}-diarized.md")
|
||||||
|
txt_path = os.path.join(outdir, f"{base}-diarized.txt")
|
||||||
|
open(md_path, "w").write("\n".join(md))
|
||||||
|
open(txt_path, "w").write("\n".join(txt))
|
||||||
|
json.dump(all_segs, open(os.path.join(work, "segs.json"), "w"), ensure_ascii=False)
|
||||||
|
print(f"[vibevoice] {len(all_segs)} segs -> {md_path}", flush=True)
|
||||||
|
|
||||||
|
if args.unify:
|
||||||
|
unify = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vibevoice-speaker-unify.py")
|
||||||
|
venv_py = os.environ.get("ASR_VENV_PY",
|
||||||
|
os.path.expanduser("~/claude-workspace/OBSIDIAN/recordings/asr-venv/bin/python"))
|
||||||
|
if os.path.exists(venv_py) and os.path.exists(unify):
|
||||||
|
print("[vibevoice] running ECAPA global speaker unification ...", flush=True)
|
||||||
|
subprocess.run([venv_py, unify, audio, os.path.join(work, "segs.json"),
|
||||||
|
os.path.join(outdir, f"{base}-global.md")])
|
||||||
|
else:
|
||||||
|
print(f"[vibevoice] --unify skipped: venv ({venv_py}) or unify script missing. "
|
||||||
|
f"See vault ASR服务横评 to rebuild venv.", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Send a Telegram status ping through the existing Hermes meeting-recording-status
|
||||||
|
webhook. Used by the Claude refine step to notify Kai that a draft is ready, reusing
|
||||||
|
the same signed channel that meeting-watch.py uses for 开始转录/转录完成.
|
||||||
|
|
||||||
|
Usage: meeting-status-notify.py "<status>" "<filename>" "<detail>"
|
||||||
|
Silent no-op (exit 0) if the webhook/secret is unavailable — never block the pipeline.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
URL = os.environ.get("MEETING_STATUS_WEBHOOK_URL", "http://127.0.0.1:8644/webhooks/meeting-recording-status")
|
||||||
|
ROUTE = os.environ.get("MEETING_STATUS_WEBHOOK_ROUTE", "meeting-recording-status")
|
||||||
|
SUBSCRIPTIONS = Path(os.environ.get("MEETING_SUBSCRIPTIONS_FILE", str(Path.home() / ".hermes" / "webhook_subscriptions.json")))
|
||||||
|
|
||||||
|
|
||||||
|
def secret():
|
||||||
|
if os.environ.get("MEETING_STATUS_WEBHOOK_SECRET"):
|
||||||
|
return os.environ["MEETING_STATUS_WEBHOOK_SECRET"]
|
||||||
|
try:
|
||||||
|
data = json.loads(SUBSCRIPTIONS.read_text(encoding="utf-8"))
|
||||||
|
return str(data.get(ROUTE, {}).get("secret", ""))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
status = sys.argv[1] if len(sys.argv) > 1 else "通知"
|
||||||
|
filename = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
|
detail = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||||
|
payload = json.dumps({"status": status, "filename": filename, "detail": detail},
|
||||||
|
ensure_ascii=False).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
sec = secret()
|
||||||
|
if sec and sec != "INSECURE_NO_AUTH":
|
||||||
|
headers["X-Webhook-Signature"] = hmac.new(sec.encode(), payload, hashlib.sha256).hexdigest()
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(URL, data=payload, headers=headers, method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
print(f"status webhook -> {resp.status}")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"status webhook skipped: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TMP = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
|
||||||
|
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
|
||||||
|
os.environ["ASR_RECORDINGS_ROOT"] = str(Path(TMP.name) / "recordings")
|
||||||
|
|
||||||
|
from asrpipe import control_web, review, segment # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ControlWebTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.db_dir = Path(TMP.name) / "control-state"
|
||||||
|
self.db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
control_web.DB = self.db_dir / "intake.db"
|
||||||
|
|
||||||
|
def test_job_state_is_persistent(self):
|
||||||
|
con = control_web._connect()
|
||||||
|
now = 100.0
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
|
||||||
|
"status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||||
|
("j1", "call.mp3", "stored.mp3", "项目通话", "背景", 123,
|
||||||
|
"queued", now, now))
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
self.assertTrue(control_web.update_job(
|
||||||
|
"j1", "transcribing", "正在转写"))
|
||||||
|
job = control_web._jobs(1)[0]
|
||||||
|
self.assertEqual(job["status"], "transcribing")
|
||||||
|
self.assertEqual(job["detail"], "正在转写")
|
||||||
|
|
||||||
|
def test_schema_migrates_task_review_fields(self):
|
||||||
|
con = control_web._connect()
|
||||||
|
columns = {row["name"] for row in con.execute(
|
||||||
|
"PRAGMA table_info(intake_job)")}
|
||||||
|
con.close()
|
||||||
|
self.assertIn("draft", columns)
|
||||||
|
self.assertIn("result", columns)
|
||||||
|
|
||||||
|
def test_review_queue_is_scoped_to_one_job(self):
|
||||||
|
con = control_web._connect()
|
||||||
|
now = 200.0
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO intake_job(id,original_name,stored_name,title,context,size,"
|
||||||
|
"status,detail,transcript,created_at,updated_at,draft,result) "
|
||||||
|
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
("job-scope", "scope.wav", "scope.wav", "范围测试", "", 10,
|
||||||
|
"review", "", "/archive/scope.txt", now, now,
|
||||||
|
"/draft/scope.md", ""))
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
own = review.enqueue("/archive/scope.txt", "本任务问题?", {
|
||||||
|
"kind": "draft_question"})
|
||||||
|
review.enqueue("/archive/other.txt", "其他任务问题?", {
|
||||||
|
"kind": "draft_question"})
|
||||||
|
job = control_web._job("job-scope")
|
||||||
|
rows = control_web._pending_for_job(job)
|
||||||
|
self.assertEqual([row["id"] for row in rows], [own])
|
||||||
|
self.assertTrue(control_web._review_url("job-scope").endswith(
|
||||||
|
"/jobs/job-scope/review/"))
|
||||||
|
|
||||||
|
def test_intake_metadata_is_not_transcript_body(self):
|
||||||
|
metadata = {
|
||||||
|
"job_id": "abc", "title": "项目通话",
|
||||||
|
"context": "参与人是张三和李四",
|
||||||
|
}
|
||||||
|
raw = (
|
||||||
|
"<!-- intake: " + json.dumps(metadata, ensure_ascii=False) + " -->\n"
|
||||||
|
"<!-- voiceprint_session: sess clusters: 1 -->\n"
|
||||||
|
"# 转写(Qwen3-ASR 1.7B + CAM++ 分角色 + 声纹)\n\n"
|
||||||
|
"【speaker_0】测试正文。\n")
|
||||||
|
turns, meta = segment.parse(raw)
|
||||||
|
self.assertEqual(meta["intake"], metadata)
|
||||||
|
self.assertEqual(turns[0].text, "测试正文。")
|
||||||
|
self.assertNotIn("参与人", turns[0].text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
TMP = tempfile.TemporaryDirectory()
|
||||||
|
os.environ["ASR_STATE_DIR"] = str(Path(TMP.name) / "state")
|
||||||
|
os.environ["ASR_VAULT"] = str(Path(TMP.name) / "vault")
|
||||||
|
os.environ["XDG_RUNTIME_DIR"] = TMP.name
|
||||||
|
|
||||||
|
from asrpipe import cli, context, flagger, graph, lexicon, numbers, review, revise, vault # noqa: E402
|
||||||
|
from asrpipe.config import CFG # noqa: E402
|
||||||
|
from asrpipe.refine import _apply_settled, _queue_ambiguous # noqa: E402
|
||||||
|
from asrpipe import refine # noqa: E402
|
||||||
|
from asrpipe.segment import Chunk, Turn # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class CoreTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
CFG.vault.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def test_context_compacts_duplicate_recall(self):
|
||||||
|
raw = [
|
||||||
|
"刘冀是张科领弋的基金投资人,目前处于活跃合作状态。",
|
||||||
|
"刘冀是张科领弋的基金投资人,最近联系日期为2026年6月26日。",
|
||||||
|
"刘冀是张科领弋的基金投资人,目前处于活跃合作状态。",
|
||||||
|
]
|
||||||
|
out = context.compact(["刘冀"], raw, per_entity=2)
|
||||||
|
self.assertEqual(out.count("目前处于活跃合作状态"), 1)
|
||||||
|
self.assertLessEqual(out.count("\n- "), 2)
|
||||||
|
|
||||||
|
def test_settled_mapping_is_deterministic_and_common_word_is_safe(self):
|
||||||
|
flags = [
|
||||||
|
{"span": "老万", "settled": True, "common_word": False,
|
||||||
|
"candidates": [{"target": "万亚坤"}]},
|
||||||
|
{"span": "公司", "settled": True, "common_word": True,
|
||||||
|
"candidates": [{"target": "工时"}]},
|
||||||
|
]
|
||||||
|
out, decisions = _apply_settled("老万说公司暂时不投。", flags)
|
||||||
|
self.assertEqual(out, "万亚坤说公司暂时不投。")
|
||||||
|
self.assertEqual(decisions[0]["surface"], "老万")
|
||||||
|
|
||||||
|
def test_review_answer_becomes_reusable_alias(self):
|
||||||
|
qid = review.enqueue("fixture-1", "老万是不是万亚坤?", {
|
||||||
|
"surface": "老万", "candidates": ["万亚坤"],
|
||||||
|
"sentence": "老万说洛启的事情"})
|
||||||
|
result = review.answer(qid, "万亚坤")
|
||||||
|
self.assertTrue(result["learned"])
|
||||||
|
self.assertIn("万亚坤", [x[0] for x in lexicon.alias_map()["老万"]])
|
||||||
|
|
||||||
|
def test_review_answer_is_idempotent(self):
|
||||||
|
qid = review.enqueue("fixture-idempotent", "是否确认?", {
|
||||||
|
"surface": "老张", "candidates": ["张成海"]})
|
||||||
|
first = review.answer(qid, "张成海")
|
||||||
|
second = review.answer(qid, "另一个人")
|
||||||
|
self.assertTrue(first["learned"])
|
||||||
|
self.assertTrue(second["already_answered"])
|
||||||
|
self.assertEqual(second["answer"], "张成海")
|
||||||
|
|
||||||
|
def test_draft_delivery_queues_questions_and_persists_answers(self):
|
||||||
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = CFG.drafts / "web-draft.md"
|
||||||
|
path.write_text(
|
||||||
|
"# 草稿\n\n## 通话背景(待确认)\n\n双方讨论项目安排。\n\n"
|
||||||
|
"## 待你确认\n\n"
|
||||||
|
"1. 刘总是 [[刘冀]] 还是 [[刘大涛]]?\n"
|
||||||
|
"2. speaker_0 是谁?\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
count = review.enqueue_draft("web-draft-recording", path)
|
||||||
|
self.assertEqual(count, 4)
|
||||||
|
rows = [r for r in review.pending(20)
|
||||||
|
if r["recording"] == "web-draft-recording"]
|
||||||
|
self.assertEqual(len(rows), 4)
|
||||||
|
self.assertEqual(json.loads(rows[0]["payload"])["kind"],
|
||||||
|
"background_check")
|
||||||
|
question_payload = json.loads(rows[1]["payload"])
|
||||||
|
self.assertEqual(question_payload["candidates"], ["刘冀", "刘大涛"])
|
||||||
|
with mock.patch.object(vault, "sync", return_value="disabled"), \
|
||||||
|
mock.patch.object(review, "_request_revision", return_value=True):
|
||||||
|
for row, answer_value in zip(
|
||||||
|
rows, ["背景基本准确", "刘冀", "谭凯", "草稿整体可用"]):
|
||||||
|
review.answer(row["id"], answer_value)
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("Web 人工确认记录", text)
|
||||||
|
self.assertIn("**回答**:刘冀", text)
|
||||||
|
self.assertIn("**回答**:草稿整体可用", text)
|
||||||
|
|
||||||
|
def test_draft_delivery_prompts_only_substantive_missing_voiceprints(self):
|
||||||
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
||||||
|
transcripts = Path(TMP.name) / "voiceprint-transcript.txt"
|
||||||
|
transcripts.write_text(
|
||||||
|
"【speaker_0】" + "这是高瑶对协议条款和保密范围的详细说明。" * 8 + "\n"
|
||||||
|
"【speaker_2】嗯。\n"
|
||||||
|
"【谭凯】好的。\n", encoding="utf-8")
|
||||||
|
path = CFG.drafts / "voiceprint-draft.md"
|
||||||
|
path.write_text(
|
||||||
|
"---\nstatus: 草稿待确认\n"
|
||||||
|
f"transcript: {transcripts}\n"
|
||||||
|
"voiceprint_session: test-session\n"
|
||||||
|
"voiceprint_speakers:\n speaker_1: 谭凯\n---\n\n"
|
||||||
|
"# 草稿\n\n## 通话背景(待确认)\n\n协议修改沟通。\n\n"
|
||||||
|
"## 待你确认\n\n1. speaker_0 身份是谁?\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
review.enqueue_draft("voiceprint-recording", path)
|
||||||
|
rows = [r for r in review.pending(20)
|
||||||
|
if r["recording"] == "voiceprint-recording"]
|
||||||
|
kinds = [json.loads(r["payload"]).get("kind") for r in rows]
|
||||||
|
self.assertEqual(kinds.count("voiceprint"), 1)
|
||||||
|
voice = next(json.loads(r["payload"]) for r in rows
|
||||||
|
if json.loads(r["payload"]).get("kind") == "voiceprint")
|
||||||
|
self.assertEqual(voice["speaker_id"], "speaker_0")
|
||||||
|
self.assertFalse(any("speaker_2" in r["question"] for r in rows))
|
||||||
|
|
||||||
|
def test_entity_card_suggestion_counts_distinct_formal_sources(self):
|
||||||
|
con = lexicon.connect()
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO entity_mention(entity,kind,source,occurred_at) "
|
||||||
|
"VALUES(?,?,?,?)", ("高瑶", "人名", "minute:older.md", 1))
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = CFG.drafts / "entity-suggestion.md"
|
||||||
|
path.write_text(
|
||||||
|
"---\npeople: [高瑶]\ncompanies: []\n---\n\n"
|
||||||
|
"# 草稿\n\n## 通话背景(待确认)\n\n协议沟通。\n\n"
|
||||||
|
"## 待你确认\n\n", encoding="utf-8")
|
||||||
|
review.enqueue_draft("entity-suggestion-recording", path)
|
||||||
|
rows = [r for r in review.pending(20)
|
||||||
|
if r["recording"] == "entity-suggestion-recording"]
|
||||||
|
cards = [json.loads(r["payload"]) for r in rows
|
||||||
|
if json.loads(r["payload"]).get("kind") == "entity_card"]
|
||||||
|
self.assertEqual(len(cards), 1)
|
||||||
|
self.assertEqual(cards[0]["entity"], "高瑶")
|
||||||
|
self.assertEqual(cards[0]["entity_kind"], "人名")
|
||||||
|
|
||||||
|
def test_first_review_prepares_preview_without_publishing(self):
|
||||||
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = CFG.drafts / "publish-fixture.md"
|
||||||
|
path.write_text(
|
||||||
|
"---\ndate: 2026-07-26\ntype: minutes\n"
|
||||||
|
"tags: [会议纪要, 草稿, 待确认]\nsource: 录音转录\n"
|
||||||
|
"status: 草稿待确认\npeople: [谭凯]\ncompanies: []\n"
|
||||||
|
"transcript: /archive/publish.txt\n---\n\n# 粗整理草稿\n\n"
|
||||||
|
f"{review.FEEDBACK_START}\n## Web 人工确认记录\n\n"
|
||||||
|
"1. **问题**:背景?\n - **回答**:准确\n\n"
|
||||||
|
f"{review.FEEDBACK_END}\n", encoding="utf-8")
|
||||||
|
formal = """---
|
||||||
|
date: 2026-07-26
|
||||||
|
type: minutes
|
||||||
|
tags: [会议纪要]
|
||||||
|
source: 录音转录
|
||||||
|
people: [谭凯]
|
||||||
|
companies: []
|
||||||
|
transcript: /archive/publish.txt
|
||||||
|
---
|
||||||
|
|
||||||
|
# 谭凯通话测试
|
||||||
|
|
||||||
|
> [!summary] Summary
|
||||||
|
> 测试正式发布。
|
||||||
|
"""
|
||||||
|
with mock.patch.object(revise, "chat", return_value=formal), \
|
||||||
|
mock.patch("asrpipe.review_web.request_start", return_value=True):
|
||||||
|
self.assertTrue(revise.revise(path))
|
||||||
|
self.assertTrue(path.exists())
|
||||||
|
self.assertIn("status: 待发布确认", path.read_text())
|
||||||
|
approvals = [
|
||||||
|
r for r in review.pending(100)
|
||||||
|
if json.loads(r["payload"]).get("draft_path") == str(path)
|
||||||
|
and json.loads(r["payload"]).get("kind") == "publish_approval"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(approvals), 1)
|
||||||
|
destination = CFG.minutes / "2026-07-26-谭凯通话测试.md"
|
||||||
|
self.assertFalse(destination.exists())
|
||||||
|
|
||||||
|
with mock.patch.object(review, "_request_revision", return_value=False):
|
||||||
|
review.answer(approvals[0]["id"], "发布正式稿")
|
||||||
|
with mock.patch.object(vault, "sync", return_value="pushed"), \
|
||||||
|
mock.patch.object(vault, "notify"), \
|
||||||
|
mock.patch.object(graph, "sync", return_value={}):
|
||||||
|
self.assertTrue(revise.revise(path))
|
||||||
|
self.assertTrue(destination.exists())
|
||||||
|
self.assertNotIn("草稿待确认", destination.read_text())
|
||||||
|
self.assertNotIn("待发布确认", destination.read_text())
|
||||||
|
self.assertFalse(path.exists())
|
||||||
|
self.assertTrue((CFG.state_dir / "published" / path.name).exists())
|
||||||
|
|
||||||
|
def test_normalize_can_disable_review_side_effects(self):
|
||||||
|
chunk = Chunk(0, [Turn("", "老万说洛启项目。", 0)])
|
||||||
|
flags = [{
|
||||||
|
"span": "老万", "settled": False, "common_word": False,
|
||||||
|
"sentence": "老万说洛启项目。",
|
||||||
|
"candidates": [{
|
||||||
|
"target": "万亚坤", "kind": "人名", "score": 0.9,
|
||||||
|
"rule": "alias", "source": "alias-card",
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
usage = refine.Usage()
|
||||||
|
with mock.patch.object(refine, "_queue_ambiguous") as queue, \
|
||||||
|
mock.patch.object(refine, "chat_many",
|
||||||
|
return_value=["老万说洛启项目。"]):
|
||||||
|
_parts, _decisions, stats = refine.normalize_chunks(
|
||||||
|
[chunk], [chunk.body], flags, "", "dry-run-fixture", usage,
|
||||||
|
queue_review=False)
|
||||||
|
queue.assert_not_called()
|
||||||
|
self.assertEqual(stats["review_queued"], 0)
|
||||||
|
|
||||||
|
def test_atomic_write_and_recovery_lookup(self):
|
||||||
|
CFG.drafts.mkdir(parents=True, exist_ok=True)
|
||||||
|
p = CFG.drafts / "fixture.md"
|
||||||
|
vault.write_draft_file(p, "---\ntranscript: /archive/a.txt\n---\nbody")
|
||||||
|
self.assertEqual(vault.find_draft_for_transcript("/archive/a.txt"), p)
|
||||||
|
leftovers = list(CFG.drafts.glob(".fixture.md.*"))
|
||||||
|
self.assertEqual(leftovers, [])
|
||||||
|
|
||||||
|
def test_relation_card_is_structured(self):
|
||||||
|
lexicon.upsert_relation("刘冀", "投资", "张科领弋", confidence=0.9,
|
||||||
|
source="test")
|
||||||
|
card = lexicon.relationship_card({"刘冀"})
|
||||||
|
self.assertTrue(any("刘冀 投资 张科领弋" in x for x in card))
|
||||||
|
|
||||||
|
def test_confirmed_correction_enters_personal_confusion_channel(self):
|
||||||
|
lexicon.learn_correction("张泰海", "张成海", confirmed=1,
|
||||||
|
note="ASR fixture")
|
||||||
|
rows = lexicon.confusion_map()["张泰海"]
|
||||||
|
self.assertTrue(any(canon == "张成海" and confirmed
|
||||||
|
for canon, _n, confirmed in rows))
|
||||||
|
|
||||||
|
def test_confirmed_correction_teaches_directional_syllable_error(self):
|
||||||
|
lexicon.learn_correction("张泰海", "张成海", confirmed=1)
|
||||||
|
flagger._PERSONAL_SYL = None
|
||||||
|
heard, intended = flagger.to_syls("泰")[0], flagger.to_syls("成")[0]
|
||||||
|
self.assertGreater(flagger.syl_sim(heard, intended, personal=True), 0.90)
|
||||||
|
self.assertLess(flagger.syl_sim(heard, intended, personal=False), 0.90)
|
||||||
|
|
||||||
|
def test_graph_indexes_minutes_and_recency(self):
|
||||||
|
for sub in ("People", "Companies", "Projects", "minutes"):
|
||||||
|
(CFG.vault / sub).mkdir(parents=True, exist_ok=True)
|
||||||
|
(CFG.vault / "People" / "刘冀.md").write_text(
|
||||||
|
"---\ncompany: 张科领弋\nrole: 投资人\n---\n", encoding="utf-8")
|
||||||
|
(CFG.vault / "Companies" / "张科领弋.md").write_text(
|
||||||
|
"---\n---\n", encoding="utf-8")
|
||||||
|
(CFG.vault / "Projects" / "杭州基金.md").write_text(
|
||||||
|
"---\n---\n", encoding="utf-8")
|
||||||
|
(CFG.vault / "minutes" / "2026-07-25-test.md").write_text(
|
||||||
|
"---\ndate: 2026-07-25\npeople: [刘冀]\ncompanies: [张科领弋]\n"
|
||||||
|
"---\n[[刘冀]]参与[[杭州基金]],并与[[张科领弋]]合作。\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
result = graph.sync(force=True)
|
||||||
|
self.assertGreaterEqual(result["relations_written"], 3)
|
||||||
|
weights = graph.context_weights({"刘冀"})
|
||||||
|
self.assertIn("张科领弋", weights)
|
||||||
|
self.assertGreater(weights["张科领弋"], 0)
|
||||||
|
|
||||||
|
def test_number_normalization_is_typed_and_conservative(self):
|
||||||
|
text = "七月十日打款一千万元,BF四幺八二项目占百分之十五,共三十人,两万五九份待核。"
|
||||||
|
out, decisions = numbers.normalize(text)
|
||||||
|
self.assertIn("7月10日", out)
|
||||||
|
self.assertIn("1000万元", out)
|
||||||
|
self.assertIn("BF4182", out)
|
||||||
|
self.assertIn("15%", out)
|
||||||
|
self.assertIn("30人", out)
|
||||||
|
self.assertIn("两万五九份", out)
|
||||||
|
self.assertTrue(any(d["raw"] == "两万五九" and d["confidence"] < 0.8
|
||||||
|
for d in decisions))
|
||||||
|
|
||||||
|
def test_generic_discourse_counter_stays_chinese(self):
|
||||||
|
out, _ = numbers.normalize("我同步两个事,第一个是条款,第二个是公告。")
|
||||||
|
self.assertEqual(out, "我同步两个事,第一个是条款,第二个是公告。")
|
||||||
|
|
||||||
|
def test_financial_scale_without_currency_is_normalized_from_context(self):
|
||||||
|
out, decisions = numbers.normalize("八月份投资两千万,后续再退出。")
|
||||||
|
self.assertIn("投资2000万元", out)
|
||||||
|
self.assertTrue(any(d["kind"] == "money" for d in decisions))
|
||||||
|
|
||||||
|
def test_process_lock_remains_held(self):
|
||||||
|
handle = cli._lock()
|
||||||
|
lock_path = str(Path(TMP.name) / "asr-pipeline.lock")
|
||||||
|
code = (
|
||||||
|
"import fcntl,sys;"
|
||||||
|
f"f=open({lock_path!r},'w');"
|
||||||
|
"\ntry: fcntl.flock(f,fcntl.LOCK_EX|fcntl.LOCK_NB)"
|
||||||
|
"\nexcept OSError: sys.exit(23)"
|
||||||
|
"\nsys.exit(0)"
|
||||||
|
)
|
||||||
|
r = subprocess.run([os.sys.executable, "-c", code])
|
||||||
|
self.assertEqual(r.returncode, 23)
|
||||||
|
handle.close()
|
||||||
|
cli._LOCK_HANDLE = None
|
||||||
|
|
||||||
|
def test_staged_pipeline_smoke_without_network(self):
|
||||||
|
old = (CFG.use_termbank, CFG.use_hindsight, CFG.use_keyword_recall,
|
||||||
|
CFG.use_normalize, CFG.use_evidence)
|
||||||
|
CFG.use_termbank = False
|
||||||
|
CFG.use_hindsight = False
|
||||||
|
CFG.use_keyword_recall = False
|
||||||
|
CFG.use_normalize = False
|
||||||
|
CFG.use_evidence = True
|
||||||
|
|
||||||
|
def fake_many(_model, jobs, *, system=None, **_kw):
|
||||||
|
if system == refine.prompts.CLEAN_SYS:
|
||||||
|
return ["【谭凯】讨论洛启项目,下一步确认投资条款。" for _ in jobs]
|
||||||
|
return ["- [片段 0] 决策|洛启项目|下一步确认投资条款|置信度:明确"
|
||||||
|
for _ in jobs]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with mock.patch.object(refine, "chat_many", side_effect=fake_many), \
|
||||||
|
mock.patch.object(refine, "chat", return_value="# 粗整理\n\n确认投资条款。"):
|
||||||
|
result = refine.refine(
|
||||||
|
"【谭凯】嗯,讨论洛启项目,然后下一步确认投资条款。",
|
||||||
|
date="2026-07-26", transcript_path="/archive/test.txt")
|
||||||
|
self.assertIn("确认投资条款", result.draft)
|
||||||
|
self.assertIn("[片段 0]", result.evidence)
|
||||||
|
self.assertEqual(result.stats["n_chunks"], 1)
|
||||||
|
finally:
|
||||||
|
(CFG.use_termbank, CFG.use_hindsight, CFG.use_keyword_recall,
|
||||||
|
CFG.use_normalize, CFG.use_evidence) = old
|
||||||
|
|
||||||
|
def test_pure_phonetic_guess_does_not_interrupt_human(self):
|
||||||
|
groups = [{
|
||||||
|
"span": "也说", "settled": False, "common_word": False,
|
||||||
|
"sentence": "但是他也说他肯定不会反驳",
|
||||||
|
"candidates": [{
|
||||||
|
"target": "叶硕", "kind": "人名", "score": 0.91,
|
||||||
|
"rule": "full", "source": "lexicon-entity",
|
||||||
|
"boost": 0, "support": "",
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
self.assertEqual(_queue_ambiguous("fixture-junk", groups), 0)
|
||||||
|
|
||||||
|
def test_context_adjudicator_must_explicitly_request_human_review(self):
|
||||||
|
groups = [{
|
||||||
|
"span": "老万", "settled": False, "common_word": False,
|
||||||
|
"sentence": "老万说洛启项目。",
|
||||||
|
"candidates": [{
|
||||||
|
"target": "万亚坤", "kind": "人名", "score": 0.92,
|
||||||
|
"rule": "alias", "source": "alias-card",
|
||||||
|
"boost": 1, "support": "关系图",
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
self.assertEqual(
|
||||||
|
_queue_ambiguous("fixture-context-decided", groups,
|
||||||
|
"老万说洛启项目。"), 0)
|
||||||
|
self.assertEqual(
|
||||||
|
_queue_ambiguous("fixture-context-uncertain", groups,
|
||||||
|
"[?万亚坤]说洛启项目。"), 1)
|
||||||
|
|
||||||
|
def test_common_words_block_embedded_name_guesses(self):
|
||||||
|
cases = [
|
||||||
|
("小问", "UPS是某个小问题", ["万亚坤", "万津"]),
|
||||||
|
("用介", "包括所有的这些公用介质报告", ["邓永杰"]),
|
||||||
|
("们总", "我上次问他们总监", ["卢萌"]),
|
||||||
|
("经签", "所有方案已经签掉", ["徐劲前"]),
|
||||||
|
("统统", "三批之间统统计一下", ["薛彤彤"]),
|
||||||
|
("峰总", "雷峰总这边的开发部", ["冯昆仑"]),
|
||||||
|
]
|
||||||
|
for i, (surface, sentence, choices) in enumerate(cases):
|
||||||
|
groups = [{
|
||||||
|
"span": surface, "settled": False, "common_word": False,
|
||||||
|
"sentence": sentence,
|
||||||
|
"candidates": [{
|
||||||
|
"target": target, "kind": "人名", "score": 0.92,
|
||||||
|
"rule": "alias", "source": "alias-card",
|
||||||
|
"boost": 1, "support": "关系图",
|
||||||
|
} for target in choices],
|
||||||
|
}]
|
||||||
|
marker = f"[?{'/'.join(choices)}]"
|
||||||
|
self.assertEqual(
|
||||||
|
_queue_ambiguous(f"fixture-common-{i}", groups, marker), 0)
|
||||||
|
|
||||||
|
def test_archive_resolution_uses_content_when_timestamp_collides(self):
|
||||||
|
old_obsidian = CFG.obsidian
|
||||||
|
root = Path(TMP.name) / "archive-resolution"
|
||||||
|
archive = root / "recordings" / "transcripts"
|
||||||
|
archive.mkdir(parents=True, exist_ok=True)
|
||||||
|
inbox = root / "2026-07-27-095902-录音-2.txt"
|
||||||
|
inbox.write_text("wav transcript", encoding="utf-8")
|
||||||
|
mp3 = archive / "aaa-2026-07-27_09_59_02.txt"
|
||||||
|
wav = archive / "bbb-2026-07-27_09_59_02.txt"
|
||||||
|
mp3.write_text("mp3 transcript", encoding="utf-8")
|
||||||
|
wav.write_text("wav transcript", encoding="utf-8")
|
||||||
|
CFG.obsidian = root
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
cli.resolve_archive("2026-07-27-095902-录音-2", inbox),
|
||||||
|
str(wav))
|
||||||
|
finally:
|
||||||
|
CFG.obsidian = old_obsidian
|
||||||
|
|
||||||
|
def test_observed_neighbour_needs_more_than_recent_weight(self):
|
||||||
|
groups = [{
|
||||||
|
"span": "们七", "settled": False, "common_word": False,
|
||||||
|
"sentence": "你们七月二十号签完",
|
||||||
|
"candidates": [{
|
||||||
|
"target": "范梦奇", "kind": "人名", "score": 0.96,
|
||||||
|
"rule": "observed", "source": "observed-correction",
|
||||||
|
"via": "范梦奇", "boost": 1.2, "support": "近期权重4.0",
|
||||||
|
}],
|
||||||
|
}]
|
||||||
|
self.assertEqual(_queue_ambiguous("fixture-observed-junk", groups), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class PortabilityTests(unittest.TestCase):
|
||||||
|
def probe(self, code, overrides):
|
||||||
|
env = {**os.environ, **overrides, "PYTHONPATH": str(ROOT),
|
||||||
|
"PYTHONDONTWRITEBYTECODE": "1"}
|
||||||
|
return subprocess.check_output([sys.executable, "-c", code],
|
||||||
|
env=env, text=True).strip()
|
||||||
|
|
||||||
|
def test_workspace_and_notifier_are_explicit(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
result = self.probe(
|
||||||
|
"from asrpipe.config import CFG; print(CFG.obsidian); print(CFG.notifier)",
|
||||||
|
{"ASR_WORKSPACE": tmp, "ASR_NOTIFIER": tmp + "/notify.py"})
|
||||||
|
self.assertEqual(result.splitlines(), [tmp, tmp + "/notify.py"])
|
||||||
|
|
||||||
|
def test_watcher_does_not_assume_claude_workspace(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
result = self.probe(
|
||||||
|
"import runpy; from pathlib import Path; "
|
||||||
|
"d=runpy.run_path('recordings/bin/meeting-watch.py',run_name='probe'); "
|
||||||
|
"print(d['CONTROL_DB']); print(d['ASR_INBOX'])",
|
||||||
|
{"ASR_STATE_DIR": tmp + "/state", "ASR_VAULT": tmp + "/vault"})
|
||||||
|
self.assertEqual(result.splitlines(),
|
||||||
|
[tmp + "/state/intake.db", tmp + "/vault/.asr-inbox"])
|
||||||
|
|
||||||
|
def test_notifier_accepts_external_webhook_without_sending(self):
|
||||||
|
result = self.probe(
|
||||||
|
"import runpy; d=runpy.run_path('scripts/meeting-status-notify.py',run_name='probe'); "
|
||||||
|
"print(d['URL']); print(d['secret']() == 'synthetic-only')",
|
||||||
|
{"MEETING_STATUS_WEBHOOK_URL": "http://example.invalid/hook",
|
||||||
|
"MEETING_STATUS_WEBHOOK_SECRET": "synthetic-only"})
|
||||||
|
self.assertEqual(result.splitlines(), ["http://example.invalid/hook", "True"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -235,29 +235,42 @@ class ProviderAndServiceTests(unittest.TestCase):
|
|||||||
q2 = review.enqueue(str(t1), "第一场问题B?", {"kind": "draft_question", "candidates": ["选项B1"]})
|
q2 = review.enqueue(str(t1), "第一场问题B?", {"kind": "draft_question", "candidates": ["选项B1"]})
|
||||||
q3 = review.enqueue(str(t2), "第二场问题C?", {"kind": "draft_question", "candidates": ["选项C1"]})
|
q3 = review.enqueue(str(t2), "第二场问题C?", {"kind": "draft_question", "candidates": ["选项C1"]})
|
||||||
|
|
||||||
|
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
|
||||||
|
os.environ.pop("ASR_WEB_ALLOW_ANON", None)
|
||||||
svc_web.AGENT_TOKEN = "test-agent-token"
|
svc_web.AGENT_TOKEN = "test-agent-token"
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
|
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
|
||||||
port = server.server_address[1]
|
port = server.server_address[1]
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
try:
|
try:
|
||||||
|
import http.cookiejar
|
||||||
|
from asrpipe import webauth
|
||||||
|
jar = http.cookiejar.CookieJar()
|
||||||
|
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
|
||||||
|
login_body = urllib.parse.urlencode({"password": "test-web-password"}).encode()
|
||||||
|
opener.open(urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/login", data=login_body, method="POST",
|
||||||
|
), timeout=5)
|
||||||
|
|
||||||
def fetch(path: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
|
def fetch(path: str, headers: dict[str, str] | None = None) -> tuple[int, str]:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"http://127.0.0.1:{port}{path}",
|
f"http://127.0.0.1:{port}{path}",
|
||||||
headers=headers or {},
|
headers=headers or {},
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
with opener.open(req, timeout=5) as resp:
|
||||||
return resp.status, resp.read().decode("utf-8")
|
return resp.status, resp.read().decode("utf-8")
|
||||||
|
|
||||||
def post_form(path: str, data: dict[str, str]) -> tuple[int, str]:
|
def post_form(path: str, data: dict[str, str]) -> tuple[int, str]:
|
||||||
encoded = urllib.parse.urlencode(data).encode("utf-8")
|
payload = dict(data)
|
||||||
|
payload.setdefault("csrf", webauth.csrf_token())
|
||||||
|
encoded = urllib.parse.urlencode(payload).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"http://127.0.0.1:{port}{path}",
|
f"http://127.0.0.1:{port}{path}",
|
||||||
data=encoded,
|
data=encoded,
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
with opener.open(req, timeout=5) as resp:
|
||||||
return resp.status, resp.read().decode("utf-8")
|
return resp.status, resp.read().decode("utf-8")
|
||||||
|
|
||||||
status, body = fetch("/health")
|
status, body = fetch("/health")
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""Regression checks for the review and the 8788 black-box report."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
if "asrservice.config" not in sys.modules:
|
||||||
|
_boot = tempfile.mkdtemp(prefix="asr-harden-")
|
||||||
|
os.environ["ASR_DATA_DIR"] = str(Path(_boot) / "data")
|
||||||
|
os.environ["ASR_STATE_DIR"] = str(Path(_boot) / "state")
|
||||||
|
os.environ["ASR_VAULT"] = str(Path(_boot) / "vault")
|
||||||
|
os.environ["ASR_NOTIFY"] = "0"
|
||||||
|
os.environ["ASR_GIT_SYNC"] = "0"
|
||||||
|
os.environ["ASR_AGENT_TOKEN"] = "test-agent-token"
|
||||||
|
|
||||||
|
from asrpipe import locking, refine, review, webauth # noqa: E402
|
||||||
|
from asrpipe.config import Config # noqa: E402
|
||||||
|
from asrpipe import providers # noqa: E402
|
||||||
|
from asrservice import config as svc_cfg # noqa: E402
|
||||||
|
from asrservice import db as svc_db # noqa: E402
|
||||||
|
from asrservice import web as svc_web # noqa: E402
|
||||||
|
from asrservice import worker # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
|
||||||
|
|
||||||
|
|
||||||
|
def _opener(follow=True):
|
||||||
|
if follow:
|
||||||
|
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
|
||||||
|
return urllib.request.build_opener(_NoRedirect)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_server():
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), svc_web.Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return server, server.server_address[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _raw(port: int, payload: bytes) -> bytes:
|
||||||
|
sock = socket.create_connection(("127.0.0.1", port), timeout=5)
|
||||||
|
try:
|
||||||
|
sock.sendall(payload)
|
||||||
|
sock.shutdown(socket.SHUT_WR)
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
part = sock.recv(65536)
|
||||||
|
if not part:
|
||||||
|
break
|
||||||
|
chunks.append(part)
|
||||||
|
return b"".join(chunks)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
class HardeningTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
os.environ.pop("ASR_WEB_ALLOW_ANON", None)
|
||||||
|
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
|
||||||
|
os.environ["ASR_NOTIFY"] = "0"
|
||||||
|
os.environ["ASR_GIT_SYNC"] = "0"
|
||||||
|
svc_cfg.ensure_dirs()
|
||||||
|
svc_db.connect().close()
|
||||||
|
self._hindsight = Config.use_hindsight.__get__(svc_web.CFG, type(svc_web.CFG))
|
||||||
|
svc_web.CFG.use_hindsight = False
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
svc_web.CFG.use_hindsight = self._hindsight
|
||||||
|
|
||||||
|
def test_closed_without_password_and_csrf_and_bad_length(self):
|
||||||
|
os.environ["ASR_WEB_PASSWORD"] = ""
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
opener = _opener(False)
|
||||||
|
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||||
|
opener.open(f"http://127.0.0.1:{port}/review/", timeout=5)
|
||||||
|
self.assertEqual(caught.exception.code, 303)
|
||||||
|
self.assertIn("/login", caught.exception.headers.get("Location", ""))
|
||||||
|
page = urllib.request.urlopen(f"http://127.0.0.1:{port}/login", timeout=5).read().decode()
|
||||||
|
self.assertIn("ASR_WEB_PASSWORD", page)
|
||||||
|
raw = _raw(port, b"POST /login HTTP/1.0\r\nContent-Length: abc\r\n\r\n")
|
||||||
|
self.assertIn(b" 400 ", raw)
|
||||||
|
self.assertNotIn(b"Empty", raw)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
os.environ["ASR_WEB_PASSWORD"] = "test-web-password"
|
||||||
|
|
||||||
|
def test_pages_hide_secrets_escape_titles_and_reject_bad_edits(self):
|
||||||
|
svc_db.save_settings({
|
||||||
|
"litellm_api_key": "sk-SECRET-abcd-wxyz",
|
||||||
|
"diarize_url": "http://192.168.50.100:10099/asr",
|
||||||
|
})
|
||||||
|
jid = "abc123abc123abc123abc123"
|
||||||
|
title = "X');window.__xss=1;//"
|
||||||
|
svc_db.create_job(jid, "录音.wav", "sample.wav", title=title, size=100, source="web")
|
||||||
|
audio = svc_cfg.PROCESSED / "sample.wav"
|
||||||
|
audio.write_bytes(b"0123456789" * 10)
|
||||||
|
draft = svc_cfg.DRAFTS / f"{jid}.md"
|
||||||
|
draft.write_text("原文一行\n", encoding="utf-8")
|
||||||
|
result = svc_cfg.RESULTS / "2026-纪要.md"
|
||||||
|
result.write_text("# 纪要\n", encoding="utf-8")
|
||||||
|
svc_db.update_job(jid, "complete", "完成", draft=str(draft), result=str(result))
|
||||||
|
q1 = review.enqueue("rec-a", "第一题?", {"kind": "draft_question", "candidates": ["甲"]})
|
||||||
|
q2 = review.enqueue("rec-b", "第二题?", {"kind": "draft_question", "candidates": ["乙"]})
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
opener = _opener(True)
|
||||||
|
opener.open(urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/login",
|
||||||
|
data=urllib.parse.urlencode({"password": "test-web-password"}).encode(),
|
||||||
|
method="POST",
|
||||||
|
), timeout=5)
|
||||||
|
home = opener.open(f"http://127.0.0.1:{port}/", timeout=5).read().decode()
|
||||||
|
self.assertNotIn('onclick="delOne(', home)
|
||||||
|
self.assertIn("data-del", home)
|
||||||
|
settings = opener.open(f"http://127.0.0.1:{port}/settings", timeout=5).read().decode()
|
||||||
|
self.assertNotIn("192.168.50.100", settings)
|
||||||
|
self.assertNotIn("sk-SECRET", settings)
|
||||||
|
self.assertIn("已配置", settings)
|
||||||
|
review_html = opener.open(f"http://127.0.0.1:{port}/review/", timeout=5).read().decode()
|
||||||
|
forms = review_html.split('<form class="q-item"')
|
||||||
|
self.assertGreaterEqual(len(forms), 3)
|
||||||
|
for form in forms[1:]:
|
||||||
|
head = form.split("</form>", 1)[0]
|
||||||
|
self.assertLess(head.find('name="single_custom"'), head.find("custom_"))
|
||||||
|
missing = _raw(
|
||||||
|
port,
|
||||||
|
(
|
||||||
|
"POST /jobs/%s/meta HTTP/1.0\r\nHost: 127.0.0.1\r\n"
|
||||||
|
"Content-Type: application/x-www-form-urlencoded\r\n"
|
||||||
|
"Cookie: asr_session=%s\r\nContent-Length: 10\r\n\r\ntitle=nope"
|
||||||
|
% (jid, webauth.session_token())
|
||||||
|
).encode(),
|
||||||
|
)
|
||||||
|
self.assertIn(b" 403 ", missing)
|
||||||
|
edit = urllib.parse.urlencode({
|
||||||
|
"csrf": webauth.csrf_token(),
|
||||||
|
"target": "../../x",
|
||||||
|
"content": "x",
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=edit, method="POST",
|
||||||
|
)
|
||||||
|
body = opener.open(req, timeout=5).read().decode()
|
||||||
|
self.assertIn("未保存", body)
|
||||||
|
self.assertEqual(draft.read_text(encoding="utf-8"), "原文一行\n")
|
||||||
|
empty = urllib.parse.urlencode({
|
||||||
|
"csrf": webauth.csrf_token(), "target": "draft", "content": " ",
|
||||||
|
}).encode()
|
||||||
|
body = opener.open(urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/jobs/{jid}/edit", data=empty, method="POST",
|
||||||
|
), timeout=5).read().decode()
|
||||||
|
self.assertIn("内容为空", body)
|
||||||
|
header = (
|
||||||
|
f"POST /jobs/{jid}/edit HTTP/1.0\r\nCookie: asr_session={webauth.session_token()}\r\n"
|
||||||
|
"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 3000000\r\n\r\n"
|
||||||
|
).encode()
|
||||||
|
huge = _raw(port, header)
|
||||||
|
self.assertIn(b" 413 ", huge)
|
||||||
|
art = urllib.request.Request(f"http://127.0.0.1:{port}/jobs/{jid}/artifact/result")
|
||||||
|
with opener.open(art, timeout=5) as resp:
|
||||||
|
disp = resp.headers.get("Content-Disposition", "")
|
||||||
|
text = resp.read().decode()
|
||||||
|
disp.encode("latin-1")
|
||||||
|
self.assertIn("filename*=UTF-8''", disp)
|
||||||
|
self.assertIn("filename=\"download.md\"", disp)
|
||||||
|
self.assertIn("纪要", text)
|
||||||
|
ranged = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/jobs/{jid}/audio",
|
||||||
|
headers={"Range": "bytes=80-10"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
opener.open(ranged, timeout=5)
|
||||||
|
self.fail("reverse range should be rejected")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
self.assertEqual(exc.code, 416)
|
||||||
|
self.assertIn("bytes */100", exc.headers.get("Content-Range", ""))
|
||||||
|
suffix = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/jobs/{jid}/audio",
|
||||||
|
headers={"Range": "bytes=-10"},
|
||||||
|
)
|
||||||
|
with opener.open(suffix, timeout=5) as resp:
|
||||||
|
self.assertEqual(resp.status, 206)
|
||||||
|
self.assertEqual(resp.headers.get("Content-Range"), "bytes 90-99/100")
|
||||||
|
self.assertEqual(len(resp.read()), 10)
|
||||||
|
try:
|
||||||
|
opener.open(f"http://127.0.0.1:{port}/no-such-page", timeout=5)
|
||||||
|
self.fail("missing page should be 404")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
self.assertEqual(exc.code, 404)
|
||||||
|
self.assertIn("页面不存在", exc.read().decode())
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
review.delete_for_job(transcript_path="rec-a")
|
||||||
|
review.delete_for_job(transcript_path="rec-b")
|
||||||
|
self.assertIsNotNone(q1)
|
||||||
|
self.assertIsNotNone(q2)
|
||||||
|
|
||||||
|
def test_busy_retry_does_not_move_processing_audio(self):
|
||||||
|
jid = "fedcba9876543210fedcba98"
|
||||||
|
name = "busy.wav"
|
||||||
|
svc_db.create_job(jid, name, name, title="进行中", size=4, source="web")
|
||||||
|
audio = svc_cfg.PROCESSING / name
|
||||||
|
audio.write_bytes(b"wave")
|
||||||
|
svc_db.update_job(jid, "transcribing", "正在转写")
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
opener = _opener(True)
|
||||||
|
opener.open(urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/login",
|
||||||
|
data=urllib.parse.urlencode({"password": "test-web-password"}).encode(),
|
||||||
|
method="POST",
|
||||||
|
), timeout=5)
|
||||||
|
body = urllib.parse.urlencode({
|
||||||
|
"csrf": webauth.csrf_token(),
|
||||||
|
"title": "进行中",
|
||||||
|
"provider": "not-a-real-engine",
|
||||||
|
"next_action": "retry",
|
||||||
|
}).encode()
|
||||||
|
page = opener.open(urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/jobs/{jid}/meta", data=body, method="POST",
|
||||||
|
), timeout=5).read().decode()
|
||||||
|
self.assertIn("未重新排队", page)
|
||||||
|
self.assertIn("未知引擎", page)
|
||||||
|
self.assertTrue(audio.is_file())
|
||||||
|
self.assertFalse((svc_cfg.INBOX / name).exists())
|
||||||
|
self.assertEqual(svc_db.get_job(jid)["status"], "transcribing")
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
def test_revision_failure_keeps_marker_and_answers(self):
|
||||||
|
jid = "a1b2c3d4e5f60718293a4b5c"
|
||||||
|
transcript = svc_cfg.TRANSCRIPTS / f"{jid}.txt"
|
||||||
|
transcript.write_text("【speaker_0】原文\n", encoding="utf-8")
|
||||||
|
draft = svc_cfg.DRAFTS / f"{jid}.md"
|
||||||
|
draft.write_text("---\ntype: minutes\n---\n# 稿\n", encoding="utf-8")
|
||||||
|
svc_db.create_job(jid, "a.wav", "a.wav", title="重精修", size=1, source="web")
|
||||||
|
svc_db.update_job(jid, "review", "待确认", transcript=str(transcript), draft=str(draft))
|
||||||
|
qid = review.enqueue(str(transcript), "已确认的问题?", {
|
||||||
|
"kind": "draft_question", "draft_path": str(draft), "candidates": ["保留"],
|
||||||
|
})
|
||||||
|
review.answer(qid, "保留")
|
||||||
|
pending_id = review.enqueue(str(transcript), "仍待确认?", {
|
||||||
|
"kind": "draft_question", "draft_path": str(draft), "candidates": ["甲"],
|
||||||
|
})
|
||||||
|
with mock.patch("asrservice.worker.refine.refine", side_effect=RuntimeError("llm down")):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
worker.rerefine_job(svc_db.get_job(jid))
|
||||||
|
answered = review.recent_answered(20)
|
||||||
|
self.assertIn(qid, [row["id"] for row in answered])
|
||||||
|
self.assertIn(pending_id, [row["id"] for row in review.pending(50)])
|
||||||
|
marker = svc_cfg.REVISION_QUEUE / f"revise-{jid}.json"
|
||||||
|
marker.write_text(json.dumps({
|
||||||
|
"action": "revise", "job_id": jid, "draft": "/missing/draft.md",
|
||||||
|
}), encoding="utf-8")
|
||||||
|
worker.process_revisions()
|
||||||
|
self.assertTrue(marker.is_file())
|
||||||
|
payload = json.loads(marker.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(payload["attempts"], 1)
|
||||||
|
self.assertEqual(svc_db.get_job(jid)["status"], "review")
|
||||||
|
|
||||||
|
def test_recover_processing_and_shared_lock_and_stream(self):
|
||||||
|
jid = "00112233445566778899aabb"
|
||||||
|
name = f"stuck-{jid[:8]}.wav"
|
||||||
|
audio = svc_cfg.PROCESSING / name
|
||||||
|
audio.write_bytes(b"stuck")
|
||||||
|
svc_db.create_job(jid, name, name, title="卡住", size=5, source="web")
|
||||||
|
svc_db.update_job(jid, "transcribing", "中断")
|
||||||
|
worker.recover()
|
||||||
|
self.assertFalse(audio.exists())
|
||||||
|
parked = svc_cfg.INBOX / name
|
||||||
|
self.assertTrue(parked.is_file())
|
||||||
|
self.assertEqual(svc_db.get_job(jid)["status"], "queued")
|
||||||
|
worker.beat()
|
||||||
|
self.assertTrue((svc_cfg.STATE / "worker.heartbeat").is_file())
|
||||||
|
|
||||||
|
lock_path = Path(tempfile.mkdtemp()) / "consumer.lock"
|
||||||
|
handle = locking.acquire_recording_lock(lock_path)
|
||||||
|
try:
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
locking.acquire_recording_lock(lock_path)
|
||||||
|
finally:
|
||||||
|
handle.close()
|
||||||
|
|
||||||
|
blob = b"STREAMTEST" + b"q" * (256 * 1024)
|
||||||
|
audio_path = Path(tempfile.mkdtemp()) / "stream.wav"
|
||||||
|
audio_path.write_bytes(blob)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class Capture(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers["Content-Length"])
|
||||||
|
body = self.rfile.read(n)
|
||||||
|
seen["n"] = len(body)
|
||||||
|
seen["head"] = b"STREAMTEST" in body
|
||||||
|
seen["tail"] = body.endswith(b"q" + b"\r\n--" + body.split(b"----ASRBoundary", 1)[0][:0] or b"q")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b'{"ok": true}')
|
||||||
|
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
cap = ThreadingHTTPServer(("127.0.0.1", 0), Capture)
|
||||||
|
threading.Thread(target=cap.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
out = providers._http_multipart(
|
||||||
|
f"http://127.0.0.1:{cap.server_address[1]}/up",
|
||||||
|
fields={"model": "unit"},
|
||||||
|
file_field="file",
|
||||||
|
file_path=audio_path,
|
||||||
|
timeout=10,
|
||||||
|
retries=1,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
cap.shutdown()
|
||||||
|
cap.server_close()
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertGreater(seen["n"], len(blob))
|
||||||
|
self.assertTrue(seen["head"])
|
||||||
|
|
||||||
|
def test_wording_timezone_git_default_and_lexicon_page(self):
|
||||||
|
self.assertEqual(
|
||||||
|
refine.scrub_internal_wording("卡片未提供职务,卡片提及张三"),
|
||||||
|
"记录未写明职务,录音中提到张三",
|
||||||
|
)
|
||||||
|
stamp = datetime(2026, 11, 2, 13, 8, 26, tzinfo=timezone.utc).timestamp()
|
||||||
|
self.assertEqual(svc_web.fmt_ts(stamp), "2026-11-02 21:08:26")
|
||||||
|
header = worker.intake_header({"context": "a-->b } c"})
|
||||||
|
self.assertEqual(header.count("-->"), 1)
|
||||||
|
self.assertTrue(header.endswith("-->"))
|
||||||
|
self.assertNotIn("a-->", header)
|
||||||
|
self.assertTrue(header.startswith("<!-- intake:"))
|
||||||
|
for i in range(3):
|
||||||
|
from asrpipe import lexicon
|
||||||
|
lexicon.learn_alias(f"别名{i}", f"规范{i}", origin="manual")
|
||||||
|
from asrpipe import lexicon
|
||||||
|
self.assertGreaterEqual(lexicon.count_aliases(q="别名"), 3)
|
||||||
|
page = lexicon.list_aliases(q="别名", limit=2, offset=0)
|
||||||
|
self.assertEqual(len(page), 2)
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("ASR_GIT_SYNC", None)
|
||||||
|
env.pop("ASR_NOTIFY", None)
|
||||||
|
env["ASR_STATE_DIR"] = tempfile.mkdtemp()
|
||||||
|
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1])
|
||||||
|
out = subprocess.check_output(
|
||||||
|
[sys.executable, "-c",
|
||||||
|
"from asrpipe.config import Config; c=Config(); print(int(c.git_sync), int(c.notify))"],
|
||||||
|
env=env, text=True,
|
||||||
|
).strip()
|
||||||
|
self.assertEqual(out, "0 0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user