feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交

按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
+201 -3
View File
@@ -1,7 +1,7 @@
# eai_agentplatform 博昇 AI 数字员工平台(EAI Agent Platform)— 编码与调试最高准则
> **版本:V1.2**
> **日期:2026-09-18**
> **版本:V1.3**
> **日期:2026-09-26**
> **状态:必须强制执行 (Highest Priority)**
> **适用范围:eai_agentplatform(EAI 数字员工平台)后端(Go)、前端(Vue3)、数据库(SQLite)、AI 检索/对话、考试引擎、素材上传与审批**
> **AI 助手启动任何任务前必须先读取并确认本文件。**
@@ -17,10 +17,24 @@
> 不适用的部分——如 Python 虚拟环境、Playwright E2E、OSS 多租户——明确不抄);
> G04 补充第 6-10 条(来自两个项目共同踩过的坑);本项目新增 P06 常见技术陷阱清单。
>
> **V1.2 补充说明**:把命名前缀从「一条要求」扩成「一套规则」,全部落在 **G03**(不新开 G19,避免命名规则被拆到两处)。
> **V1.2 补充说明**:把命名前缀从「一条要求」扩成「一套规则」,全部落在 **G03**(当时不新开编号,避免命名规则被拆到两处)。
> 仍只做加法:G03 原第 1-4 条正文一字未改,只在其后新增第 5-10 条 + 关联节;标题由「变量命名锚定」放宽为「命名锚定」
> (前缀要管表名、API 路径、文件名),索引行同步更新。
> 展开与可执行化版本在 `docs/02_Architecture/AR09_对象命名规范.md` §5.7 + §6.2 守卫 G–J + §7.6。
>
> **V1.3 补充说明**:新增 **G19(外部下载物统一存放 `external_download/raw/`)**,仍只做加法。
> 起因是本地 ASR 落地时下了 4.1GB 模型与一整套 CUDA 依赖,散在 `~` 与 `/tmp` 里 ——
> 换个会话、换个人就没人知道那是什么、能不能删、要不要重下。规则要求
> 「**原始件进只读的 `raw/`(一份)+ 一份清单 + 一份 `SHA256SUMS`;
> 解压/转换/配置后的加工件放同级目录,可删可重建,没改过的大文件用软链指回 `raw/`**」。
>
> **V1.4 补充说明**:P06 增补 7 条(P06.12–P06.18),并在 P06.1 补两条延伸;仍只做加法。
> 收录标准是「**以后还会再踩**」,不是「修过一次就好了」——
> 已经修完、且不会以同样形状复发的过程性修复不收录(那些留在提交记录与代码注释里)。
> 本次来源:把仓库里散落在代码注释、架构文档、历史会话中的事故做了一次普查,
> 按「是否属于工具/语言/框架的固有性质、是否会换个场景再犯」筛过后才进来。
> 另:本地 ASR 落地中遇到的版本与依赖坑(`torchaudio>=2.9` 删 API 等)记在仓库根
> `bugs_and_errors.md`,不重复进 P06 —— 那份文件管「一次性的错」,P06 管「会复发的坑」。
---
@@ -49,6 +63,7 @@
- G16:Windows 侧 .ps1 脚本统一 UTF-8 with BOM
- G17:脚本内禁止兼容式依赖回退,必须固定单一工具链
- G18:仓库应尽量支持拷贝后直接运行
- G19:外部下载物统一存放 `external_download/raw/`(原始件只读 + `SHA256SUMS`,加工件另置同级目录),一次下载永久复用
---
@@ -382,6 +397,92 @@
---
## G19 最高原则:外部下载物统一存放 `external_download/raw/`,一次下载永久复用
> 起因:本地 ASR 落地时下了 4.1GB 模型 + 一整套 CUDA 依赖(合计约 6GB)。
> 这些文件散在 `~` 和 `/tmp` 里时,下一个会话 / 下一个人只知道「磁盘少了 6G」,
> 不知道那是什么、能不能删、要不要重下 —— 于是又下一遍。
>
> 目录形状(`raw/` 是原始件,只读;`asr-local/` 是从它生成的加工件,可删可重建):
>
> ```
> external_download/
> ├── raw/
> │ └── asr-local/ 原始件 ★ 磁盘上只有这一份
> │ ├── README.md 来源 / 许可 / 清单
> │ ├── SHA256SUMS 逐文件校验和
> │ ├── models/ 模型权重(下下来那一刻的字节)
> │ └── wheels/ 依赖的 .whl(同属原始件)
> └── asr-local/ 加工件:解压 / 转换 / 配置后的可用件
> └── models/ 权重按原样引用;被改写过的 config.yaml 放这里
> ```
1. **一切从外部下载的原始文件必须放仓库根的 `external_download/raw/`**:模型权重、wheel、
数据集、字体、预编译二进制、第三方发行包都算。禁止散落在 `~`、`/tmp`、`/opt`
或各人自己的家目录里 —— 那些位置要么会被清理(`/tmp`),要么别人找不到(`~`)。
2. **原始件与加工件分开:`raw/` 只放下载下来的那套,解压 / 转换 / 配置的结果放 `raw/` 之外的
同级目录**(`external_download/raw/<批次>/` ↔ `external_download/<批次>/`)。
一个批次一个子目录,命名带用途与来源。
`raw/` 是**只读区** —— 里面存的必须是「下载下来那一刻」的字节,
任何程序都不得就地改写、解包、改名或生成中间产物。
加工件必须**可丢弃、可重建**(一条脚本能从 `raw/` 再生成一遍),否则它就成了第二份真本,
两边一旦不一致就没人说得清哪份是对的。
**大文件不复制**:加工件里没被改过的权重用软链指回 `raw/`,别为了「看起来完整」把 4GB 存两遍 ——
存两遍的结果是两份都会漂移。只有真正被改写过的文件(如指向 HF repo id 的 `config.yaml`
要改成本地路径)才在加工件里落成真实文件。
3. **每个批次必须有一份清单**(`raw/<批次>/README.md`,加工件目录里也放一份说明它是什么、
怎么重建),写明:
**下载时间、来源站点 / repo ID、版本或 commit、文件清单与大小、许可**。
没有清单的下载物按不可用处理 —— 没人知道它是什么,就没人敢删也没人敢用。
4. **每个批次必须有一份校验和清单**(`raw/<批次>/SHA256SUMS`),由下载/导出脚本在收尾时自动重算,
并可用 `sha256sum -c SHA256SUMS` 一条命令自证。**"文件还在"不等于"文件还是当初那份"** ——
被覆盖、拷坏、下到一半续传错位,只有校验和能发现(关联 G04:完成判定靠事实,不靠印象)。
校验和只算 `raw/`;加工件可重建,不需要单独记账。
5. **禁止重复下载**:动手下之前先 `ls external_download/raw/`。已有就用已有的。
真需要新版本时,**新开一个批次子目录**(`raw/` 与加工目录各一个),不要覆盖旧的
(关联 G11:不可静默修改)。
6. **下载脚本必须幂等且必须校验**:已存在且大小正确的跳过;每个文件校验
**HTTP 状态码 + 落盘字节数**,不完整即失败并报出是哪个文件。
禁止「下不到就跳过」式的静默兜底(关联 G02)。
7. **依赖要能离线重建**:Python 依赖除装进虚拟环境外,还须用 `pip download` 导出
wheelhouse 到该批次的 `raw/<批次>/wheels/`(`.whl` 是依赖的原始件,和模型同级)。
虚拟环境本身是加工件,留在工作区即可 —— 有 wheelhouse 就能离线重建它。
只留一个虚拟环境,换机器就得重下。
8. **`external_download/` 不进 git**:在仓库根 `.gitignore` 排除。它是本机资产库,不是源码;
体积以 GB 计,提交一次就永久留在历史里(关联 G14.5 提交前扫大文件)。
9. **不得放密钥**:清单里写来源 URL,不写 token;需要鉴权的下载把凭据放环境变量
(关联 G01 脱敏红线、P06.10)。
10. **许可必须记录并核对**:引入任何外部素材前先读 LICENSE,把许可写进清单。
特别注意:**HF 上的 gated(需登录/接受条款)不等于许可变更**,但通过镜像绕过 gated
拿到的文件,其许可要与官方来源核对后再写进清单(关联 P06.11)。
11. **与交付物划清边界**:`external_download/` 是本机开发资产,**不是交付物**。
交付走 `eai_agentplatform/backend-go/deploy/`(D14 单二进制 + systemd + 整盘克隆);
整盘克隆时本目录随镜像一起过去,这正是它要放仓库根、让脚本能定位到的原因。
### 反例与正例
| | 反例 ❌ | 正例 ✅ |
|---|---|---|
| A | `~/models/whisper/` 下 3GB 权重,无清单,作者离职后无人敢动 | `external_download/raw/asr-local/models/` + 同目录 `README.md` 写明来源与许可 |
| B | 换个会话发现「好像下过」,不确定,于是又下 3GB | 先 `ls external_download/raw/`,命中即复用 |
| C | 下载脚本 `curl -o f ... \|\| true`,静默跳过半个文件 | 校验 HTTP 码 + 字节数,不对就报出文件名并整体失败 |
| D | 只装了 venv,换机器重装时又拉一遍 2GB CUDA 库 | 同批次目录里带 wheelhouse,`--no-index --find-links` 离线重装 |
| E | 在 `raw/` 里就地解包、改名、跑脚本生成中间文件 | `raw/` 只读;加工结果写到同级目录,且一条脚本能重建 |
| F | 文件都在,就认为「还是当初那份」 | `sha256sum -c SHA256SUMS` 全 OK 才算数 |
| G | 为了「保险」把 4GB 权重在 raw 和加工目录各存一份,之后两边不一致 | 加工件里的权重用软链指回 `raw/`,只有改过的文件才落真实副本 |
### 关联
- 关联 G02:下载不完整是错误状态,不是可跳过状态
- 关联 G04:完成判定靠事实 —— 「文件还在」不等于「还是当初那份」,用 `SHA256SUMS` 自证
- 关联 G10:路径不写死在代码里,**由脚本从仓库根派生**
- 关联 G11:新版本新目录,不覆盖旧的
- 关联 G14:提交前扫大文件与密钥,本目录整目录排除
- 关联 G18:拷到新机器能跑,前提就是依赖有本地来源
- 关联 P06.11:引入第三方素材前先读 LICENSE
---
# 第二部分:eai_agentplatform 项目专用规则
> **适用范围**:仅适用于博昇 AI 数字员工平台项目。
@@ -492,6 +593,15 @@
- **后果**:以为"改了 model 就完事",实际老列一直留在库里,代码与库结构悄悄分叉。
- **正确做法**:加字段 → 确认 model 在 `AutoMigrate` 列表里;删字段 / 改类型 → 写一次性迁移,或明确保留旧列并在文档里写明原因。
- **验证方式**:**改库结构后必须在数据库副本上跑一遍升级探测**,确认列真的加上、seed 真的填对,再动真库(动用户数据前先备份,见 P06.7)。
- **延伸一 · 删列会先撞上索引**:SQLite **拒绝删除仍被索引引用的列**,而 GORM AutoMigrate
**只建新索引、不清理旧模型遗留的索引**(如 `idx_skill_definition_role_kind`)。
于是手写的 `DROP COLUMN` 会以 `error in index ... after drop column` 失败,
**直接让 `store.Init` 崩在启动** —— 不是迁移没生效那么温和,是服务起不来。
正确做法:删列前先 `dropIndexesOnColumn` 把该列上的索引摘掉(`store/db.go` 的 `dropColumn` 已内置)。
- **延伸二 · 全局 `sed` 改名会改坏迁移本身**:改名时禁止 `sed -i 's/worker_/task_/g'` 式的全局替换 ——
`migrateLegacyTaskRuntimeSchema` 和它的迁移测试里出现的旧名**是必须保留的**
(它们的工作就是「认旧名、迁到新名」),改掉之后迁移永远不生效,而且没有任何报错。
判据一句话:**看这行是在「改」名字还是在「用」名字** —— 改名字的留旧名,用名字的改新名。
### P06.2 一次性探测脚本用完即删,别留成测试
@@ -557,12 +667,100 @@
- **规则**:该目录目前已不在仓库中;**不得重新引入,也不得改写成"我们的 pdf 技能"**。需要 PDF 能力时自己实现或选许可允许的方案。
- **引申**:引入任何第三方素材/代码前,先读它的 LICENSE——"能用"和"能合法地用并交付"是两件事。
### P06.12 同一步的产物必须取「最后一次」的 run —— 否则用户对着作废产物点确认
- **事实**:取某一步的产出时如果用 `find()` / `[0]` 取**第一条** run,重跑之后「产出」区
仍然指向**上一版**。用户对着一份已经作废的名单点确认,而**界面上完全看不出这是旧的**。
- **后果**:不是显示错误,是**用户基于过期内容做了决定**。公众号面板与专员面板各自踩过一次。
- **正确做法**:取**同一步最后一次**跑出来的那条 run(`latestRunOfActionKey` / `latestArtifactOfStep`)。
凡是「同一步可重跑」的流程都适用,不限于这两个面板。
- **配套**:后端的闸门也不能只看「历史上有没有确认过」——那会拿**上一版**的身份去写新稿子;
闸门必须锚定到当前生效的那一版(`audio_speakers_gate_test.go` 有对应断言)。
- **为什么**:「重跑」在本平台是常态操作,而不是异常路径。
### P06.13 备份会自己毁掉备份 —— 三条自毁路径
- **事实**:内置备份(D24)有三处会让「保护数据的机制」反过来损害数据:
1. **主库不存在或是空的,仍然去备** —— 会拿一份**空快照顶掉保留窗口里的好备份**
(被人误删、或还没初始化时最危险,那时恰恰最需要旧备份)。正确做法:主库为空直接跳过。
2. **每次启动都备** —— 进程反复重启(crashloop)时每次产一份新备份,**把保留窗口撑爆**,
反而把有价值的旧备份挤出去。正确做法:走 `BackupIfDue`,按间隔补齐。
3. **解析备份文件名时去找「-2」** —— 时间戳 `20260914-200000` **本身就含 `-2`**。
正确做法:时间戳长度固定,按**长度**切前一段(`raw[:len(backupLayout)]`)。
- **为什么**:备份是「出事之后才用」的东西,它的失败**在正常路径上完全看不见**,
等真要用的时候才发现窗口里全是空快照 —— 这是最典型的「看起来完成了,其实没有」(G04)。
### P06.14 上游返回 HTTP 200 不等于这次调用成功
- **事实**:两种「200 但没用」的报文都真实出现过:
- **网关回 200,报文里却没有 `choices`**。实测背景:某技能第 3 步要连打 11 次模型
(逐字稿按 1200 字分块),**第 4 次**撞上这个,整步就此失败。
- **`finish_reason=length` + 半截正文** —— 改之前是**原样返回、落库成产物**,
用户拿到一份被截断的稿子,且系统认为它成功了。
- **正确做法**:
- 判成功要判**报文结构**,不能只判状态码;
- **重试与否必须由类型决定,不能由文案猜**:模型确实写不出正文(预算被思考吃光、拒答)
重发一百次都是同一结果,重试只是再花一次钱 —— 这类用 `EmptyCompletionError`;
协议/链路抖动(无 choices、限流、上游 5xx、连接被掐断)重发有机会成 —— 用 `TransientUpstreamError`。
- 超长导致的截断必须**报错**,报错要带上路由名与该路由的 `max_tokens`(否则不知道往哪调)。
- **引申 · 计费的真实性**:转写技能曾有「无论成败都写 `Success: true`」,**用量与计费都是假的**。
凡是要计点/计量的调用,成功标志必须来自真实结果,不能来自「函数返回了」。
### P06.15 同步长任务的超时上限,就是整套流程的真实长度上限
- **事实**:`audioSkill.js` 里 structure/minutes 的超时值**不是随手填的护栏,是这套流程的长度天花板**。
实测一条 26:41 的录音(11936 字 → 10 块):**全程 589.54s**,
原来的 5 分钟上限**已经被吃到 ≥89%**,稍慢一点就会被前端自己掐断。
- **后果**:前端超时了,**但后端还在跑、产物也已经落库** ——
用户看到的却是「失败」,然后重跑一次,产出一份重复的。
- **正确做法**:把超时当**容量参数**对待,按实测最长耗时的余量来定,不要凭感觉写整数;
更长的输入应当走异步(G12),而不是继续加超时。
- **为什么**:这是「前后端对同一件事的成功判定不一致」,与 G04 同源。
### P06.16 授权边界必须「失败即最小权限」
- **事实**:两条真实存在、且**方向相反**的边界约定:
- `GetByIDForOwners`:**owners 为空时查不到任何东西(而不是查到全部)** ——
归属标识缺失时必须退化成「什么都看不到」,**绝不能反过来退化成「看所有人的」**。
- 前端 `deleteTaskCascade`(按 id 级联删、**不校验归属**)与 `deleteMyTask`
(限定 owner、不级联)**两条不能合并**:侧边栏对所有人可见,
改调前者等于**给任何登录用户一个按 id 删任意任务的口子**;工作台只对管理员开放,
用不校验归属的那条才是它原本的语义。
- **正确做法**:权限参数缺失/为空时,默认落到**权限最小**的那一侧;
两条语义不同的删除路径**不要为了「统一」而合并**。
- **为什么**:这类代码在正常路径上永远是对的,只有边界输入才透光 —— 而边界输入正是攻击者的入口。
### P06.17 整盘克隆会把原型机的历史数据带到客户机上
- **事实**:原型机的 `data/backups/` 里会有**原型机自己的历史数据**(测试账号、演示素材)。
交付走整盘克隆(D14)时,这些会被**原样带到客户机器上**。
- **正确做法**:`clonezilla-cleanup.sh` 清理时一并**删空 `data/backups/`**,让客户机从干净状态开始。
- **为什么**:「删了主库」不等于「删了数据」—— 备份目录是数据的第二份副本,
清理脚本漏掉它,等于数据没清干净就交付了。
### P06.18 过滤条件与判定逻辑必须共用一份实现
- **事实**:`resolveChunkMeta` 只认已审批的来源(素材 `status=approved`、知识源 `audit_status=approved`)。
上游批量加载元信息时,**如果传了空 map,所有带 `media_file_id` / `knowledge_source_id` 的分片
会被整段丢弃** —— 检索候选集只剩「无指针」的那些,**不报错,只是结果悄悄变少**。
- **正确做法**:过滤条件与内部判定**共用同一个方法**(`ApprovedMetaMaps()`),
两处各自手写过滤条件必然漂移;且「过滤结果为空」与「本来就没有」必须能区分开。
- **为什么**:这是「两处实现同一个判据」的必然结局 —— 与 P06.12 / P06.14 同源:
**静默的错误比报错难查一个量级**。
### 关联
- 关联 G04:这些坑的共同点是"看起来完成了,其实没有"——完成判定必须靠事实
- 关联 G11 / P04:数据可追溯、只补空字段,同属"不可静默修改"
- 关联 G12:异步任务的失败与进度不能靠 `worker_run.status`
- 关联 G14:提交前扫密钥与大文件(P06.10)
- 关联 D14:交付走整盘克隆,清理必须覆盖 `data/backups/`(P06.17)
- **P06.12–P06.18 的共同形状**:失败不报错,只是**结果悄悄变少或变旧** ——
过期产物、空快照、无 `choices` 的 200、被丢弃的分片。
遇到「结果看着对但就是不对」时,先按这几条查,别从头推。
- **收录标准**:本清单只收「**以后还会再踩**」的坑 —— 工具/语言/框架的固有性质、
或换个场景就会复发的失误模式。**已经修完且不会以同样形状复发的过程性修复不收录**
(那些属于提交记录与代码注释)。一次性的报错、版本事故记在 `bugs_and_errors.md`。
---