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
@@ -1,4 +1,4 @@
# 交付手册(Go 单二进制 + Clonezilla 整盘克隆)
# 交付手册(静态单二进制 + 本地语音转写 + Clonezilla 整盘克隆)
> 面向交付工程师。目标:把一台配置好的原型机整盘克隆到客户同型号机器,
> 客户拿到手即用,机器上**无源代码、无开发痕迹、无测试数据、无默认口令**。
@@ -12,13 +12,35 @@
| `eai_agentplatform-server` | 单二进制(CGO 关闭,静态链接,约 38MB,无任何运行时依赖) |
| `eai_agentplatform.service` | systemd 单元文件 |
| `eai_agentplatform.env` | 环境变量样例 |
| `eai_agentplatform-asr.service` | 本地语音转写服务的 systemd 单元(**需要 Python,见下**) |
| `asr.env` | 本地语音转写服务的环境变量样例 |
| `clonezilla-cleanup.sh` | 克隆前清理脚本(DRY-RUN 默认) |
| `data/eai_agentplatform.db` | SQLite 数据库(首启自动建表) |
| `data/kb_data/` | 已审批素材目录 |
| `assets/knowledge/source/` | 知识源 Markdown(待入库,由管理员审批) |
| `assets/training/materials/` | 培训资料资产(课程种子、PDF、视频、脚本) |
**不需要**:Go 运行时、Python、Docker、MySQL、任何 pip/npm 依赖。
**不需要**:Go 运行时、Docker、MySQL、任何 npm 依赖。
> **平台本体确实不需要 Python**:`eai_agentplatform-server` 是静态单二进制,
> `ldd` 输出 `not a dynamic executable`,这条一直成立。
>
> **但语音转写是另一个进程,它需要。** 转写默认走本机(faster-whisper large-v3 +
> pyannote 3.1),由一个独立的 Python 服务(`eai_agentplatform-asr.service`,
> 监听 127.0.0.1:8090)提供。客户机器上因此会多出:
>
> | 组件 | 体积 | 说明 |
> |---|---|---|
> | `/opt/eai_agentplatform-asr/venv` | 约 9.4 GB | Python 环境(含 CUDA 版 PyTorch) |
> | `/opt/eai_agentplatform-asr/models` | 约 4.1 GB | 模型权重,随整盘克隆带过去,不重新下载 |
>
> 合计约 **13.5 GB**,交付前请确认目标机磁盘够用。
>
> 这**不是**「为了本地化而放弃轻交付」——恰恰相反:把转写留在本机,是为了不让
> 客户会议的录音被送到公网 ASR。此处与「单二进制交付」的出入是明知并接受的取舍,
> 记在案。若某台客户机确实装不了 Python,可以把默认语音路由切到云端
> (后台 → AI 路由 → 默认语音路由),代价是**录音会离开本机**,
> 界面在转写结果上会明确标注「音频已离开本机」。
---
@@ -38,8 +60,23 @@
│ ├── backups/ # 定期备份(服务自动维护,见第 7 节)
│ └── kb_data/ # 素材 + 提取缓存
└── (前端静态资源由 Nginx 托管,见 ../docs/02_Architecture/部署文档.md)
/opt/eai_agentplatform-asr/ # 本地语音转写(独立进程,独立单元)
├── serve.py # OpenAI 兼容 HTTP 端点(/v1/audio/transcriptions、/v1/models)
├── asr_core.py # 转写 + 说话人分离
├── asr.env # 环境变量(精度档位、离线开关)
├── venv/ # Python 环境(约 9.4 GB)
├── models/ # 模型权重(约 4.1 GB,实体目录)
│ ├── Systran/faster-whisper-large-v3/
│ └── pyannote/{segmentation-3.0,wespeaker-voxceleb-resnet34-LM,speaker-diarization-3.1}/
└── cache/ # 运行时缓存(HF_HOME 指到这里)
```
> `serve.py` 按 `脚本所在目录/models` 找模型,所以 `models/` 必须是它的同级目录
> (符号链接也行,但交付机上建议放实体目录,少一层依赖)。
> 原型机上 `~/asr-poc/models` 是指向仓库 `external_download/` 的软链 —— 那是开发期的
> 组织方式,克隆到客户机之前必须把权重**拷成实体目录**,客户机上没有仓库。
---
## 2. 交付前准备(原型机)
@@ -66,8 +103,34 @@ sudo sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$NEW_SECRET|" /opt/eai_agentplatform/.e
sudo systemctl daemon-reload
sudo systemctl enable --now eai_agentplatform
curl http://127.0.0.1:8080/api/health # {"status":"ok",...}
# 2.5 本地语音转写服务(ASR)
# 环境本身随原型机整盘克隆过来,这一步只是把它装到交付位置 + 起服务。
# venv 与模型都很大(9.4G + 4.1G),用 mv 而不是 cp —— 两台机器上各留一份没意义。
sudo mkdir -p /opt/eai_agentplatform-asr
sudo cp -a ~/asr-poc/{serve.py,asr_core.py} /opt/eai_agentplatform-asr/
sudo mv ~/asr-poc/venv /opt/eai_agentplatform-asr/venv
sudo cp -aL ~/asr-poc/models /opt/eai_agentplatform-asr/models # -L 解开软链,客户机上没有仓库可指
sudo install -m 0644 deploy/asr.env /opt/eai_agentplatform-asr/asr.env
sudo install -m 0644 deploy/eai_agentplatform-asr.service /etc/systemd/system/
sudo chown -R eai_agentplatform:eai_agentplatform /opt/eai_agentplatform-asr
sudo mkdir -p /opt/eai_agentplatform-asr/cache && sudo chown eai_agentplatform:eai_agentplatform /opt/eai_agentplatform-asr/cache
sudo systemctl daemon-reload
sudo systemctl enable --now eai_agentplatform-asr
# 2.6 验证:模型加载要几十秒,起来之前 /v1/models 是连不上的
systemctl is-active eai_agentplatform-asr # active
curl -s http://127.0.0.1:8090/v1/models | head -c 200
# 再确认平台侧认它:后台 → AI 路由 → 语音转写,应显示「本地转写 · Whisper large-v3」
```
> **转写模型与平台是强耦合的吗?** 不是。平台启动时读 `config/ai_config.json`,
> `audio_route_local_whisper` 指向 `http://127.0.0.1:8090/v1`。ASR 服务没起来
> **不影响平台启动**:转写会自动回退到 `fallback_routes` 里的云端路由,
> 并在转写产物的日志与界面警示区写明「已回退到云端,音频已离开本机」。
> 这正是让 ASR 单独成一个 systemd 单元(而不是塞进主进程)的原因。
---
## 3. 克隆前清理(不可逆,务必先 DRY-RUN)
@@ -81,6 +144,8 @@ sudo bash clonezilla-cleanup.sh --confirm # 确认后真实清理
- 删除所有 Go 源代码(`cmd/` `internal/` `go.mod` `go.sum`)
- 清除 `.git` / 版本控制痕迹
- 清空日志与 shell 历史
- 删除本地 ASR 工作区里的**原型机数据**:`~/asr-poc/audio`(真实会议录音)、
`~/asr-poc/out`(完整逐字稿与说话人分离结果,含一份云端转写对照稿)、安装日志
脚本**不**负责(需手工确认,见下):
@@ -91,6 +156,7 @@ sudo bash clonezilla-cleanup.sh --confirm # 确认后真实清理
| A | **重置管理员密码** | `sudo -u eai_agentplatform /opt/eai_agentplatform/eai_agentplatform-server -reset-admin '<强密码>'` |
| B | **确认 JWT 密钥随机** | `grep -c '__CHANGE_ME__' /opt/eai_agentplatform/.env`(应为 0) |
| C | **清空测试数据** | Web 后台删除演示用户/素材/考试记录,或用 sqlite3(见第 4 节) |
| D | **删掉 ASR 工作区,只留一份 venv** | 装好 `/opt/eai_agentplatform-asr` 并确认服务能起后,`rm -rf ~/asr-poc`(清理脚本会给提示,不代删) |
> 默认种子账号为 `admin / admin123`,**克隆前必须改密**(A 项),否则客户拿到默认口令。
@@ -138,6 +204,10 @@ SQL
- [ ] `.bash_history` 已清空、无 `.git`
- [ ] 测试账号(zhangsan 等)、演示素材、演示考试记录已删除
- [ ] `data/backups/` 里没有**原型机自己的**数据备份(见第 7 节末)
- [ ] `~/asr-poc/audio`、`~/asr-poc/out` 已删(真实录音与逐字稿)
- [ ] 全盘只有一份 `venv`(在 `/opt/eai_agentplatform-asr`),`~/asr-poc` 已删
- [ ] `/opt/eai_agentplatform-asr/models` 是实体目录,不是指向仓库的软链
- [ ] `systemctl is-active eai_agentplatform-asr` 为 `active`,且转写可选到「本地转写」路由
---
@@ -0,0 +1,30 @@
# 本地语音转写服务(eai_agentplatform-asr.service)的环境变量样例。
#
# 装机:sudo cp deploy/asr.env /opt/eai_agentplatform-asr/asr.env
# 端口不在这里配 —— 它写在 unit 的 ExecStart 上(--port 8090),
# 与后端 config/ai_config.json 里 audio_route_local_whisper.base_url 的 8090
# 是同一个值。改端口要同时改这两处,否则后端起不来时界面会显示
# 「本地路由不健康」,而真实原因是对不上端口。
# 这里注释掉的那行只是记录这个耦合,别指望它生效:
# ASR_PORT=8090
# 计算精度。8G 显存要和 llama-server 共用,float16 会 OOM(原型机实测,
# 见 bugs_and_errors.md E06)。int8_float16 是这台机器的可用档位。
ASR_COMPUTE_TYPE=int8_float16
# 模型目录**不在这里配**:serve.py 按 `脚本所在目录/models` 找,也就是
# /opt/eai_agentplatform-asr/models(软链或实体目录都行,见 DELIVERY.md 第 1 节)。
# 把模型放到别处就得改 asr_core.py,不如把目录放对。
# 离线开关。权重全部是本地文件(faster-whisper 直接读目录、pyannote 的
# config.yaml 已改写成本地文件路径),正常情况下一个网络请求都不发。
# 这两个开关是安全网:万一哪条分支想回 huggingface.co,本机到那边是**不通**的,
# 表现会是挂起几分钟而不是报错 —— 加上开关,它立刻失败,日志里一眼能看出来。
HF_HOME=/opt/eai_agentplatform-asr/cache/huggingface
HF_HUB_OFFLINE=1
TRANSFORMERS_OFFLINE=1
# 故意没有 PYTORCH_CUDA_ALLOC_CONF 之类的分配器开关:这台机器的显存配置
# (int8_float16 + 转写前后释放模型)是实测调出来的,见 bugs_and_errors.md E06。
# 没在这台机器上验过的旋钮不要加进来 —— 加错了表现是 OOM,
# 而 OOM 看起来又像「模型太大」,很容易往错误的方向查。
@@ -46,7 +46,42 @@ echo " 或在克隆前用 sqlite3 执行(见 DELIVERY.md 第 4 节)。脚
echo " - 别漏了 $APP_DIR/data/backups/ :里面是原型机自己的历史快照,含同一批测试数据,"
echo " 整盘克隆会一起带到客户机器上。确认无用后手工清空该目录(见 DELIVERY.md 第 7 节)。"
step "4. 清空日志与 shell 历史"
step "4. 清理本地 ASR 工作区的原型机数据(录音与逐字稿)"
ASR_DIR="/opt/eai_agentplatform-asr"
# 为什么这一步必须做,而不是「顺手清个缓存」:
# 原型机的 ~/asr-poc/audio/ 里是**真实会议录音**,~/asr-poc/out/ 里是它的完整逐字稿
# 与说话人分离结果(还有一份云端转写的对照稿)。整盘克隆会把它们原样带到客户机器上。
# 与 data/backups/ 同一类问题:不是程序数据,是原型机自己的业务内容。
for ws in /home/*/asr-poc; do
[[ -d "$ws" ]] || continue
echo " 工作区: $ws"
for sub in out audio __pycache__; do
[[ -e "$ws/$sub" ]] || continue
echo " 将删除: $ws/$sub(录音 / 逐字稿 / 缓存)"
[[ $DRY -eq 0 ]] && rm -rf "$ws/$sub"
done
# requirements.lock.txt 留着:它是这套环境精确版本的唯一记录,
# 属于离线重建的工具件,不是开发痕迹(venv 里 pip freeze 也能再生成)。
for f in "$ws"/*.log; do
[[ -e "$f" ]] || continue
echo " 将删除: $f"
[[ $DRY -eq 0 ]] && rm -f "$f"
done
done
# 工作区本身能不能删,取决于它是否已经**装到**交付位置。
# 删早了服务就起不来,而且没有源码可以重建 —— 所以只提示,不自动删。
if [[ -d "$ASR_DIR" ]]; then
echo " 已安装到交付位置:$ASR_DIR"
echo " 确认下面这些都在,就可以删掉 ~/asr-poc(其中 venv 有 9G,别在两处各留一份):"
echo " $ASR_DIR/serve.py $ASR_DIR/asr_core.py $ASR_DIR/venv/ $ASR_DIR/models/ $ASR_DIR/asr.env"
echo " 删之前先确认服务能起来:systemctl restart eai_agentplatform-asr && curl -sI http://127.0.0.1:8090/v1/models"
else
warn " 未发现 $ASR_DIR —— 本地 ASR 还没装到交付位置,先别动 ~/asr-poc。"
fi
step "5. 清空日志与 shell 历史"
for f in "$APP_DIR/logs" "$APP_DIR"/*.log /var/log/eai_agentplatform*.log; do
if compgen -G "$f" >/dev/null 2>&1; then
echo " 将清空: $f"
@@ -60,7 +95,7 @@ for h in /root/.bash_history /home/*/.bash_history; do
fi
done
step "5. 提示:以下必须手工完成(脚本无法替你决定口令)"
step "6. 提示:以下必须手工完成(脚本无法替你决定口令)"
cat <<'EOF'
[A] 重置管理员密码:
sudo -u eai_agentplatform /opt/eai_agentplatform/eai_agentplatform-server -reset-admin '<新强密码>'
@@ -70,13 +105,14 @@ cat <<'EOF'
grep -n '__CHANGE_ME__' /opt/eai_agentplatform/.env && echo '!! 存在未替换占位符' || echo '无占位符'
EOF
step "6. 移除本清理脚本自身(交付物不含脚本)"
step "7. 移除本清理脚本自身(交付物不含脚本)"
echo " 将删除: $0"
[[ $DRY -eq 0 ]] && rm -f "$0"
echo
if [[ $DRY -eq 0 ]]; then
log "清理完成。请复核 [A][B][C] 三项,再执行 Clonezilla 整盘克隆。"
warn "本地 ASR 工作区(~/asr-poc)是否已删、是否只剩一份 venv,请手工确认(见上面第 4 步的提示)。"
else
warn "DRY-RUN 结束,未做任何修改。确认后:sudo bash $0 --confirm"
fi
@@ -0,0 +1,40 @@
[Unit]
Description=eai_agentplatform 本地语音转写服务(faster-whisper large-v3 + pyannote 3.1)
Documentation=file:/opt/eai_agentplatform-asr/OFFLINE.md
After=network.target
Wants=network.target
[Service]
Type=simple
User=eai_agentplatform
Group=eai_agentplatform
WorkingDirectory=/opt/eai_agentplatform-asr
EnvironmentFile=-/opt/eai_agentplatform-asr/asr.env
ExecStart=/opt/eai_agentplatform-asr/venv/bin/python /opt/eai_agentplatform-asr/serve.py --host 127.0.0.1 --port 8090
# 模型加载要几十秒,第一次失败多半是显存被占(和 llama-server 抢 8G 卡)。
# 不要因为一次失败就让本地转写彻底消失 —— 后端起不来时平台会回退云端,
# 而用户并不知道自己失去了「音频不出本机」,所以这里必须一直重试。
Restart=always
RestartSec=10
# 交付专用设备:最小权限加固。
# 与 eai_agentplatform.service 的差异有两处,都是必需的:
# 1) 没有 PrivateDevices=true —— 它会隐藏 /dev/nvidia*,本服务要跑 CUDA,一设就起不来;
# 2) ReadWritePaths 指向自己的 cache,venv 与模型目录保持只读。
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/eai_agentplatform-asr/cache
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
# 显存只有 8G,还要和 llama-server 共用。杀进程时给它留出释放显存的时间,
# 否则紧接着的重启会撞上「显存没还回来」而再失败一次。
KillMode=mixed
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target