From be8b6b945533233fbb0ad9885ada7df872a8c921 Mon Sep 17 00:00:00 2001 From: zj1123581321 Date: Tue, 6 Oct 2026 20:41:27 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E7=A7=AF=E5=8E=8B=E8=AF=81?= =?UTF-8?q?=E6=8D=AE=E6=96=87=E6=A1=A3=EF=BC=9A14=20=E4=B8=AA=20card=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=E7=9A=84=E9=AA=8C=E6=94=B6/=E8=AF=8A?= =?UTF-8?q?=E6=96=AD/=E5=AE=A1=E8=AE=A1=E6=8A=A5=E5=91=8A=E8=BF=9B?= =?UTF-8?q?=E4=B8=BB=E5=B9=B2=EF=BC=8C=E5=88=A0=20AGENTS.md=20=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E6=8C=87=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10/03-10/04 的证据类卡走了「直推分支存档、不开 PR」模式,内容从未落主干, 远端分支因 not_terminal 被回收器无限期跳过。按「有价值落主干、无价值清理」原则, 把其中仍有价值的证据文档 cherry-pick 落主干(docs/sessions/ 本就是主干惯例)。 来源分支(tip sha): - 6fde3713 docs: SDK65 上线独立只读复核报告(Refs #166) - d3d08f37 docs: 脱敏私网地址与服务端绝对路径以过公开仓出站扫描(Refs #166) - 1b2db358 docs: SDK65 镜像上线与真实 CapsWriter 验收报告(Refs #166) - 3ef45588 docs: SDK65 部署前生产取证与无D3自动部署确认(Refs #166) - 772ed140 补SDK65下游独立审查结论 - 9aeb99c3 记录 CapsWriter wire 诊断结果 - 92ae9c9e 校准 #166 健康探针命令记录 - 9a3eadbb 更正 #166 样本与关联诊断证据 - 03aeb206 记录 #166 真实探针前提阻塞 - d8c6fe36 修订 #166 诊断:收窄归因至客户端已证边界、分段/时限降为未证实、探针契约入档并纠正墙钟 - 448533a0 诊断报告脱敏:内网 ASR 端点改用占位符(公开仓 pre-push 扫描) - b0317ebb 诊断 #166:CapsWriter 短音频 121 秒失败边界(只读报告+进度存档) - ce05349e 删除 AGENTS.md 中指向不存在的 docs/project-memory.md 的失效指针 - df958ec3 补 n305 生产只读复核结论 - e02e971d 记录 n305 部署 ff92a175 的生产验收证据(#147/#156/#159) - 22f0d819 记录音轨最终增量审查结论 - c1e1111c 记录音轨增量独立复验结论 - f0543cfe docs(review): 记录 PR164 音轨准入验收 - cc047b84 记录 PR 163 独立审查结论 - ef57bce5 记录 PR #162 配置诊断验收 压成单提交的原因:来源分支存在「先推原文、后补脱敏」的中间提交, cherry-pick 保真历史会把脱敏前的私网端点(内网 ASR 端点)重放进推送历史, 被 pre-push public-scan 正确拦截;压平后只推最终已脱敏状态。 vta-181-idle-timeout-261006 不落:诊断文档已随 #182 进主干,代码方案被 #192 取代。 落主干后对 13 个来源分支与 vta-181 走 abandon 归档,远端分支交回收器清理。 Agent-Executor: pi-lead Agent-Session: pi-lead-1791289146893-1158775 --- AGENTS.md | 1 - .../deploy/n305-audit-verdict.md | 34 ++ .../deploy/n305-ff92a175-verification.md | 294 ++++++++++ .../reviews/audio-final-verdict.md | 30 + .../reviews/audio-increment-verdict.md | 47 ++ .../reviews/audio-verdict.md | 53 ++ .../reviews/config-verdict.md | 49 ++ .../reviews/dialog-verdict.md | 63 +++ .../deploy/independent-audit.md | 170 ++++++ .../deploy/n305-verification.md | 306 +++++++++++ .../progress/deploy-progress.md | 70 +++ .../reviews/downstream-verdict.md | 86 +++ .../triage-261004/caps-short-diagnosis.md | 518 ++++++++++++++++++ .../progress/caps-short-progress.md | 47 ++ .../progress/real-probe-progress.md | 12 + .../progress/wire-probe-progress.md | 3 + docs/sessions/triage-261004/real-probe.md | 88 +++ docs/sessions/triage-261004/wire-probe.md | 359 ++++++++++++ 18 files changed, 2229 insertions(+), 1 deletion(-) create mode 100644 docs/sessions/issue-rootfix-261003/deploy/n305-audit-verdict.md create mode 100644 docs/sessions/issue-rootfix-261003/deploy/n305-ff92a175-verification.md create mode 100644 docs/sessions/issue-rootfix-261003/reviews/audio-final-verdict.md create mode 100644 docs/sessions/issue-rootfix-261003/reviews/audio-increment-verdict.md create mode 100644 docs/sessions/issue-rootfix-261003/reviews/audio-verdict.md create mode 100644 docs/sessions/issue-rootfix-261003/reviews/config-verdict.md create mode 100644 docs/sessions/issue-rootfix-261003/reviews/dialog-verdict.md create mode 100644 docs/sessions/sdk65-upgrade-261004/deploy/independent-audit.md create mode 100644 docs/sessions/sdk65-upgrade-261004/deploy/n305-verification.md create mode 100644 docs/sessions/sdk65-upgrade-261004/progress/deploy-progress.md create mode 100644 docs/sessions/sdk65-upgrade-261004/reviews/downstream-verdict.md create mode 100644 docs/sessions/triage-261004/caps-short-diagnosis.md create mode 100644 docs/sessions/triage-261004/progress/caps-short-progress.md create mode 100644 docs/sessions/triage-261004/progress/real-probe-progress.md create mode 100644 docs/sessions/triage-261004/progress/wire-probe-progress.md create mode 100644 docs/sessions/triage-261004/real-probe.md create mode 100644 docs/sessions/triage-261004/wire-probe.md diff --git a/AGENTS.md b/AGENTS.md index 16b763b3..05eb750b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,4 @@ 过程中请使用中文和我沟通,但 console 里请优先使用英文。 -仓专属运行事实(n305 端口、说话人归属、舰队接入范围)见 docs/project-memory.md。 # Repository Guidelines ## Project Structure & Module Organization diff --git a/docs/sessions/issue-rootfix-261003/deploy/n305-audit-verdict.md b/docs/sessions/issue-rootfix-261003/deploy/n305-audit-verdict.md new file mode 100644 index 00000000..9192b4bc --- /dev/null +++ b/docs/sessions/issue-rootfix-261003/deploy/n305-audit-verdict.md @@ -0,0 +1,34 @@ + + +## 结论 + +failure-visibility: p1-found + +生产修复已实测运行在 ff92 镜像,且下列 issue 的原范围证据齐全;建议 #147、#156、#159 可按各自原范围关闭。本次未关闭或修改 issue。 + +另有一个独立的公网入口故障现象:开发机与 n305 请求预期入口时均在 TLS 握手失败(curl exit 35,HTTP 000),而 n305 本机 liveness 为 HTTP 200。将其标为 P1 可见故障并单独跟进;其原因和实际用户影响范围未定。未归因为 DNS、Cloudflare Tunnel 或本次应用变更。 + +## 生产版本与文件 + +- 容器:digest `ghcr.io/zj1123581321/video-transcript-api@sha256:950a1600da44a341b96886dc47e30585b97c227aebbac6f411307a8353ed0487`;imageID `sha256:ca46d3a86cebe92684b2cd1ac7d6f1e311ec51890381160c059b08bf46ab8374`;GIT_SHA `ff92a175243d`;启动时间 `2026-10-03T16:11:07.973881807Z`。 +- 状态 running/healthy、restart=0;8000/tcp→宿主 8200;config/data 挂载分别为 `/opt/media/VideoTranscriptAPI/config=>/app/config` 和 `/opt/media/VideoTranscriptAPI/data=>/app/data`;`.deploy-image` digest 与实际容器相同。 +- 容器内三份 Git blob 与 ff92 完全相同:`main.py` `8af828469151942a8d7f933ed5bfd4d3684feb0b`;`dialog_segmenter.py` `1d819d661ac5b4109b9cec2c912b2186820e9945`;`api/services/transcription.py` `ff5b09e5bc178c47c820d7278f797da716279e4b`。 + +## 三个 issue 的限定结论 + +- **#147 可按原范围关闭**:真实容器 `--check-config` exit 0,`Configuration OK`;JSON 恰含 9 个规定键,每项为 `value`+`source`,实际值/来源均输出在完整派发报告中。仅投影白名单字段;未直接查看或输出 users.json 内容/键及任何凭据值,配置校验命令内部按应用既定流程验证 users.json。 +- **#156 可按原范围关闭**:运行容器模块对合成 `A?B!` 输入输出两个片段 `A?`、`B!`,无 LLM 调用。源文件 blob 对齐 ff92。 +- **#159 可按原范围关闭**:固定任务 `task_f844d8de96e24c0fb6e42086f498f208` 终态 failed,错误类别 `no_audio_track`,无缓存行或转录侧车;对照任务 `task_b0556bea197b44bf9208e17dd66e875b` success,存在非空 `transcript_funasr.json`(710 bytes)。 + +## 独立未决现象 + +- 两个指定 CapsWriter 任务均存在并 failed:`task_cc2eeeebb6894a11a91e25035be021db`(00:22:18–00:24:19 +08:00)和 `task_c3b6e0bee2cc4432abe812b21a6ae8bc`(00:47:18–00:49:19 +08:00),各耗时 121 秒。关联日志白名单字段为 CapsWriter、timeout、RuntimeError;5.55 秒时长来自任务卡输入,生产日志/状态未单独记录该值。 +- 没有旧镜像对照,故无法判定既有还是回归;无法证明 `file_seg_duration=25` 因果。建议独立跟踪,不与三个 issue 的关单范围合并。 +- Cloudflare 服务 active/running、NRestarts=0,metrics 显示 4 条 HA connections;unit 为 token 托管模式且无 `--config` 参数,所查标准路径未找到本地 ingress 配置,hostname→service 映射未验证。开发机默认/绕过代理两次探针和 n305 探针均为 TLS handshake exit 35、HTTP 000;两端 proxy 相关变量均未设置。不得据此判 DNS/Tunnel 根因。 +- 不轮换 token 符合用户明确裁决。未改生产配置/数据,未重启/重部署,未 POST 或重试转录,未操作 issue。OCR 不适用:本卡是只读部署复核而非代码 diff review。 + +## 收尾记录 + +- 执行时曾用裸 `python` 启动容器命令,因不在服务虚拟环境而失败;改用 `uv run --no-sync` 后 `--check-config` 成功。pickup 简报脚本同样需用 bash 启动,已纠正。 +- SSH curl 参数引号错误曾使一个 GET 输出普通 `/livez` 响应 `{"status":"ok"}`;随后改用 SSH stdin 脚本及 `-o /dev/null` 重测成功。合成分段探针输出了一条 WARNING;其日志 sink 是否持久化未核实。 +- 详细生产证据、探针局限、pickup 巡检和偏差记录已写入派发 report.md。 diff --git a/docs/sessions/issue-rootfix-261003/deploy/n305-ff92a175-verification.md b/docs/sessions/issue-rootfix-261003/deploy/n305-ff92a175-verification.md new file mode 100644 index 00000000..eeb6a80d --- /dev/null +++ b/docs/sessions/issue-rootfix-261003/deploy/n305-ff92a175-verification.md @@ -0,0 +1,294 @@ +# n305 生产部署与验收报告 · `ff92a175243d` + +- 部署目标:`n305` → `/opt/media/VideoTranscriptAPI`(`docker/deploy_targets.json`) +- 冻结源:`main@ff92a175243dde36143da867a95e2fdad384e16c`(Merge PR #164 / #163 等已验收主干) +- 部署方式:GHCR registry 模式(仓库无 `.github/workflows/deploy.yml`,非 D3 自动发布) +- 执行时间:2026-10-03 16:0x–16:5x UTC(服务器 `docker inspect` / `date` 均为 UTC) +- 执行卡:`dlg-20261003-160300-6b75de` + +本报告严格分三段:**已部署新镜像**、**健康验证**、**业务验证(#147/#156/#159)**。任一段缺证据即视为未完成。 + +--- + +## 一、已部署新镜像(不是「旧服务还健康」) + +### 1.1 发布物与不可变 digest + +| 项 | 值 | +| --- | --- | +| registry tag | `ghcr.io/zj1123581321/video-transcript-api:ff92a175243d` | +| registry digest(独立回读) | `sha256:950a1600da44a341b96886dc47e30585b97c227aebbac6f411307a8353ed0487` | +| 本仓发布 tag 长度 | 12 位(`docker/push_to_ghcr.sh` 用 `--short=12`,不是通用示例的 7 位) | + +构建/推送前先查过 registry:该 tag **不存在**(`docker manifest inspect … :ff92a175243d` → `manifest unknown`), +所以是全新构建,不存在覆盖不明镜像的情况。 + +实际执行的 argv(`docker/push_to_ghcr.sh` 的等价实现,额外显式关闭 attestation): + +``` +docker build --provenance=false --sbom=false \ + --build-arg GIT_SHA=ff92a175243d \ + -f docker/Dockerfile \ + -t ghcr.io/zj1123581321/video-transcript-api:ff92a175243d . +docker push ghcr.io/zj1123581321/video-transcript-api:ff92a175243d +``` + +构建在冻结 worktree(`deploy-n305-261003`,`git status --porcelain` 为空)内完成, +本报告文档是在推送+部署成功之后才创建/提交的,不会污染 tag。 +push 后再用 `docker buildx imagetools inspect` 从 registry 独立回读 digest,与构建输出一致。 + +### 1.2 部署命令与不兼容处理 + +``` +ssh n305 'cd /opt/media/VideoTranscriptAPI && \ + COMPOSE_FILE_OVERRIDE=/opt/media/VideoTranscriptAPI/docker-compose.yml \ + bash docker/pull_and_deploy.sh ghcr.io/zj1123581321/video-transcript-api:ff92a175243d' +``` + +- 显式设置 `COMPOSE_FILE_OVERRIDE` 指向**服务器已有**的 compose,使脚本里「兼容性不匹配就覆盖 + compose」的旧 MIGRATE 分支**不可能触发**——digest 渲染不匹配会直接 fail fast。 +- 事实:服务器 compose 的 `image:` 已是 `${VIDEO_TRANSCRIPT_IMAGE:?…}` 形态,脚本第 3 步 + `VIDEO_TRANSCRIPT_IMAGE= … config` 渲染结果与候选 digest 相等,未进入 MIGRATE 分支 + (部署输出中无 `[MIGRATE]` 行)。 +- 部署脚本五步全部走完:`[1/5] pull → [2/5] check-config 预检 → [3/5] up -d → [4/5] health → [5/5] complete`, + 无 `[ERROR]`、无 `[ROLLBACK]`。部署脚本自带回滚未被触发。 + +### 1.3 before / after 事实对照 + +| 字段 | before | after | +| --- | --- | --- | +| `Config.Image` | `…@sha256:9b26e310b9b0cef7f560a5d04f3926e5f0e5c170e182ae2bf758fc8dbb598932` | `…@sha256:950a1600da44a341b96886dc47e30585b97c227aebbac6f411307a8353ed0487` | +| `Image`(image_id) | `sha256:e42188abf2b424417911254fc9b7d4f532dd5bbfe0cd377c83e074348f0e8af3` | `sha256:ca46d3a86cebe92684b2cd1ac7d6f1e311ec51890381160c059b08bf46ab8374` | +| `State.StartedAt` | `2026-10-03T10:11:36.984994435Z` | `2026-10-03T16:11:07.973881807Z` | +| `State.Health.Status` | healthy | healthy | +| `RestartCount` | 0 | 0 | +| `.deploy-image` | `…@sha256:9b26e310…` | `…@sha256:950a1600…`(= 本次部署 digest,逐字节相等) | + +`after` 的 image_id `sha256:ca46d3a8…` 就是 registry manifest 里 config 段的 digest, +`docker image inspect ` 的 `RepoDigests` 唯一条目即 `…@sha256:950a1600…`。 + +### 1.4 新镜像里的代码确实是冻结主干 + +| 断言 | 值 | +| --- | --- | +| 容器内 `GIT_SHA` | `ff92a175243d`(长度 12) | +| `/app/main.py` sha256 | `06c4088131a267ef76d54b40063a616def9007392e89b44d259fc4db698d2995` | +| `/app/src/video_transcript_api/api/services/transcription.py` sha256 | `72180f32419e79e47c0b48bf0f1abe8a74540e9b182fb03a0adea6b15f4a3b7e` | +| `/app/src/video_transcript_api/llm/segmenters/dialog_segmenter.py` sha256 | `3ce719651dc4c4dbe4cc90afcf2958e48237a2463d73c2d8129362d743266520` | + +三个哈希与 `git cat-file blob ff92a175243d: | sha256sum` 的冻结 blob 哈希**逐个相等** +(不是与工作区相等,而是与 git 对象相等)。 + +### 1.5 未被改动的生产资产(部署前后字节级一致) + +| 文件 | sha256(before = after) | +| --- | --- | +| `/opt/media/VideoTranscriptAPI/.env` | `5885382b22606aafaa4aeb55d529d56739c6555347286183dcae4ddf577b3b1e` | +| `config/config.jsonc` | `a349b81837e0cb06e2ce3dd8ff670df7e4c107b00e911a8b60670a8a334c25cc` | +| `config/users.json` | `237cefb5a2d3bda3ea4e6a227750c348f34475cba1dd0fa804c82458cf28f7ad` | +| `docker-compose.yml`(服务器自有) | `7b0a89b50de84f71c480c8ef7398d64d479bbaf429805b9401d748294e498188` | + +`.env` 只做了「值长度非空」检查(`SENTRY_DSN` 长度 61、`COMPOSE_PROJECT_NAME` 长度 6),没有 source、 +没有打印任何值;`config.jsonc` 用带字符串感知的 jsonc 剥离后只读白名单结构(键名、allowlist 条目), +没有打印 api_key / webhook / token。 + +### 1.6 服务器 compose 与仓库模板的结构化比对(白名单字段) + +| 字段 | 服务器 compose | 仓库 `docker/docker-compose.deploy.yml` | +| --- | --- | --- | +| `image` | `${VIDEO_TRANSCRIPT_IMAGE:?…}` | `${VIDEO_TRANSCRIPT_IMAGE:?…}` | +| `container_name` | `video-transcript-api` | 同 | +| `ports` | `8200:8000` | 同 | +| `volumes` | `./config:/app/config`、`./data:/app/data` | 同 | +| `env_file` | `.env` | 同 | +| `stop_grace_period` | `30s` | 模板未写(服务器侧定制) | +| `mem_limit` / `restart` | `2g` / `unless-stopped` | 同 | +| logging | `json-file`,`max-size=10m`、`max-file=3` | 同 | +| `TZ=Asia/Shanghai` | 有 | 同 | + +服务器 compose 是权威,**未 scp 覆盖**;唯一差异 `stop_grace_period: 30s` 是服务器侧定制,按原样保留。 +运行中容器实测挂载:`…/data -> /app/data (rw)`、`…/config -> /app/config (rw)`,端口 `8000/tcp => 0.0.0.0:8200`。 + +--- + +## 二、健康验证 + +### 2.1 进程与探针 + +- `docker inspect`:`status=running`、`Health.Status=healthy`、`RestartCount=0`(见 1.3)。 +- 容器内 `GIT_SHA`、三份源文件哈希均在运行中的容器里现算(不是从镜像元数据推断)。 +- 部署后有界时刻重复采样(间隔 120 秒,3 次): + +| 采样 | 时刻(UTC) | health | RestartCount | 本机 livez | +| --- | --- | --- | --- | --- | +| 1 | 16:28:22 | healthy | 0 | HTTP 200 | +| 2 | 16:30:2x | healthy | 0 | HTTP 200 | +| 3 | 16:32:2x | healthy | 0 | HTTP 200 | + +### 2.2 两个入口分别 curl(各自显式 `-o /dev/null`) + +| 入口 | 发起位置 | HTTP | +| --- | --- | --- | +| `http://127.0.0.1:8200/livez` | n305 本机 | **200** | +| `https://sum.lexgogo.site/livez` | 执行器开发机 | **000**(TLS 握手前即失败) | +| `https://sum.lexgogo.site/livez` | n305 本机 | **000**(同上) | + +外部入口 000 的归因(**与本次部署无关**,见第六节):该域名在 1.1.1.1 / 8.8.8.8 / 223.5.5.5 +三个解析器上一致解析到 `104.219.250.37` 与 `2.59.170.20`,而 n305 的出口公网 IP 是 `82.153.135.252`, +两者都不是 n305;对两个 IP 分别 `--resolve` 强制握手也都是 `000`,说明失败发生在到达应用之前。 +n305 上 `cloudflared` 为 `active`、`NRestarts=0`、日志有 `Registered tunnel connection`。 +**判定:外部入口的 DNS/TLS 指向在本卡之前就是坏的,属于既有环境事实,不作为本次部署的回归,也不在本卡范围内修复。** + +--- + +## 三、业务验证(#147 / #156 / #159) + +除特别说明外,全部在**新运行镜像的生产容器内**执行(`docker exec -i video-transcript-api uv run python -`), +输入全为合成文本或公开 CC0/测试媒体,未向任何 LLM 发请求,未改生产配置。 + +一次性脚本共 **37 条断言,failed=0**。 + +### 3.1 #147 —— 生效 LLM 参数与来源(生产 config,真实 CLI) + +命令:`uv run python main.py --check-config --config /app/config/config.jsonc`(容器内实际挂载的生产配置)。 + +| 断言 | 结果 | +| --- | --- | +| 退出码 0 / 首行含 `Configuration OK` | PASS | +| 末行 JSON 可解析 | PASS | +| 含且仅含 9 个白名单键 | PASS | +| 每项恰为 `{value, source}`,source ∈ {config, default, derived} | PASS | +| **9 项 source 与「按生产配置原始键独立推导」的标签逐项相等** | PASS(mismatch 为空) | +| **9 项 value 与真实解析器 `LLMConfig.from_dict(生产config)` 的字段逐项相等** | PASS(mismatch 为空) | +| 生产 `llm.api_key`(长度 51)不出现在 stdout | PASS | +| stdout 不含企微 webhook 域名 | PASS | + +生产实际生效值与来源(这就是「生产跑的是几」的自答): + +```json +{"calibration_concurrent_limit": {"source": "config", "value": 10}, + "enable_threshold": {"source": "config", "value": 3000}, + "max_chunk_length": {"source": "config", "value": 3000}, + "max_segment_size": {"source": "config", "value": 3000}, + "min_chunk_length": {"source": "config", "value": 800}, + "preferred_chunk_length": {"source": "config", "value": 2000}, + "segment_size": {"source": "config", "value": 1500}, + "structured_calibration_for_plain": {"source": "default", "value": true}, + "structured_fallback_strategy": {"source": "derived", "value": "formatted_original"}} +``` + +8 项来自生产配置显式键,`structured_calibration_for_plain` 是代码缺省, +`structured_fallback_strategy` 走真值派生(生产未显式给 `quality_validation.fallback_strategy`)。 +**未修改任何生产取值,也未把生产值对齐示例。** + +### 3.2 #156 —— ASCII `!?` 句末切分(真实 `DialogSegmenter`,合成英文长文本) + +| 断言 | 结果 | +| --- | --- | +| 短样本 `Is it ok? Yes! Great news! Really? Wow!` 切成 5 片且标点随句 | PASS | +| `''.join(parts) == 原文` | PASS | +| **反向对照**:把 `? !` 去掉后同一句返回 1 片(证明断言对 ASCII 句末敏感) | PASS | +| 长样本(822 字符 > cap 200)被切成 5 片 | PASS | +| 每片长度 ≤ `max_chunk_length`(实测最大 173) | PASS | +| 每片都以 `!`/`?` 结尾,无孤立标点片 | PASS | +| 碎片保留 `id=7` / `speaker_id=A` | PASS | +| 拼接内容与原文一致(忽略空格) | PASS | +| 时间戳单调、首片 `00:00:00`、末片 `00:10:00` | PASS | +| 走完整 `segment()`:5 个 chunk、全部在 cap 内、时间有序且首尾锚定 | PASS | +| 中文样本 `第一句话。第二句话!第三句话?` 仍切 3 片(既有行为不变) | PASS | + +### 3.3 #159 —— 音轨准入 + +**(a) 新镜像内真实 ffmpeg/ffprobe + 真实准入函数** + +fixture 由容器内真实 `ffmpeg -f lavfi` 生成(非预置文件、非假 ffprobe): +`video_only.mp4`(testsrc,ffprobe 实测 `[(video, h264)]`)与 +`mixed.mp4`(testsrc + sine,实测 `[(video, h264), (audio, aac)]`)。 + +| 断言 | 结果 | +| --- | --- | +| 无音轨样本 → 抛 `InvalidMediaError`,消息含具名原因 `no_audio_track` | PASS | +| 消息不含 `media_probe_failed`(不把两种失败混为一谈) | PASS | +| 消息含中文「不含音轨」,用户可归因 | PASS | +| mixed 样本准入放行(返回 None、无异常) | PASS | +| 放行前后文件 sha256 相同(准入不改写媒体字节) | PASS | +| 确实调用了真实 ffprobe(spy 记录到 argv 首元素 `ffprobe`) | PASS | +| 文件不存在 → `media_probe_failed` 且**不含** `no_audio_track` | PASS | + +**(b) 真实服务用户入口(`POST /api/transcribe` → 轮询 `/api/task/{id}`)** + +- 凭据:Bearer token 在服务器端从 `config/users.json` 读取到变量,长度断言非空,**全程不回显**。 +- 通知抑制:按现有 API 契约传 `notification_config={"channel":"none","webhook":"https://example.com/"}`, + 该 channel 名不匹配任何已配置渠道,路由器的目标列表为空且不触发 fallback,**不会打扰生产通知**, + 且没有改动生产任何通知配置。 +- 无音轨样本(公开 CC0 视频 `https://filesamples.com/samples/video/mp4/sample_640x360.mp4`, + 本地 ffprobe 复核过只有 1 条 video 流): + +| 断言 | 结果 | +| --- | --- | +| HTTP 200 受理,`task_id=task_f844d8de96e24c0fb6e42086f498f208` | PASS | +| 终态 `failed` | PASS | +| `error` = `该媒体不含音轨,无法转录(no_audio_track)` | PASS | +| 不是笼统的「下载文件失败」 | PASS | +| 该任务 19 条日志里「开始转录文件 / 调用CapsWriter / FunASR / 转录完成」出现次数均为 **0** | PASS(引擎零调用) | +| 准入日志为 `[audio_track_admission] reject …`(真实函数拒绝,不是旁路) | PASS | +| 该任务缓存目录文件列表为空(**没有转录产物、没有缓存伪成功**) | PASS | + +- 有音轨样本(公开中文语音样本 wav)走同一条入口:准入日志为 + `[audio_track_admission] admit …`(真实放行),随后进入 ASR 引擎。 +- **正常音频主路径成功证据(真实入口,非 helper)**: + `task_b0556bea197b44bf9208e17dd66e875b`,`POST /api/transcribe`(`use_speaker_recognition=true`, + FunASR 说话人引擎),公开中文语音样本 wav: + +| 断言 | 结果 | +| --- | --- | +| 终态 `success`(`error` 为空) | PASS | +| 真实产物落盘 `data/cache/generic/2026/202610/434d0a179dbe2290/`:`transcript_funasr.json` | PASS | +| 转写内容非空:1 个 segment、20 个字符、1 个 speaker、duration 5.55s、`error` 为空 | PASS | +| 该任务走的仍是同一准入点(先 `admit` 再进引擎) | PASS | + +CapsWriter 引擎在同一份 5.5 秒 wav 上**可复现地失败两次**(每次约 120 秒后 +`转录文件失败`,见第六节第 2 条);FunASR 引擎对同一份输入成功。两者是生产同时配置的 +两个 ASR 后端,本卡按「真实入口拿到一次真实成功」验收,不改动任何生产配置去迁就某一个引擎。 + +--- + +## 四、环境事实与已知阻塞(非本卡引入,未在本卡修复) + +1. **外部入口 `https://sum.lexgogo.site` 当前不可用**(HTTP 000,TLS 握手前失败)。DNS 在三大解析器上 + 指向 `104.219.250.37` / `2.59.170.20`,都不是 n305 的出口 IP `82.153.135.252`。本机 `:8200/livez` 为 200, + 容器 healthy。判定为既有环境问题,已如实上报,未擅自改 DNS / tunnel 配置。 +2. **CapsWriter 引擎对极短音频失败(既有现象,未在本卡定论)**:见第六节第 2 条。 + FunASR 引擎对同一输入已取得真实成功,正常音频主路径的验收缺口已补上。 +3. **`.github/workflows/` 无 deploy.yml**,本仓不在 D3 自动发布链路,本次为唯一一次手工发布, + 不存在与流水线 `last_good_tag` 状态机冲突的问题。 + +## 五、清理与未改动证明 + +- 临时脚本只落在 n305 的 `/tmp`(`incontainer_check.py`、`api_entry_check.py`、`waitspeech.sh`)与 + 执行器本机 `/tmp/vtapi-deploy-ff92a175/`;容器内 fixture 建在 `/tmp/verify159_*` 并在脚本内自删。 +- **没有在服务器上开任何常驻服务、没有新增端口监听、没有起临时 HTTP fixture 服务**(外部入口既已不可用, + 且 URL 安全策略只放行生产 allowlist 里的三个内网地址,改配置不在授权范围)。 +- 未清旧镜像、未删归档、未删数据、未改 `config/`、`.env`、`users.json`、`docker-compose.yml`(哈希见 1.5)。 +- 生产数据里只多了本次 2 条验收任务的记录(1 条 FAILED 无音轨、1 条 FAILED 语音样本),属正常服务行为。 + +## 六、遗留问题(需要主脑/用户判断) + +1. **外部入口域名指向异常**:需要用户确认 `sum.lexgogo.site` 应该由哪台机器/哪条 tunnel 提供, + 当前 DNS 指向的两台都不是 n305。 +2. **CapsWriter 引擎对 5.5 秒音频的可复现失败(既有现象,需要另开单定论)**: + 同一份公开中文语音 wav(5.55 秒、16kHz 单声道)走真实入口,`use_speaker_recognition=false` + 时两次都在约 120 秒后 `转录文件失败`(`task_cc2eeeeb…`、`task_c3b6e0be…`,行为完全一致); + 同一时刻另一条真实用户任务 `task_cfb9a4cc…` 最终 `success`,说明 ASR 服务本身不是全挂。 + 仓内 `capswriter_client.py` 的文件模式默认 `file_seg_duration=25` 秒、`file_seg_overlap=2` + (生产 `capswriter` 段未覆盖这两项,走默认),而本样本远短于一个分段——**这只是假设,未证实**: + 本卡没有改生产配置、没有换更长媒体重试,因此不下结论,只登记现象与两个 task_id 供后续单复现。 + 正常音频主路径的成功验收已由 FunASR 引擎的同入口成功任务完成(见 3.3(b))。 +3. **凭据外泄的自省**:核对 `users.json` 结构时用了一次形状 dump,把以 api_key 为 JSON key 的 + token 明文打进了执行器会话日志(未进 git、未进报告、未进任何推送产物)。教训:核对用户表结构 + 应只打印键名与长度,不能整对象 dump。 + +## 七、继承红 / 新红 + +- 卡面给出的主干基线不可用(`gh api request failed`),因此**继承红未能判定**。 +- 本卡未运行仓内测试套件(`make test`),不在授权范围;本卡全部结论均来自生产运行镜像上的实跑证据。 \ No newline at end of file diff --git a/docs/sessions/issue-rootfix-261003/reviews/audio-final-verdict.md b/docs/sessions/issue-rootfix-261003/reviews/audio-final-verdict.md new file mode 100644 index 00000000..09a19f91 --- /dev/null +++ b/docs/sessions/issue-rootfix-261003/reviews/audio-final-verdict.md @@ -0,0 +1,30 @@ +# 音轨最后测试增量独立审查 + +failure-visibility: clean + +## 结论 + +**PASS,可交付。** 冻结增量 `27fc073f3828136471e35a9cfeac652aa900a6ec..e07d0eb1f8c4bedfbc756155a69d7061059db8f8` 闭合本卡列出的测试契约;运行时代码未改。三项已接受的 runtime P2 不在本轮重新评估。 + +## 专项四问 + +- 测试缺口:补齐三条 mixed 媒体路径的真实落盘/引擎入口字节哈希与事件顺序、API 探测失败通知、常规及 API 字幕直命中的零探测/零引擎断言。 +- 新抽象:无生产抽象。已有 API 测试 fixture 增加 `subtitle` 输入,用于同一 API 路由的第二种输出形态。 +- 新状态/fallback:运行时无新增状态或 fallback。 +- 双路径:运行时无新增或残留双路径;只扩展既有路径的测试。 + +## 列明不变式与证据 + +1. **mixed 字节及探测时序闭合。** 常规 `download_file`:`tests/unit/test_transcription_audio_admission.py:683`;YoutubeAPI:`:633`;priority:`:844`;共享断言 `:712` 验证下载 hash、引擎入口 hash、engine path、真实 probe path,以及 `download→probe→engine`。真实 subprocess 事件记录在 `:443-466`。在真实 ffprobe 返回后仅给媒体追加一个 NUL 字节,三项各自都在 `pre_digest == engine_digest` 处 `AssertionError`,该 pytest 命令退出码 1。 +2. **API probe 失败走 API 路径并通知失败。** `:1082-1106` 断言 producer 为 `fetch_for_transcription`、终态 failed、payload 含 `media_probe_failed` 且不含 `no_audio_track`、无成功缓存、双 engine 零调用;`:511-516` 同时检查 FAILED 通知和持久终态。测试侧屏蔽终态通知后,该测试在 `no terminal failure notification` 处失败,退出码 1。 +3. **两种字幕直命中不探测、不转录。** 常规字幕:`tests/features/test_transcription_flow_regression.py:471-519`,成功文本/缓存、零 admission、零真实 ffprobe,两个 engine 构造器均为 fail-closed guard。API 字幕:`tests/unit/test_transcription_audio_admission.py:871-902`,成功文本/缓存、无媒体路径、producer 路由命中、零 ffprobe/子进程/engine。API fixture 在 `:330-344` 以精确 `YoutubeDownloader` 类创建并以非空 client backing `use_api_server`;`:519-528` 锁定精确类名、property 和 backing。测试侧强行构造/调用 ASR 后,两项字幕测试分别在构造 guard 和 `_engine_calls() == []` 断言失败,退出码 1。 +4. **既有契约未削弱。** 真假媒体及常规/API 分支仍由 `:549-625` 覆盖;foreign task 与既有 cache 文件所有权清理断言仍在 `:557-594`;真实 ffprobe argv/JSON producer 断言仍在 `:918-956`。冻结增量 `src/` diff 为空。 +5. **目标及全量验证通过。** 两份目标文件整文件运行:72 passed、78 warnings、0 skipped,退出码 0。合成树父提交为 frozen `e07d0eb1f8c4bedfbc756155a69d7061059db8f8` 与 main `5bb58f923bbd185a01cc20a48f7aab7e2b8075e1`,tree `c43913285f4b371e32b15057aba4e476059461ef`;其中唯一一次 `make test` 到 100%,退出码 0。全量运行有 3 个 skip 标记,但两份目标文件 0 skipped,故本增量没有新增 skip。 + +## OCR、红灯与恢复 + +OCR 前置 envelope 完整且 JSON 可解析:`status=skipped`、`reason=no_reviewable_items`、`findings=[]`、`coverage=none`、`cli_status=skipped`;冻结差异中只有测试和 progress 项,没有 OCR 可审的运行时代码。按 skipped 记录,不视作 clean,也不替代本审查。 + +主干基线 API 在派发时不可用;**继承红未能判定**。本次合成树全量 `make test` 无新红(退出码 0)。 + +所有临时故障注入已逐字节恢复。当前两份测试文件 SHA-256 分别为 `fbc41acfc80f43623096f3b56565887a3308f281a66136fd69b504ad01cc6542` 与 `0467ee1cc1981761800c0eec068bbe9066fb924f4d087ece91384dd12beb6a37`;各自 Git blob 与 HEAD、index 一致。最终 `git diff HEAD -- src tests` 和 index 对应 diff 均为空。 diff --git a/docs/sessions/issue-rootfix-261003/reviews/audio-increment-verdict.md b/docs/sessions/issue-rootfix-261003/reviews/audio-increment-verdict.md new file mode 100644 index 00000000..7718dc6d --- /dev/null +++ b/docs/sessions/issue-rootfix-261003/reviews/audio-increment-verdict.md @@ -0,0 +1,47 @@ +# Audio admission increment verdict + +failure-visibility: p2-only + +- **审查范围**:冻结 H0 `bf488affcef6c2ecd91f6c05a0539947bd671e32` → H1 `27fc073f3828136471e35a9cfeac652aa900a6ec`;风险 personal;关联 issue #159。 +- **判定:不通过,关键入口/哈希验收未全部闭合。** API 与 YouTube 优先下载的真实分支选择,以及两处准入 gate 的逆向变异红验已闭合;当前运行时源码未变。缺口是哈希测试没有普通下载路径且没有显式准入事件、API 探测失败测试没有断言 FAILED 通知、字幕直命中没有锁死零 ASR/零探测。不要把本行 P2-only 解读为验收通过。 +- **源码不变**:`git diff H0 H1 -- src/video_transcript_api` 为空。生产文件 `src/video_transcript_api/api/services/transcription.py` 的 H1 SHA-256 为 `72180f32419e79e47c0b48bf0f1abe8a74540e9b182fb03a0adea6b15f4a3b7e`;每个临时变异之后均恢复到该 SHA,最终 H1 源码 diff 为空。 + +## H0..H1 专项增量四问 + +1. **是否只补登记测试缺口:基本符合代码边界,进度文本不作内容审查。** 代码增量只有 `tests/unit/test_transcription_audio_admission.py`;新用例针对卡面登记的真实 YouTube API 类型条件、优先下载 producer、入口准入及媒体字节不变。另有 `docs/sessions/issue-rootfix-261003/progress/audio-admission.md` 变化(53 行新增、1 行删除);依输入隔离要求没有打开它,故不以其内容判断实现理由或扩展范围。 +2. **是否新增未经批准的抽象:没有生产抽象。** 新增的 `YoutubeRouteRecorder`、`_real_youtube_downloader`、`_attach_common_stubs` 都是测试夹具;两种 YouTube fixture 共用同一构造与记录器。`file_digest`/`EVENTS` 只记录真实测试文件字节与事件,不进入运行时。 +3. **是否增加无依据状态或 fallback:没有。** 生产源码与 H0 相同;没有生产状态、fallback、重试或配置变化。`EVENTS` 是测试过程中的观测列表,由 fixture 每次清空。 +4. **是否留下双路径:没有运行时双路径。** 测试分别覆盖原有 API 快速路径与原有 YouTube 优先下载路径;没有复制或替代生产准入逻辑。 + +## 关键不变式与证据 + +- **API 精确分支条件与 producer**:生产入口在 `transcription.py:1881-1887` 检查精确类名 `YoutubeDownloader`、只读 `use_api_server` 属性及其真值。真实类的 property 在 `downloaders/youtube.py:69` 读取 `_youtube_api_client is not None`。测试用 `YoutubeDownloader.__new__` 构造该类并设置其实际 backing field(`test_transcription_audio_admission.py:330-344`);API 入口用例在 `:579-612` 断言 `fetch_for_transcription` 被调用、`download_file` 与 `get_download_info` 未调用,且断言失败终态和双引擎零调用。API 有音轨对照在 `:614-634` 到达 FunASR。 +- **优先下载分支与终态**:生产选择条件在 `transcription.py:2384-2403`。无音轨、探测失败、含音轨对照分别在 `test_transcription_audio_admission.py:750-775`、`:778-799`、`:802-823`;都断言真实入口记录器实际收到 `download_video_with_priority` 且没有 `download_file`。两种失败分别断言 `no_audio_track`/`media_probe_failed`、FAILED 通知和引擎零调用;有音轨对照进入 FunASR。 +- **共享 gate**:API ASR 前的 gate 位于 `transcription.py:2022-2034`;普通下载、预下载及 YouTube 优先路径共用的 gate 位于 `:2417-2428`。API 无音轨测试及普通入口参数化用例都锁定失败终态、通知、缓存和引擎行为;普通入口 also 保留 foreign task 文件和已有缓存产物的清理边界(测试 `:531-577`)。现存 `tests/features/test_transcription_flow_regression.py:495-526` 用明确事件序列锁定常规准入发生在 ASR 前且恰好一次。 +- **逆向变异(全部恢复)**:①只把 API gate 调用替换为无操作,确认注入行后运行 `test_no_audio_track_is_rejected_at_youtube_api_entry`,退出 1,`assert _engine_calls() == []` 触发 `AssertionError`(日志 `/tmp/audio-increment-api_gate_removed.log`)。②只替换普通 gate,普通入口的 4 个参数化用例全部退出 1,终态成功值触发 `AssertionError`(`/tmp/audio-increment-regular_gate_removed.log`)。③在 `_ensure_audio_track` 成功后追加字节,API 音轨用例在 `pre_digest == engine_digest` 处退出 1(`/tmp/audio-increment-post-admission-bytes.log`)。测试源文件未改;生产源均恢复到上述 H1 SHA。 +- **SHA-256 与时间顺序:未完全闭合。** `_assert_bytes_unchanged` 在 `test_transcription_audio_admission.py:683-702` 对 producer 实际落盘和 engine 实际读取的字节做 SHA-256 比较;API 与优先路径调用它(`:705-722`、`:802-823`),准入后字节变异会使比较转红。但普通下载对照 `test_mixed_media_reaches_funasr_without_transcoding`(`:661-680`)没有哈希断言,`:705-722` 的 `test_mixed_media_bytes_are_unchanged_across_admission` 实际使用 API fixture。`EVENTS` 只记录 `download`、`engine` 两种事件,`:700-701` 仅断言这两个阶段;没有记录准入事件,故不能用该事件序列证明哈希采样严格早于 gate。代码当前调用顺序可由 producer 与 gate 的源码位置读出,但卡面要求测试事件本身证明顺序。 +- **真实 ffprobe producer**:`test_transcription_audio_admission.py:839-920` 通过真实 `ffmpeg -f lavfi` 生成样本,并由 `SubprocessSpy` 调用实际 `ffprobe`,断言 argv、JSON、探测次数及无 ffmpeg 抽轨。目标测试文件本轮 30 项全部通过,条件跳过数为 0;本机实际二进制为 `/usr/bin/ffmpeg` 和 `/usr/bin/ffprobe`。 +- **字幕直命中与 helper 边界:测试未锁全。** API 字幕命中分支 `transcription.py:1914-1963` 在 API gate(`:2027`)之前直接处理字幕;现有标准字幕测试 `tests/features/test_transcription_flow_regression.py:471-492` 只断言结果文本和缓存,没有断言 `_ensure_audio_track`、FunASR、CapsWriter 都未调用,也没有 API 字幕快路径 fixture。`_ensure_audio_track`(`transcription.py:66-159`)只读文件并启动 ffprobe,不写媒体、不抽轨;两处真实 ASR 路径在对应 gate 后。该运行时事实不替代缺失的负向测试断言。 + +## 发现与风险处置 + +- **P2 新发现:哈希入口与采样时序断言不足**,违反卡面不变式 4。普通下载路径缺哈希比较,哈希测试也没有 gate 事件。必须明确判为未闭合。 +- **P2 新发现:API 探测失败通知未被断言**,违反卡面不变式 1。`test_probe_failure_at_youtube_api_entry_is_check_failure`(`test_transcription_audio_admission.py:1003-1024`)断言结果为 failed、消息为 `media_probe_failed`、引擎零调用,但没有调用 `_terminal_failure` 校验 FAILED 通知;API 无音轨用例有该断言,不能替代探测失败情形。 +- **P2 新发现:字幕直命中缺零调用断言**,违反卡面不变式 5。当前源码分支顺序正确,但相关测试不能在新增 ASR/探测调用时保证转红。 +- **预算偏差**:H0..H1 总计 382 行新增、40 行删除(422 行变更),超过 Diff-Lines-Hard 300;测试文件单独新增 329 行。目标 150 也明显超出。作为 reviewer 记录为任务卡预算不符合;本轮没有扩展或修改实现范围。 +- **已接受且不要求修复的运行时 P2**:probe stream 缺少 `codec_type` 时会归为 `no_audio_track`;除 `FileNotFoundError`/超时外的其他 `OSError` 未统一归一化;stderr 摘要直接取首行而不是白名单。三项均按卡面接受不修,不建议为其加机制。 +- **运行时 P1:无。** 没有新的运行时差异或真实 P1 证据。外部 OCR 没有审查到任何项,见下方三态;人工审查仍完整执行。 + +## OCR 与测试结果 + +- **OCR:`skipped`**,envelope `reason=no_reviewable_items`、`coverage=none`、`findings=[]`。这是未审查,不是干净结论;手工 review 不受其替代。 +- **目标测试文件**:`uv run --frozen pytest -v -ra tests/unit/test_transcription_audio_admission.py`,退出 0:**30 passed, 78 warnings, 2.66s**,没有 skip。 +- **H1 完整入口**:`make test` 在审查树退出 0,pytest 进度到 100%,观察到 3 个 skip,无失败。入口使用 `-q`,未打印 collected 总数/skip 原因;本结论来自这次真实完整执行,不使用缓存点数代替。 +- **当前主干合成树完整入口**:在 scratch worktree 临时合并 H1 与 `5bb58f923bbd185a01cc20a48f7aab7e2b8075e1`,父提交依次为 `27fc073f3828136471e35a9cfeac652aa900a6ec` 和 `5bb58f923bbd185a01cc20a48f7aab7e2b8075e1`,合成 tree `b64bb17851b4cefa29e54f7cc9da216955d9ae59`。合并无冲突,`make test` 退出 0、进度 100%、3 个 skip、无失败。scratch-worktree 已自动移除;审查树仍在 H1。 +- **CI 红归因**:派发卡记录的主干基线为 `gh api request failed`。本地两次完整运行无红;继承红无法判定,新增红为无。三项预期变异红已单独记录,不计作套件失败。 + +## 收尾状态 + +- OCR 状态:skipped;人工 review 已完成,结论不通过,待上述测试缺口补齐后重审。 +- H1 源码保持冻结字节;当前审查树唯一允许的持久改动是本 verdict 文件。 +- 没有 ready、合并或部署实现 PR;本卡只提交并推送 verdict。 diff --git a/docs/sessions/issue-rootfix-261003/reviews/audio-verdict.md b/docs/sessions/issue-rootfix-261003/reviews/audio-verdict.md new file mode 100644 index 00000000..308acaae --- /dev/null +++ b/docs/sessions/issue-rootfix-261003/reviews/audio-verdict.md @@ -0,0 +1,53 @@ +# PR #164 音轨严格准入独立验收 + +- Frozen PR 范围:`2995a379a898b1d1437ee02e22996e12ecebcc31..bf488affcef6c2ecd91f6c05a0539947bd671e32`;代码增量:`7f08586717f8abd565ccca0a7ad99b5cfc5f122b..bf488affcef6c2ecd91f6c05a0539947bd671e32`。 +- 结论:**P2 only,建议修复以下测试/诊断缺口后再合入;本次不改实现。** 未发现 P1。 +failure-visibility: p2-only + +## 按完成条件验收 + +1. **ASR 入口准入:部分满足。** `src/video_transcript_api/api/services/transcription.py:2024-2032` 在 YouTube API `audio_path` 直调 ASR 前探测;`2418-2426` 在常规/预下载/选择下载汇合点、ASR 前探测;字幕直命中在 `1914` 后无需探测。两真实调用点足以支撑无状态共享函数。缺口见 P2-1:API 与 YouTube 优先下载的测试替身没有满足生产分支条件。 +2. **归因、放行与字节:部分满足。** `_ensure_audio_track` 一次调用 `ffprobe`、30 秒超时、失败关闭;缺二进制/超时/非零/非法 JSON/缺 streams/非列表 streams/非对象元素均映射 `media_probe_failed`,真实无音轨映射 `no_audio_track`。`codec_type` 缺失的对象仍被当成“无音轨”(P2-2);启动子进程的其它 `OSError` 未映射为 `media_probe_failed`(P2-3)。音频-only 与混合样本能到所选引擎;混合测试断言无 ffmpeg 调用,但没有比较准入前后的文件哈希(P2-5)。 +3. **终态、通知、清理:满足。** admission 失败进入既有 `_fail_task_and_notify`;测试读取实际失败通知错误文本与 FAILED 终态。任务 finally 清理只回收当前任务目录;测试确认 foreign task 文件、缓存样本和 foreign task active 状态保留。既有 finally 仍执行,未发现准入分支绕过它。 +4. **真实边界与断言约束:部分满足。** 新测试用 `ffmpeg -f lavfi` 动态生成音频-only、混合、视频-only 样本;真实 `ffprobe` spy 保存仓库实际 argv 与 JSON 至忽略目录 `data/temp/audio_admission_probe/`。缺失二进制、超时、非零、JSON/结构问题用隔离 mock。未发现恒 skip;缺口见 P2-1、P2-5。将常规入口 `_ensure_audio_track(local_file)` 临时替换成 `pass` 后,`test_audio_admission_runs_once_before_asr` 按预期 AssertionError;恢复后源码 SHA-256 为 `72180f32419e79e47c0b48bf0f1abe8a74540e9b182fb03a0adea6b15f4a3b7e`,实现相对 H0 diff 为空。 +5. **诊断及真实使用风险:部分满足。** 无音轨与检查失败有具名日志。错误日志中的 `stderr_head` 只是任意首行截断/ASCII 转换(最多 160 字符),不是字段白名单(P2-4);当前本机以 `-v quiet` 探测损坏媒体的真实 stderr 为 0 字节,降低了已证实泄露风险,但代码仍会记录非空原文。`codec_type` 缺失分类见 P2-2。未增加结果类型/状态;局部共享 helper 有两个真实调用点。 +6. **定级:P2 only。** 本仓 risk-tier 为 personal;未用公网或多租户情境抬高影响。当前源码的两处 ASR 入口均有准入,已测失败都不调用 ASR、会落 FAILED 并清理;没有发现可复现的假成功绕过或高影响消费链故障。主干基线 API 在派发时不可用,继承红无法判定。 + +## Findings(仅标记,不在本卡修复) + +### P2-1:YouTube API 与优先下载测试没有走到声称覆盖的分支 + +- 证据:生产 API 分支要求精确类名 `YoutubeDownloader`(`transcription.py:1881-1886`),测试替身实际名为 `YoutubeApiServerDownloader`(`tests/unit/test_transcription_audio_admission.py:286`);去掉 API 分支准入调用后,三个声称覆盖该分支的测试仍通过。`RouteDownloader` 没有 `download_video_with_priority`,但测试把 `route="youtube"` 记作选择下载路径(测试 `232-240,391-404`),生产优先分支因此也未触发(`transcription.py:2384-2400`)。 +- 违反:完成条件 1、4。 +- 影响:入口调用点目前可在源码中确认,但回归测试不能约束这两条特别路径;相关分支以后被移除准入时测试仍可能全绿。 + +### P2-2:缺失 `codec_type` 被误报成无音轨 + +- 证据:仅验证 streams 是列表且元素是 dict(`transcription.py:135-145`);随后 `s.get("codec_type") == "audio"`(147)把 `{"streams":[{}]}` 归为 `no_audio_track`。spec 明确将无效 stream 结构归入 `media_probe_failed`;失败 fixtures 覆盖字符串元素,却没覆盖缺字段的对象(测试 `610-628`)。真实 ffprobe 产物样本中的 stream 都有 `codec_type`(测试 `576-584`),所以此处是畸形结构处理问题,不是对真实样本伪造契约。 +- 违反:完成条件 2、5。 +- 影响:检查结构异常会误导用户换源,而非报告检查失败。 + +### P2-3:ffprobe 启动时的非 FileNotFoundError/超时 OSError 未归一化 + +- 证据:调用只捕获 `FileNotFoundError` 与 `TimeoutExpired`(`transcription.py:99-115`)。用不可执行的 ffprobe 探针实际触发 `PermissionError(errno=13)`,异常逸出 helper,最终由 `process_transcription` 外层记录为通用 `转录任务异常`,未生成 `media_probe_failed`。外层仍失败并清理,没有发现放行。 +- 违反:完成条件 2(探测失败原因须明确且不冒充无音轨)。 +- 影响:服务环境中二进制不可执行/进程无法启动时,任务失败但消费者拿不到稳定的检查失败原因。 + +### P2-4:有界 stderr 仍是原文,不是白名单诊断 + +- 证据:`_bounded_stderr_head` 仅取首行、转 ASCII 并截至 160 字符(`transcription.py:57-63`);returncode 错误日志直接写入该值(120-123)。诊断测试只保证假 secret 不进入用户通知,不断言日志排除了它(测试 `745-777`)。本机真实 quiet 模式坏文件 stderr 为空,故当前没有确认的真实泄露样本。 +- 违反:完成条件 5 与仓库生产诊断的结构化字段/白名单要求。 +- 影响:若底层工具将媒体内容、路径或其他输入回显到错误首行,该原文会进入日志;截断限制长度但不限制内容。 + +### P2-5:未锁定输入媒体字节在准入后保持不变 + +- 证据:混合样本测试断言一条 ffprobe、无 ffmpeg 调用(`tests/unit/test_transcription_audio_admission.py:495-513`),但不记录/对比下载文件准入前后的内容哈希;真实 producer 产物只记录样本大小。 +- 违反:完成条件 2 的“无抽音轨/哈希字节变化”要求及完成条件 4 的独立断言约束。 +- 影响:当前实现路径没有媒体转换调用的迹象,但字节不变契约没有回归断言锁定。 + +## OCR 与验证 + +- OCR 前置:status=`reviewed`,reason=`primary_selected`;profile=`minimax`,model=`MiniMax-M3.1-Flash-Preview`;4 条评论均 4/4 confirmed。已逐条核对:子进程 OSError、缺 `codec_type`、quiet stderr、PerfTracker 未计 probe。前三项见以上 finding/风险;PerfTracker 事实成立但卡面未要求该阶段进入性能摘要,未列 finding。 +- 本地:`uv run --frozen pytest -q tests/unit/test_transcription_audio_admission.py tests/features/test_transcription_flow_regression.py tests/integration/test_task_observability.py tests/integration/test_temp_cleanup_integration.py tests/unit/test_capswriter_deadline_budget.py`:102 passed markers,0 failed,0 skipped。 +- PR #164 H0 CI(run `37121530913`,job `111198424072`):quality Tests 执行 `uv run --frozen pytest -q tests`;日志测试进度到 100%,共 3,567 项,3,562 passed、5 skipped、0 fail/error。质量 job SUCCESS;draft 状态下 primary/OCR/shadow jobs 为 SKIPPED,不能视为主审通过。`-q` 日志未显示 5 项具体名称。 +- 实现/测试/配置未改动;仅新增本 verdict。完整记录见 delegate report。 diff --git a/docs/sessions/issue-rootfix-261003/reviews/config-verdict.md b/docs/sessions/issue-rootfix-261003/reviews/config-verdict.md new file mode 100644 index 00000000..9245557d --- /dev/null +++ b/docs/sessions/issue-rootfix-261003/reviews/config-verdict.md @@ -0,0 +1,49 @@ +# PR #162 配置诊断独立验收 + +failure-visibility: p2-only + +## Verdict + +**findings**(risk-tier: personal)。审查对象固定为 `2995a379a898b1d1437ee02e22996e12ecebcc31..a8b0251d13fe956303a5894840c59c0716cd2aac`。 + +## Findings + +### P2:ASCII stdout 遇到合法非 ASCII 策略值时检查命令失败 + +- **违反 spec #5**:`main.py:89-94` 用 `json.dumps(..., ensure_ascii=False)` 输出白名单;`LLMConfig.from_dict` 在 `src/video_transcript_api/llm/core/config.py:190-195` 原样接受非空 `fallback_strategy`,校验未限制字符集。`CLAUDE.md:13` 要求 console 日志使用纯英文。 +- **实测**:真实运行 `main.py --check-config` 子进程,输入合法策略值 `策略-测试`,设置子进程 `PYTHONIOENCODING=ascii`,退出码为 1;stdout 已有 `Configuration OK`,随后抛 `UnicodeEncodeError`。临时配置位于系统临时目录,未生成运行期路径。当前评审 shell 的 `locale charmap` 和 Python stdout 编码均为 UTF-8,所以该环境下不触发;这是个人使用下的 CLI 编码边缘故障,判 P2。 +- **处置建议**:默认 `ensure_ascii=True` 会保持 ASCII 输出,JSON 解码值不变。本卡不改实现。 + +### P2:示例把 plain 结构化路由描述为阈值分段之后的选择 + +- **违反 spec #4**:新增 `config/config.example.jsonc:276-277` 写成“分段之后”再由 `structured_calibration_for_plain` 选择结构化或旧纯文本路由,并未说明结构化路由依赖原始 segments。 +- **代码证据**:`src/video_transcript_api/api/services/llm_ops.py:1149-1153` 在交给协调器前先尝试把 plain 输入变为 segments;无 segments 时返回 transcript。`src/video_transcript_api/llm/coordinator.py:436-452` 按输入类型分别路由:字符串进入 `PlainTextProcessor`,列表进入 `SpeakerAwareProcessor`。阈值判断只在 `src/video_transcript_api/llm/processors/plain_text_processor.py:121-138` 的字符串路径中发生。因此阈值分段在 plain 结构化路由之后;即使开关为 true,无可用原始 segments 时也会回到纯文本路径(`llm_ops.py:1087-1091,1117-1120`)。注释顺序及无条件承诺会误导配置维护者;仅文档注释有误,未改变运行行为,判 P2。 + +## Spec checklist + +1. **部分符合**:`main.py:85-94` 保留原校验和 `Configuration OK`,随后输出稳定 JSON;`llm_effective` 内恰有 9 个白名单键,每键仅 `value`、`source`,没有凭据字段。非 ASCII 序列化见 P2-1。 +2. **符合**:值来自同输入的 `LLMConfig.from_dict`(`main.py:31,48-62`);8 个 `.get` 字段按键存在判断来源,fallback 按真实 truthiness 派生。测试覆盖显式 `null`、`False`、缺失及 fallback 假值(`tests/unit/test_check_config_effective_values.py:156-244`)。 +3. **符合**:新增单测通过真实 `subprocess.run` 执行 CLI,传入绝对 argv 和最小 env,解析真实 stdout JSON,检查凭据 sentinel 与运行期路径;缺少 llm 段仍走原错误路径(同测试 `:85-123,254-317`)。主分支仍先完成 `load_and_validate_config` 再打印成功行。未读运行配置或生产凭据。 +4. **部分符合**:阈值注释明确为严格大于,与 `plain_text_processor.py:121` 一致;新增路由注释顺序错误,见 P2-2。 +5. **部分符合**:无凭据字段进入白名单;合法非 ASCII 策略值与 ASCII stdout 冲突,见 P2-1。当前 shell 实测编码为 UTF-8。 +6. **符合**:把 `main.py:51` 的来源映射临时变异为恒 `default`,先确认注入行,再运行 `test_explicit_values_report_config_source`;该测试以 `AssertionError` 在 `tests/unit/test_check_config_effective_values.py:191` 转红(exit 1)。只恢复该行;恢复后相对 H0 的实现 diff 为空,工作区干净。 +7. **符合**:本 verdict 提供文件/行号、两条 P2、全量测试、OCR 三态、逐条 spec 结论及剩余风险。 +8. **符合**:本评审仅新增此 verdict;本文件随评审分支提交并推送,SHA 与干净状态写入派发报告。 + +## 全量测试 + +- 命令:`make test`(Makefile 正式入口:`uv sync --frozen` + `uv run --frozen pytest -q tests`)。在本 worktree 独立 `.venv` 中运行一次。 +- 结果:exit 0,`real 154.03s`。完整输出已归档于派发目录 `make-test.log`。 +- pytest 输出未给最终汇总行;按本次完整进度输出计 3,524 个通过标记、3 个 skip 标记,共 3,527 项;无失败标记。运行有弃用告警。pytest 缓存的 nodeid 数与本次进度标记不一致,未用缓存计数冒充测试数。 + +## OCR 前置 + +- `ocr-review` exit 0;JSON envelope 完整,`status=reviewed`、`reason=primary_selected`、`cli_status=complete`、`coverage=complete`;stderr 记主腿耗时 `575.833s`。 +- 1 条意见:`main.py:89-93` 的 `ensure_ascii=False` 可能让合法非 ASCII fallback 策略在 ASCII stdout 上触发 `UnicodeEncodeError`。工具标注 `medium` / `confirmed`;独立子进程探针复现后,本仓按 personal 风险判 **P2**,不是 P1。 +- OCR 结果完整归档于派发目录 `ocr-stdout.json` 与 `ocr-stderr.txt`;OCR 未替代全量独立审查。 + +## 边界与遗留 + +- 未修改实现、测试或配置;来源标签红验后 `main.py` 相对 H0 完全无差异。 +- 卡面 Diff-Lines-Hard 为 240,但冻结 PR diff 实测为 504 insertions、2 deletions(6 文件);`budget_diff/base_sha/head_sha` 未提供。作为 reviewer 按要求审完固定范围,没有扩写 PR。 +- 未核验 PR Ready 或最终 CI;按卡面留给主脑取证。派发时主干基线不可用,继承红未能判定;本地全量测试本次为绿。 diff --git a/docs/sessions/issue-rootfix-261003/reviews/dialog-verdict.md b/docs/sessions/issue-rootfix-261003/reviews/dialog-verdict.md new file mode 100644 index 00000000..01cc7dbc --- /dev/null +++ b/docs/sessions/issue-rootfix-261003/reviews/dialog-verdict.md @@ -0,0 +1,63 @@ +# PR #163 独立审查结论 + +Frozen range:`2995a379a898b1d1437ee02e22996e12ecebcc31..c387e2a8e15361dd807e4a0448599a5a84a60b4d`(H0) +风险级别:personal +Verdict:**PASS(仅一条非阻塞 P2 注释准确性意见;无 P1 运行时缺陷)** + +failure-visibility: p2-only + +## Finding + +### P2-1:句末集合说明仍把相同集合描述成不同定义 + +- 关联 spec:第 4 条(审查原位句末对照注释与实际实现是否自洽)。 +- 证据:实现现为 `dialog_segmenter.py:344` 的 `[。!??!]`;CapsWriter 在 `capswriter_client.py:222,230` 用 `。!?!?` 并按字符成员判断,两边字符集合相同。新注释 `dialog_segmenter.py:337-338` 仍把 CapsWriter 列作“另有 3 套互不一致定义”之一;同文件旧注释 `:153-154` 还把 DialogSegmenter 写成 `。!?` 并称四套互不一致。改动后该旧对照已失实,新增对照的计数也有歧义。 +- 影响:只误导维护者理解 #146 的句末集合关系,不改变切分结果、块长或时间轴。 +- 定级与处置:P2,非阻塞;按本卡不修实现或测试代码,接受不修并留作维护注释清理。没有 P1,因此 P1 两问不适用。 + +## Spec 逐条核验 + +1. **通过。** 唯一运行时变化是 `DialogSegmenter._split_by_sentences` 加入 ASCII `!?`(`:344`);`.` 未加入。该方法只由超长对话路径 `_split_long_dialog` 调用(`:73,111,125`);尾部空白过滤和上层 `.strip()` 未变。 +2. **通过。** 新测试逐项锁定中文直接切句不变(`test_dialog_segmenter_caps.py:224`),无空白尾片样本拼接回原文(`:237`),尾部空白过滤仍生效(`:243`),ASCII `.` 不切(`:249`);连续 `?!` 的重组与既有中文 `?!` 合同一致(`:202`)。既有中文完整分块/时间线样本 `test_text_segmenter.py:472` 也通过。 +3. **通过。** 两个工厂分别构造默认说话人分段器(`:23`)和模拟 `speaker_aware_processor.py:247` 的 plain 参数覆盖分段器(`:167`),均为真实 `DialogSegmenter` 实例;直接 `_split_long_dialog` 与 `segment` 路径均有覆盖(`:258,293,313`)。断言块长上限、id、时间戳首尾锚定、单调相邻连续及 `time_estimated`;完整 `segment` 结果单独计数实际 chunk,不从句子数推成本。 +4. **有一条 P2。** 没有新增类型、状态、兼容或 fallback 机制。源文件原有 CRLF 保留;变更文件 `git diff --check` 通过。注释不一致见 P2-1。 +5. **通过。** 仅将正则恢复成旧字符集 `[。!?]` 后,`test_direct_split_cuts_at_ascii_marks` 的 3 个参数用例均因 `AssertionError` 转红。源文件恢复 SHA-256 为 `3ce719651dc4c4dbe4cc90afcf2958e48237a2463d73c2d8129362d743266520`;355 行 CRLF、0 行裸 LF;被审实现与测试文件相对 H0 均无 diff。 +6. **通过。** 本 verdict 记录逐条 spec、P2、OCR 三态、目标测试实跑和真实 CI 完整入口证据。没有 P1,不作 P1 两问结论。CI 的 `gate / primary` 为 `SKIPPED`,不当作主审通过。 +7. **通过。** 本文件是唯一仓内评审产物;failure-visibility 为 `p2-only`。评审分支 commit+push 与最终 SHA/远端干净状态记录在派发报告中。 + +## OCR 前置 + +- 状态:`reviewed`;profile:`minimax`;reason:`primary_selected`;envelope 完整。 +- comments:1 条,工具标注 low/documentation。意见指向 `dialog_segmenter.py:337-338` 将 CapsWriter 字符集计作不同集合,并指出 `:153-154` 及 CapsWriter 对照注释仍记旧集合。 +- 独立判定:确认注释比较失实,按本仓 risk-tier 判 P2;不据工具 severity 直接定级。未发现额外运行时意见。 + +## 目标测试 + +命令:`uv run pytest -q -o 'addopts=--strict-markers' tests/unit/test_dialog_segmenter_caps.py tests/unit/test_text_segmenter.py tests/unit/test_speaker_aware_no_speaker.py tests/unit/test_speaker_aware_processor_stats.py` +结果:`96 passed, 78 warnings in 10.54s`,退出码 0。四个指定相关整文件完整运行;未在本 worktree 启动本地全量套件。 + +## CI 完整入口证据 + +- PR #163 为 draft,head `c387e2a8e15361dd807e4a0448599a5a84a60b4d`,与 H0 一致。 +- Run `37120954386` / job `111196795948`(`gate / quality`):job head 同 H0;`Tests` step 为 `success`。workflow 测试入口选中 `make test`;H0 的 Makefile 展开为 `uv sync --frozen` 后 `uv run --frozen pytest -q tests`。H0 的 pytest 配置发现 `tests/` 下测试并排除 `tests/manual/`,符合项目全量自动测试合同。 +- 实际 Tests 日志进度输出计得 3,559 个测试 outcome 标记:3,554 个 `.`、5 个 `s`;没有失败标记。step success 且 runner 使用 `bash -e`,故退出码为 0。 +- 同一 PR check rollup 的 `gate / primary` 为 `SKIPPED`;quality 绿灯不等于正式主审通过。派发时主干基线不可用,继承红无法判定。 + +## 踩到的坑 + +- `?`、`!` 加入捕获组后,连续 `?!` 的第二个标点会按既有配对算法成独立句片;本次样本与中文 `?!` 相同,符合冻结合同。 +- 源文件为 CRLF;红验用字节替换并以原始字节校验还原,没有整文件规范化行尾。 + +## 闸与绕过 + +- 按要求先 OCR、再独立全量审 diff;CI 只使用指定真实 run/job 证据。本地只跑相关四个整文件,未并发启动全量测试。 +- 变异注入被断言证实;还原后 SHA 一致、H0 文件 diff 为空。没有绕过门禁。 + +## 与卡面的偏差 + +- 无范围偏差:未读实现方报告、未修改实现/测试/配置/workflow、未审其它 PR、未部署/ready/合并。 +- 本地测试命令覆盖目标文件及调用路径相关整文件;按卡面未启动 `make test`。 + +## 最贵的一步 + +- OCR 主腿等待约 6 分钟才返回完整 envelope;其后 CI job API 与日志核对花费次多,原因是需要把真实 Tests 命令、收集范围、outcome 数和 primary skip 分开确认。 diff --git a/docs/sessions/sdk65-upgrade-261004/deploy/independent-audit.md b/docs/sessions/sdk65-upgrade-261004/deploy/independent-audit.md new file mode 100644 index 00000000..98b8b2fe --- /dev/null +++ b/docs/sessions/sdk65-upgrade-261004/deploy/independent-audit.md @@ -0,0 +1,170 @@ + + +# SDK65 上线独立只读复核(independent-audit) + +- Issue:#166(本卡不执行关单,只给 verdict;关单由主脑定局) +- 派发:`dlg-20261004-114054-b9a6f5`(接手 `dlg-20261004-113810-c654aa`,其上家 TLS 断连、无 report 无 commit,非生产缺陷) +- 被审对象:部署执行器报告 `deploy/n305-verification.md`(dispatch `dlg-20261004-111840-b18694`),**本文件不复述其结论,全部结论独立重查** +- 基线 commit:`ffbddbd10042b8f56f81af711252aa1a257d3a23`;本分支 `card/sdk65-prod-audit-261004` +- 资源消耗:SSH **9/12**(n305 ×6,mac-studio ×3);生产 GET **2/2**(n305 loopback livez、mac /health,各 ≤8s);**0 个新任务、0 次 POST、0 处生产写入** + +## 0. Verdict + +**证据链完整,#166 可关。** 五组判据全部独立核实通过,其中判据 3(服务端同 UUID 四事件)取得了比部署报告更强的证据,并证伪其 D1「结构性不存在」论断——事件在 `server_latest.log*` 中精确存在,部署报告只查了 `pm2-out.log`(无 UUID 的粗粒度日志)。通知偏差(D2)两个判定的代码契约只读核实均成立,属请求契约缺口,不阻塞 #166。 + +## 1. 判据 1:运行镜像 / 资产 / 服务端未变 + +### 1.1 registry digest(本机只读,独立读 registry,不看 push 输出) + +```bash +docker buildx imagetools inspect ghcr.io/zj1123581321/video-transcript-api:ffbddbd10042-sdk65-261004 +# Digest: sha256:20d683a70c95ef976def9f2c78f1eb31e25bb7fe69634860cfdf9e176d643340 ✅ 与卡面期望一致 +``` + +### 1.2 容器身份与四文件(SSH n305 #1) + +```bash +ssh n305 'docker inspect video-transcript-api --format "Image=... ImageID=... StartedAt=... Health=... Restarts=..."; \ + cat /opt/media/VideoTranscriptAPI/.deploy-image; \ + sha256sum docker-compose.yml config/config.jsonc config/users.json .env' +``` + +| 项 | 实测 | 期望 | 判定 | +| --- | --- | --- | --- | +| 容器 Image | `…@sha256:20d683a…643340` | registry digest | ✅ 相等 | +| `.deploy-image` | `…@sha256:20d683a…643340` | registry digest | ✅ 相等(独立读 registry 后与容器/.deploy-image 三方一致) | +| ImageID | `sha256:6116e950…80bf89e` | 构建 config digest | ✅ 与 registry manifest config digest 逐位相同 | +| StartedAt | `2026-10-04T11:24:17Z` | 部署后重建 | ✅(旧容器 10-03T16:11Z 已被替换) | +| Health / Restarts | `healthy` / `0` | 未重启 | ✅ | +| compose / config.jsonc / users.json / .env | `7b0a89b5…` / `a349b818…` / `237cefb5…` / `5885382b…` | 卡面升级前四 hash | ✅ 逐字符全等(升级前后字节未变) | + +### 1.3 镜像内载荷(SSH n305 #2) + +```bash +ssh n305 'docker exec video-transcript-api /app/.venv/bin/python -c \ + "import hashlib,sys,os,capswriter_asr.client as c,websockets; print(...)"' +``` + +| 项 | 实测 | 期望 | 判定 | +| --- | --- | --- | --- | +| SDK `client.py` SHA-256 | `eccec1a69b81c4a2360d33e15f0ddb8adffb85725dc93d41f8e3150a0863f6c7` | 新 pin `b0818dc` | ✅ | +| Python / websockets | 3.11.17 / 15.0.1 | 3.11(隔离变量只有 SDK) | ✅ | +| `GIT_SHA` | `ffbddbd10042b8f56f81af711252aa1a257d3a23` | 部署固定 SHA | ✅ | + +### 1.4 端口 / 挂载 / 健康(SSH n305 #6、mac-studio #3,含 2 次生产 GET) + +```bash +ssh n305 'docker inspect video-transcript-api --format "Ports=... Binds=..."; curl -s -m 8 -o /dev/null -w "%{http_code}" http://127.0.0.1:8200/livez' +ssh mac-studio 'curl -s -m 8 -o /dev/null -w "%{http_code}" http://127.0.0.1:6016/health' +``` + +- 端口映射 `8200:8000`(v4+v6),binds `data/`、`config/`:✅ 未变 +- n305 `livez`=200、mac CapsWriter `/health`=200:✅ +- 服务端进程未变(SSH mac-studio #2):listener PID **22665**,启动 `Thu Oct 1 18:36:04 2026`,cwd `capswriter_server_main`,`git rev-parse HEAD` = `6b7a2b82fbc3ebe862250a8804902e5bf37f9211`:与卡面基线完全一致 ✅ + +## 2. 判据 2:固定任务终态 / 产物 / API 侧定序 + +### 2.1 SQLite 终态(SSH n305 #3,`mode=ro`,只查固定 task) + +```bash +# 经 docker exec -i 跑只读 python:sqlite3.connect("file:/app/data/cache/cache.db?mode=ro", uri=True) +# select task_id,status,completed_at from task_status where task_id='task_7c71ffc0e4f64b3bbdf47a46b65ada8a' +``` + +`status=success`,`completed_at=2026-10-04 11:25:56`:✅ SQLite 成功终态。`task_terminal_notifications` 该 task 行数=1(存在一条终态通知记录,与 §4 偏差相互印证)。 + +### 2.2 cache 产物与真实 SDK UUID(SSH n305 #3/#4) + +`generic/2026/202610/df618b74f2c1e02b/` 四文件 mtime 均 `2026-10-04T11:25:56Z`(任务新建,非历史缓存)。白名单投影(只读 UUID/计数/长度,不输出正文): + +| 项 | 实测 | +| --- | --- | +| `transcript_capswriter.txt` | 60 字节,非空 ✅ | +| `transcript_capswriter.json` | 375 字节;`task_id=2ab35323-0cd3-4beb-b4e5-677e41ca7fdf`(合法 UUID);`segments=1`;正文合计 **20 字符**;`duration=5.5466875`;`error=null` | +| `llm_calibrated.txt` / `llm_status.json` | 60B / 1004B | +| workspace `asr_example_zh_funasr.json` | 与 cache JSON **同一 `task_id`**、同尺寸同计数;txt 的 UTF-8 字符数 == 两处 segments 字符数 == 20(60B=20 汉字 ×3B)✅ | + +注:FunASR 兼容 JSON 的 schema 是 `{task_id, file_name, duration, segments[], created_at, processing_time, error}`(`capswriter_client.py:687`),**没有**顶层 `text/tokens/timestamps` 键;本复核第一轮按 FunASR 原生键名投影得到全 0,已按正确 schema 复测(上述数字为复测值),不存在产物为空问题。顺带观察(出范围、不影响结论):`processing_time` 字段为负数(-0.122s),系服务端 `time_complete - time_start` 口径所致,登记为既有行为观察。 + +### 2.3 API 日志事件定序(SSH n305 #5,独立按行号+时间戳定序) + +`docker logs --since 2026-10-04T11:25:50Z --until 2026-10-04T11:26:25Z --timestamps`,窗口内 `timeout|重试|retry|Traceback` 命中数=**0**。关键行(行号为窗口内相对行号): + +| 行 | 时间(UTC) | 事件 | +| --- | --- | --- | +| 24 | 11:25:55.752 | `[缓存检测] 跳过缓存检查 (platform=generic)` | +| 26 | 11:25:55.753 | `[缓存检测] ❌ 缓存未命中,准备下载和转录` | +| 47 | 11:25:56.035 | `文件下载成功 …/asr_example_zh.wav (0.17 MB)` | +| 54 | 11:25:56.072 | `开始转录文件 … (尝试 1/5)` ← CapsWriter 客户端真实被调用 | +| 65 | 11:25:56.352 | `输入数据: text_accu=20 字符, tokens=20, timestamps=20` | +| 66 | 11:25:56.353 | `Segments 生成完成: 1 个片段` | +| 78 | 11:25:56.370 | `[perf] transcription: 299ms (OK)` | +| 107 | 11:25:56.407 | `[perf-summary] total: 575ms` | +| 114 | 11:25:56.408 | `任务状态更新: … -> success` | +| 115 | 11:25:56.416 | `terminal CAS won: … -> success` | +| 148 | 11:26:06.861 | `GET /api/task/task_7c71ffc0… 200 OK` | + +定序独立核验通过:未命中旧缓存 → 下载 → CapsWriter 调用 → 转录 **299ms** → success,全程 <1s,远低于 120s 预算;`尝试 1/5` 只出现一次,无重试。299ms 与 20 字符/1 片段与产物侧数字三方一致。 + +## 3. 判据 3:服务端按产品 UUID 精确事件(核心增量) + +UUID 取自 §2.2 cache JSON 白名单字段(**非** API task_id)。服务端日志经 listener 实际 cwd 定位(§1.4),复用 wire 期验证过的安全过滤器(`server_latest.log*` 按 ID 投影,只输出时间/级别/阶段/来源/状态/错误码;先本地语法自检再执行)。SSH mac-studio #4(随机否 ID + 真实 ID + pm2-out 计数一次完成): + +```json +{ + "negative_control": {"task_id": "dfa8ba2a-…", "query_status": "ok", "files_read": 6, "event_count": 0}, + "real_task": {"task_id": "2ab35323-0cd3-4beb-b4e5-677e41ca7fdf", "query_status": "ok", "files_read": 6, "event_count": 4, + "events": [ + {"time_local": "2026-10-04 19:25:55.914", "level": "INFO", "phase": "receive_complete", "source_file": "ws_recv.py", "source_line": 315}, + {"time_local": "2026-10-04 19:25:55.914", "level": "DEBUG", "phase": "final_submit", "source_file": "ws_recv.py", "source_line": 344}, + {"time_local": "2026-10-04 19:25:56.028", "level": "INFO", "phase": "task_end", "task_status": "done", "source_file": "state.py", "source_line": 276}, + {"time_local": "2026-10-04 19:25:56.028", "level": "DEBUG", "phase": "result_dispatched", "source_file": "ws_send.py", "source_line": 204} + ]}, + "pm2_out_hits_for_real_id": 0 +} +``` + +- **查询成功否 ID 0**:随机否 ID `dfa8ba2a-…` 查询 `query_status=ok`、0 命中 —— 证明「查不到」≠「查询失败」,通道可读(6 个日志文件)。 +- **目标结果**:真实 UUID 精确命中 4 事件,`receive_complete → final_submit → task_end(status=done) → result_dispatched`,与 wire 期同 server SHA(`6b7a2b8`)验证过的四事件契约逐位同构。 +- **与 pm2-out 的关系**:`pm2-out.log` 对该 UUID 命中 **0**——pm2-out 是不带 UUID 的粗粒度连接日志(连接/断开/时长/模型输出),`server_latest.log*` 才是带 UUID 的结构化日志。部署报告 D1「服务端 UUID 事件在生产日志格式下结构性不存在、再发一次验收任务同样拿不到」**论断不成立**:查错了日志源,不是结构性不可得。四元组关联(IP+时长+窗口+输出)被本 UUID 级关联严格增强,#166 验收口径无需降级。 +- 时序自洽:服务端 `task_end(done)` 本地 19:25:56.028(UTC+8 = 11:25:56.028Z)落在 API 侧 `开始转录 11:25:56.072Z` 与 `transcription 299ms 完成 11:25:56.370Z` 之间;服务端 `receive_complete 11:25:55.914Z` 早于 API `开始转录` 日志约 0.16s,判为双机时钟偏移量级,不影响「同一 UUID、同一秒级窗口、done 先于客户端 success」的定性。 + +## 4. 判据 4:通知偏差只读核验(判定与位置,不改代码不发通知) + +代码契约(本地只读,`base` 工作树): + +1. **「channel 无 webhook 被忽略」成立**。`api/routes/tasks.py:287`:`if notification_config and notification_config.webhook:` —— 仅当 webhook 非空才把 `notification_config.channel` 赋给 `effective_channel`。只给 `channel:"none"` 不给 webhook 时,`effective_channel` 保持 `None` → `notification_channel=None` → `router._resolve_targets(None)` 返回**全部已配置渠道**。即:用户以为「none=静默」,实际照常全渠道通知。实测印证:本任务 `task_terminal_notifications` 有 1 行(§2.1),与部署报告 D2 的窗口日志一致。 +2. **「none + 非空合法 webhook 已有零路由」成立**。`channel="none"` + 合法 webhook 时:`effective_channel="none"`、`webhooks={"none": url}`(`tasks.py:288-290`);`NotificationRouter._resolve_targets("none")` 按名过滤,没有任何渠道名为 `none` → `targets=[]`(`utils/notifications/router.py:57-58`)→ 发送循环空转、返回 `{}`;fallback 分支要求 `targets` 非空(`router.py:97/133/170`)→ 不会 fallback 到真实渠道。webhook 先过 `validate_webhook_url` 的 SSRF 校验(`transcription.py:234`),即「合法但零路由」。 +3. 性质判定:`notification_config` 无法单独表达「本任务不通知」,属**请求契约缺口/新功能需求**(此前安全实验用 `none + https://example.com` 走的就是零路由这条路,行为未变),不是本次部署引入的回归,也不阻塞 #166。是否登记显式静默开关由主脑/用户裁决,本卡未新增开关、未发任何通知。 + +## 5. 与部署报告的差异汇总 + +| 项 | 部署报告 | 本独立复核 | +| --- | --- | --- | +| 服务端 UUID 事件 | D1 判「结构性不存在」,降格四元组关联 | **证伪**:`server_latest.log*` 精确 4 事件;pm2-out 0 命中系日志源差异 | +| 产物内容 | 引用 20 字符/1 片段(API 日志口径) | 同数字从 cache JSON + workspace JSON + txt 字节三方独立复得 | +| 299ms | 引用 perf 日志 | 独立按窗口行号定序复得,且与服务端 done 时序自洽 | +| 通知 | D2 描述现象 | 两个判定落到具体代码行,并补判「none+webhook 零路由」契约 | + +其余各项(镜像 digest、SDK hash、GIT_SHA、四文件 hash、端口挂载、服务端未变)与部署报告**一致**,但均为本卡独立重查所得。 + +## 6. 缺证项与边界 + +- 无阻塞缺证。固定任务成功终态、产物非空且 UUID 关联、引擎真实调用、服务端 done/dispatched、四文件与镜像身份,五链闭合。 +- 本卡未新任务、未 POST、未改任何生产数据/config/代码、未重启服务;服务端零写入。两次生产 GET 均为 loopback 只读健康探测。 +- 时钟偏移(n305 vs Mac 约 0.16s 量级)为观察项,不构成缺证。 +- 继承红/新红:本卡不跑 CI,基线 `gh api request failed`,按卡面规则**继承红未能判定;新红无**。 + +## 7. 四问 + +**踩坑(最隐蔽的一个)**:FunASR 兼容 JSON 没有顶层 `text/tokens/timestamps` 键,第一轮回读全 0——若不复核 schema 直接采信,会把「键名投影错」误报成「产物为空」假缺陷。第二是部署报告的 D1:「在某个日志文件里 grep 不到」被写成了「结构性不存在」;按锁定决策换 `server_latest.log*` 按 UUID 投影,四事件即刻现形——查错日志源不能归因为系统不可得。 + +**闸**:随机否 ID 先行(`query_status=ok` + 0 命中)区分「无命中」与「查询失败」,过滤器本地 `py_compile` 通过再上生产;远端多行脚本一律单 stdin(`ssh host 'python3 -' < file`);JSON 只投影 UUID/计数/长度;服务端路径只回 basename;先验值(digest/hash 非空相等)再定结论。 + +**偏差**:服务端 `receive_complete` 比 API `开始转录` 日志早 0.16s(时钟偏移量级,已观察不定性为缺陷);cache JSON `processing_time` 为负(既有口径,出范围)。通知契约缺口(§4)为存量行为,非本次引入。 + +**最贵的一步**:把部署报告 D1 从「结构性不可得」翻案——需要在 listener cwd 下对 6 个 `server_latest.log*` 做 UUID 级投影并用否 ID 校准通道;翻案后 #166 的验收口径从「降级关联」回升到「UUID 级四事件」,这是本卡相对部署报告的净增量。 + +## 8. 可复核命令清单 + +本文 §1–§4 各节内联的命令即完整安全命令(含白名单投影脚本,经单 stdin 送达,不在生产落盘)。复核入口:`test -s docs/sessions/sdk65-upgrade-261004/deploy/independent-audit.md`。 diff --git a/docs/sessions/sdk65-upgrade-261004/deploy/n305-verification.md b/docs/sessions/sdk65-upgrade-261004/deploy/n305-verification.md new file mode 100644 index 00000000..e072110f --- /dev/null +++ b/docs/sessions/sdk65-upgrade-261004/deploy/n305-verification.md @@ -0,0 +1,306 @@ + + + +# SDK 65 修复 API 镜像上线与真实 CapsWriter 验收(n305) + +- Issue:#166(本卡不关、不合并) +- 派发:`dlg-20261004-111840-b18694` +- 固定部署 SHA:`ffbddbd10042b8f56f81af711252aa1a257d3a23`(PR #168 MERGED,主干 `git log -1` 一致) +- 锁定 SDK pin:`b0818dc7859d1d8100e42f5c70cb75d34da422f7` +- 结论:**新镜像已替换生产,真实 POST /api/transcribe 经 CapsWriter 引擎在 299ms 内返回非空产物,任务终态 success。 + 服务端(Mac Studio)零改动,config/users/.env/compose 四文件升级前后逐字节相同。** + +> 注:判据 4 中「服务端同 UUID 的 `task_end(done)` / `result_dispatched` 事件」在生产日志格式下**结构性不存在** +> (见 §6 偏差 D1),已用可获得的最强四元组关联替代,并说明为何再发一次验收任务也不会改变这一点。 + +## 1. 上线前基线(2026-10-04T11:21Z 冻结) + +| 项 | 值 | +| --- | --- | +| worktree | `sdk65-deploy-n305-261004`,分支 `card/sdk65-deploy-n305-261004` | +| HEAD | `ffbddbd1`(Merge pull request #168),`git diff --stat HEAD` 为空 | +| 生产容器 Image | `ghcr.io/zj1123581321/video-transcript-api@sha256:950a1600da44a341b96886dc47e30585b97c227aebbac6f411307a8353ed0487` | +| 生产容器 ImageID | `sha256:ca46d3a86cebe92684b2cd1ac7d6f1e311ec51890381160c059b08bf46ab8374` | +| 生产 StartedAt | `2026-10-03T16:11:07.973881807Z`,`Health=healthy`,`RestartCount=0` | +| 生产容器 `GIT_SHA` | `ff92a175243d`(**旧 pin 构建**) | +| 生产容器 SDK `client.py` | `ff476ad7cd40401b7ed77c7606cb4fc14dc3d529043c29c4714b199c13423cdf`(**旧 pin,缺陷在线**),Python 3.11.17,websockets 15.0.1 | +| `.deploy-image` | `…@sha256:950a1600…`(与运行镜像一致) | +| CapsWriter 服务端 | PID 22665,`start_server.py`,启动于 Thu Oct 1 18:36:04 2026,仓库 HEAD `6b7a2b82fbc3ebe862250a8804902e5bf37f9211`,`/health`=200 | +| 端口/挂载 | `8200:8000`;`config/`、`data/` | + +### 1.1 未接 D3 自动部署(判据 1) + +- 本仓 `.github/workflows/` 仅 `gate.yml` / `gate-disposition.yml` / `gate-shadow.yml`,无 build/push/deploy job。 +- gate-hub `runner/fleet-manifest.json` 只把 `zlxlabs/VideoTranscriptAPI` 列在 `managed_callers`; + `gate-hub/.github/workflows/gate.yml` 唯一 `uses:` 是 `zlxlabs/gate/.github/workflows/gate-v2.yml@v2` + (quality / primary / gate aggregator / notify),无部署 job。 +- n305 `crontab -l` 无本项目任何条目(只有备份、镜像 prune、gitea 同步等)。 +- 生产 compose 用 `image: ${VIDEO_TRANSCRIPT_IMAGE:?…}` 钉 immutable digest,不存在自动拉取/自动更新路径。 + +结论:生产镜像只由本次人工部署变更。 + +### 1.2 四文件升级前 SHA-256(判据 1) + +| 文件 | SHA-256 | +| --- | --- | +| `/opt/media/VideoTranscriptAPI/docker-compose.yml` | `7b0a89b50de84f71c480c8ef7398d64d479bbaf429805b9401d748294e498188` | +| `/opt/media/VideoTranscriptAPI/config/config.jsonc` | `a349b81837e0cb06e2ce3dd8ff670df7e4c107b00e911a8b60670a8a334c25cc` | +| `/opt/media/VideoTranscriptAPI/config/users.json` | `237cefb5a2d3bda3ea4e6a227750c348f34475cba1dd0fa804c82458cf28f7ad` | +| `/opt/media/VideoTranscriptAPI/.env` | `5885382b22606aafaa4aeb55d529d56739c6555347286183dcae4ddf577b3b1e` | + +部署脚本未同步:服务器 `docker/pull_and_deploy.sh` SHA-256 = +`1d98c3eb9a2f5c6e4e4714b5c54d0915645ff1ae08a834ef7f065eb81d1d5db1`,与本 worktree 仓内同名脚本 +**逐字节相同** → 无 CRLF 差异,无需 scp,未触碰服务器 compose。 + +## 2. 构建与推送(判据 2) + +```bash +docker build --provenance=false --sbom=false \ + --build-arg GIT_SHA=ffbddbd10042b8f56f81af711252aa1a257d3a23 \ + -f docker/Dockerfile \ + -t ghcr.io/zj1123581321/video-transcript-api:ffbddbd10042-sdk65-261004 . +``` + +- 唯一 tag 含 12 位短 SHA + 唯一后缀 `sdk65-261004`,非 `latest`,不覆盖任何已有 tag。 +- 推送前先在本地镜像内核对 SDK 载荷(见 §3),确认构建物确实含新 pin 才推。 +- 推送输出 digest:`sha256:20d683a70c95ef976def9f2c78f1eb31e25bb7fe69634860cfdf9e176d643340`。 +- **registry 独立复核**(不看 push 输出、不看本地副本): + +```bash +docker manifest inspect --verbose ghcr.io/zj1123581321/video-transcript-api:ffbddbd10042-sdk65-261004 +# Descriptor.digest = sha256:20d683a70c95ef976def9f2c78f1eb31e25bb7fe69634860cfdf9e176d643340 +# mediaType = application/vnd.oci.image.manifest.v1+json(单 manifest,无 attestation 变体) +``` + +部署即以该 digest 引用(`pull_and_deploy.sh` 内部 `docker pull` 后解析 digest 并用 `VIDEO_TRANSCRIPT_IMAGE=` 启动)。 + +## 3. 部署后取证(判据 3) + +| 项 | 值 | +| --- | --- | +| `docker inspect` Image | `…@sha256:20d683a70c95ef976def9f2c78f1eb31e25bb7fe69634860cfdf9e176d643340`(= registry 实际 digest) | +| `docker inspect` ImageID | `sha256:6116e9502f5b3787a4d538cd12b8520216a393937314cb6e17f11f39d80bf89e`(= 本地构建产物的 config digest,逐位相同) | +| StartedAt | `2026-10-04T11:24:17.07081266Z`(旧容器为 `2026-10-03T16:11:07Z`,确被重建) | +| Health / Restarts | `healthy` / `RestartCount=0`(部署后两次复读均为 0,未增长);`Health.Log` 5 条,`ExitCode=0` | +| `.deploy-image` | `…@sha256:20d683a70c95ef976def9f2c78f1eb31e25bb7fe69634860cfdf9e176d643340`(**等于 registry 实际 digest**) | +| 容器内 `GIT_SHA` | `ffbddbd10042b8f56f81af711252aa1a257d3a23` | +| 容器内 SDK `client.py` | `eccec1a69b81c4a2360d33e15f0ddb8adffb85725dc93d41f8e3150a0863f6c7`(新 pin;旧 `ff476ad7…` 已不在运行镜像中) | +| Python / websockets | 3.11.17 / 15.0.1(与旧镜像同版本,隔离变量只有 SDK) | +| 端口/挂载 | `8200:8000`;`config/`、`data/`(未变) | +| CapsWriter 服务端 | PID 22665、启动时间、HEAD `6b7a2b82…`、`/health`=200 —— 部署前后**完全不变** | +| `curl 127.0.0.1:8200/livez` / `/health` | 200 / 200 | + +四文件升级后 SHA-256 **与 §1.2 逐字符相同**(部署后即时、验收后各复读一次,共三次读数全等)。 + +> 健康是必要非充分:本卡的通过依据是 ImageID/StartedAt/SDK 哈希/GIT_SHA 四项同时变更,不是 `Health=healthy`。 + +## 4. 真实 API 验收(判据 4) + +### 4.1 请求契约(白名单 payload) + +- 入口:`POST http://127.0.0.1:8200/api/transcribe`(生产宿主 loopback,即生产容器本身;公网入口存在历史 TLS 现象,与本卡无关,未混入)。 +- 鉴权:`Authorization: Bearer `,token 由服务器内 `config/users.json` 读出(选第一个 `enabled=true` 的用户, + 共 6 个用户全部 enabled),写入 `0600` 头文件后以 `curl -H @file` 传入 —— **不进 argv、不进 stdout、不进本报告**。 + 仅记录:token 长度 67,SHA-256 前 8 位 `5527876e`(用于事后核对是同一枚凭据,不泄露凭据本身)。 +- 请求体(全部字段,无敏感值): + +```json +{ + "url": "https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_zh.wav?v=sdk65-261004-112555", + "use_speaker_recognition": false, + "notification_config": {"channel": "none"}, + "processing_options": {"calibrate": false, "summarize": false, "infer_speaker_names": false, "chapters": false} +} +``` + +字段选择的理由: +- `use_speaker_recognition=false` → 引擎为 **CapsWriter**(`transcription.py:1066`:`"普通转录(CapsWriter)"`), + 明确排除 FunASR 与字幕路径。 +- `processing_options` 全 false → 只转录,**不产生 LLM 耗时**(实测 `llm_processing: 3ms`,未调用任何 LLM), + 因此耗时数字就是纯 ASR 时间,不需要拆分估算。 +- URL 加唯一 query `?v=sdk65-261004-112555`:本仓 HTTP API **不存在** `force_refresh`/跳缓存参数 + (`TranscribeRequest` 无该字段,全仓 `force_refresh` 只出现在 FunASR/下载器内部),因此按卡面允许的 + 「原公开 URL + 无敏感唯一 query」路径取得全新 `view_token` 与全新缓存键;**没有删除任何既有缓存**。 +- `notification_config.channel="none"` 的实际效果见 §6 偏差 D2(未生效)。 + +### 4.2 缓存与引擎证据(不是缓存命中) + +按时间顺序取自容器日志(`docker logs --since 2026-10-04T11:25:50Z --until …T11:26:20Z`,按 `task_id` 与事件关键字白名单投影): + +``` +19:25:55 [缓存检测] 跳过缓存检查 (platform=generic, is_generic=True) +19:25:55 [缓存检测] ❌ 缓存未命中,准备下载和转录 +19:25:56 文件下载成功: data/temp/task_task_7c71ffc0e4f64b3bbdf47a46b65ada8a/asr_example_zh.wav (大小: 0.17 MB) +19:25:56 [perf] … | download: 248ms (OK) +19:25:56 已配置CapsWriter客户端,服务器: :6016 +19:25:56 调用CapsWriter客户端转录文件: …/asr_example_zh.wav +19:25:56 开始转录文件: …/asr_example_zh.wav (尝试 1/5) +19:25:56 transcription_deadline duration=unknown fallback=sdk_auto +19:25:56 输入数据: text_accu=20 字符, tokens=20, timestamps=20 +19:25:56 Segments 生成完成: 1 个片段 +19:25:56 Segments 统计: 总时长=3.94s +19:25:56 已生成: data/workspace/asr_example_zh.txt / asr_example_zh_funasr.json +19:25:56 [perf] … | transcription: 299ms (OK) +19:25:56 [perf-summary] … | total: 575ms +19:25:56 任务状态更新: … -> success +19:25:56 terminal CAS won: … -> success +``` + +- 「缓存未命中」在前、下载与 CapsWriter 调用在后 → 引擎确实被调用。 +- 新写入的缓存条目 `generic/df618b74f2c1e02b` 目录与全部 4 个文件的 mtime 均为 `2026-10-04T19:25:56`, + 即本次任务新建;随后 4 次 `get_cache: 缓存命中` 发生在 `save_cache` **之后**(日志行号 76-77 写、96-103 读), + 是本次自己写入产物被 LLM 层回读,不是历史缓存冒充。 +- 全窗口 `grep -ai "timeout|重试|retry|code=timeout"` **零命中**;`开始转录文件 (尝试 1/5)` 只出现一次,无重试。 + +### 4.3 服务端同任务证据(Mac Studio,只读) + +`capswriter_server_main/logs/pm2-out.log`(行 1743-1751): + +``` +2026-10-04T19:25:55: 客户端已连接: : +2026-10-04T19:25:55: 音频文件接收完毕,时长 5.55s +2026-10-04T19:25:56: 模型输出:… +2026-10-04T19:25:56: 格式化后:… +2026-10-04T19:25:56: 客户端已断开: : +``` + +四元组关联(同一任务的唯一指纹): +1. 源 IP 为 n305 宿主的内网地址(私网地址按公开仓出站扫描规则脱敏); +2. 服务端收得音频时长 **5.55s**,与公开样本 `5.546688s` 及卡面基线一致; +3. 连接→断开窗口 `19:25:55 → 19:25:56`(≈1s),与 API 侧 `transcription: 299ms` 同窗口; + **旧 pin 的签名行为是客户端挂到 120s 预算耗尽才断开(历史 `task_cc2eeeeb…` 即此形态),本次 1s 内断开。** +4. 服务端返回非空模型输出,API 侧据此生成 1 个 segment、20 字符时间戳序列(`text_accu=20 字符`)。 + +(服务端返回文本与产物文本一致;按安全约束本报告只记长度,不回显正文。) + +### 4.4 终态与产物 + +| 项 | 值 | +| --- | --- | +| `POST` 返回 | HTTP 202,`code=202`,`data={task_id, view_token}` | +| task_id | `task_7c71ffc0e4f64b3bbdf47a46b65ada8a` | +| 平台 / 引擎 | `platform=generic`,CapsWriter(非 FunASR、非字幕) | +| 终态 | `success`(`GET /api/task/` 与 DB `task_status.status` 一致) | +| POST → 终态 | **11.232s**(11:25:55.683Z → 11:26:06.915Z;含排队与一次轮询间隔) | +| **纯 ASR 耗时** | `transcription: 299ms`;整条管线 `total: 575ms`(远低于 120s 预算) | +| 转录产物 | `data/cache/generic/2026/202610/df618b74f2c1e02b/transcript_capswriter.txt` = **60 字节(非空)**;`transcript_capswriter.json` = 375 字节;`llm_calibrated.txt` = 60 字节 | +| workspace 产物 | `data/workspace/asr_example_zh.txt` = 60 字节;`asr_example_zh_funasr.json` = 375 字节 | +| 公开样本字节 | n305 独立下载核验:`http=200`、`177572` 字节、SHA-256 `a1bd32dc78493c123f9625a66deee562aed2895f53fbc39f2cca3be7e6f4f20f`,与卡面基线逐位相同;**带本次 `?v=` query 的同一 URL 下载结果字节数与哈希完全相同** | +| 临时文件 | 任务结束即被 `tempfile_manager` 清理(`释放 0.17 MB`),符合既有契约 | +| 通知 | 见 §6 偏差 D2(抑制未生效,产生了一次运维侧通知) | + +只发了 **1 次**真实验收任务(卡面允许上限 2),一次即取得完整证据。 + +## 5. 副作用清单与清理 + +- 副作用共 3 处:① 推一个新 tag 到 GHCR;② 生产容器换镜像(一次 recreate,含一次健康等待); + ③ 一个真实转录任务(新建缓存条目 `generic/df618b74f2c1e02b`、一条 `task_status` 行、一条 + `task_terminal_notifications` 行、一次对外通知)。除此之外无任何写操作。 +- 本卡自建的临时产物(`/tmp/sdk65-verify`:auth 头文件、请求/响应 JSON、窗口日志、两份样本下载) + 已 `rm -rf` 清理;`ls` 复核目录不存在。 +- **未**做任何数据/缓存删除、镜像 prune、凭据轮换、compose/config 改动、CapsWriter 服务端操作、 + DNS/tunnel 操作。既有旧缓存一条未动;生产新生成的缓存条目按契约保留(它是这次真实任务的产物)。 +- 未回滚(无需回滚)。旧 immutable 镜像 `sha256:950a1600…` 仍在 n305 本地可用,回滚路径保持可用。 + +## 6. 偏差与未满足项(如实记录) + +**D1|判据 4 的「服务端同 UUID `task_end(done)` / `result_dispatched`」在生产日志里不存在(未按字面满足)。** +- 事实:服务端 `pm2-out.log` 近 5000 行中 `uuid` / `task_end` / `result_dispatched` / `receive_complete` + 出现次数均为 **0**;服务端日志格式只打印连接/断开/时长/模型输出。 +- 事实:镜像内 SDK 源码 `/app/.venv/.../capswriter_asr/client.py` 只在第 338 行 `task_id = str(uuid.uuid4())` + 生成 UUID,**全文件无任何 uuid/task_end/result_dispatched 的日志语句**。 +- 因此该事件名只可能来自 triage 期的 wire-probe/代理抓包,不是生产可观测面。再发一次验收任务同样拿不到, + 故未消耗第 2 次额度;改用 §4.3 的四元组关联(源 IP + 音频时长 + 连接窗口 + 非空输出)作为同任务证据。 +- 需要主脑裁决:是否把「UUID 级关联」降级为「服务端四元组关联」写入 #166 的验收口径。 + +**D2|`notification_config.channel="none"` 不是抑制路由,验收任务产生了一次对外通知(计划外副作用)。** +- 事实:`tasks.py` 中 `effective_channel` 仅在 `notification_config.webhook` 为真时才被赋值; + 只给 `channel` 不给 `webhook` 时,`effective_channel` 保持 `None`,全部渠道照常通知。 + `TranscribeRequest.wechat_webhook` 走空串同样无效(falsy,且被 SSRF 校验)。 +- 事实:窗口内 WeCom `Message … sent successfully`、Feishu `message … sent successfully` 多条, + 且 `task_terminal_notifications` 中本任务行 `notified_at='2026-10-04 11:25:56'`、`attempts=1` + —— 终态通知确实被投递,不是「排入队列但未发送」。 +- 归因已排除他因:同窗口 `api_audit_logs` 无其它租户请求;`task_terminal_notifications` 在 + `11:25:00–11:27:00` 区间只有本任务一行;服务启动时的积压投递也无 pending 行(pending 查询为空)。 +- 影响面:收信方是运维自己的企微/飞书频道,与该服务每一次生产任务的通知同类同渠道;不含凭据, + 但确实是一条计划外的对外消息(view 链接 + 完成状态 + 校对正文)。 +- 未做:没有为了「下次能抑制」去改 `config/config.jsonc` 的全局 webhook(卡面禁止),也没有改代码。 +- 建议(交主脑裁决,本卡不动代码):`notification_config` 目前无法单独表达「本任务不通知」, + 属于请求契约缺口;若需要验收/演练静默,应新增一个显式开关而不是复用 `channel`。 + +**D3|卡片指定的必读文件 `agent-config/claude/skills/docker-deploy/SKILL.md` 不存在。** +- 该路径 `ls` 返回 `No such file or directory`;实际承载 n305 部署事实的是 + `agent-config/memory/repos/VideoTranscriptAPI/n305-docker-deploy.md`(已读并按其约束执行: + 不 scp compose、只读服务端、digest 钉版本)。另读 `agent-config/claude/skills/deploy-ops/SKILL.md`。 + +**D4|主脑基线不可用(`gh api request failed`)。** +- 本次未跑任何 CI/测试用例(阶段为 verifying,只部署 + 生产取证),因此不存在「继承红 / 新红」判定对象。 + **继承红:未能判定**(无基线可比);**新红:无**。 + +## 7. 四问 + +**踩坑(最隐蔽的一个)**:`notification_config.channel` 在没有 `webhook` 时被静默忽略, +「指定一个不存在的渠道名 = 不通知」这个直觉在代码里不成立 —— 抑制失败发生在 HTTP 层, +而所有下游证据(`notified_at` 被写、企微/飞书 sent successfully)都显示「成功」,不主动查通知侧就发现不了。 +第二隐蔽的是:日志里 4 条 `缓存命中` 出现在同一次任务里,但它们全在 `save_cache` **之后**, +只看关键字会误判成「命中缓存冒充引擎成功」;必须用日志行号定序。 + +**闸(拦住错误动作的机制)**: +- 服务器 compose 用 `image: ${VIDEO_TRANSCRIPT_IMAGE:?…}` 钉 digest,`pull_and_deploy.sh` 在 + `COMPOSE_FILE_OVERRIDE` 存在且渲染不出候选 digest 时直接 `[ERROR]` 退出 → 结构上杜绝了 + 「顺手 scp 仓内 compose 覆盖端口 8200」这条路。 +- 镜像推送前先跑本地 `docker run` 核对 SDK `client.py` 哈希,不信任「构建成功」等于「构建物正确」。 +- 部署判据锚定 ImageID/StartedAt/SDK 哈希/GIT_SHA,不锚定 `Health=healthy`。 +- registry digest 用 `docker manifest inspect` 独立读,不拿 `docker push` 输出的 digest 或本地 tag 当结果。 + +**偏差**:见 §6 的 D1–D4。核心一条(D2)已经发生了一次计划外对外通知,无法在不改全局配置的前提下避免。 + +**最贵的一步**:核实「这次成功到底是不是缓存冒充 / 引擎有没有真被调用」。 +耗时不在命令执行上,而在把三条独立证据链对齐到同一秒级窗口: +容器日志的「缓存未命中 → 下载 → CapsWriter 调用 → transcription 299ms」、 +CapsWriter 服务端日志的「5.55s 音频 + 1s 内断开」、以及缓存目录 mtime 全为 `19:25:56`。 +如果只信 API 返回的 `success`,旧 pin 的「final 已送达但同步入口 120s 后 timeout、零产物」同样可能 +在别处被兜底成 success —— 这次之所以能确证,是因为服务端断开时间与 `transcription` 耗时都远低于 120s 预算。 + +## 8. 可复核探针契约(主脑独立复跑用) + +```bash +# 1) 生产镜像身份(ImageID / StartedAt / 健康与重启) +ssh n305 'docker inspect video-transcript-api --format \ + "Image={{.Config.Image}} ImageID={{.Image}} StartedAt={{.State.StartedAt}} Health={{.State.Health.Status}} Restarts={{.RestartCount}}"' + +# 2) .deploy-image 是否等于 registry 实际 digest +ssh n305 'cat /opt/media/VideoTranscriptAPI/.deploy-image' +docker manifest inspect --verbose ghcr.io/zj1123581321/video-transcript-api:ffbddbd10042-sdk65-261004 \ + | grep -m1 '"digest"' + +# 3) 镜像内 SDK pin / Python / websockets / GIT_SHA(白名单) +ssh n305 'docker exec video-transcript-api /app/.venv/bin/python -c " +import hashlib,sys,os,capswriter_asr.client as c +print(hashlib.sha256(open(c.__file__,\"rb\").read()).hexdigest()); print(sys.version.split()[0]); print(os.environ[\"GIT_SHA\"])"' + +# 4) 四文件未变(与 §1.2 逐字符比对) +ssh n305 'sha256sum /opt/media/VideoTranscriptAPI/docker-compose.yml \ + /opt/media/VideoTranscriptAPI/config/config.jsonc \ + /opt/media/VideoTranscriptAPI/config/users.json /opt/media/VideoTranscriptAPI/.env' + +# 5) CapsWriter 服务端未动(PID / 启动时间 / HEAD / 健康) +# CAPSWRITER_HOME = 服务端 pm2 托管的服务仓根目录(绝对路径属私有基础设施信息,报告内不落盘) +ssh mac-studio 'ps -eo pid,lstart,command | grep start_server.py | grep -v grep' +ssh mac-studio 'cd "$CAPSWRITER_HOME" && git rev-parse HEAD' +ssh mac-studio 'curl -s -m 5 -o /dev/null -w "%{http_code}\n" http://127.0.0.1:6016/health' + +# 6) 本次任务的产物与终态(只投影长度与状态,不回显正文/标题) +ssh n305 'docker exec video-transcript-api sh -c \ + "stat -c \"%n %s\" /app/data/cache/generic/2026/202610/df618b74f2c1e02b/*"' +ssh n305 'docker exec -i video-transcript-api /app/.venv/bin/python -c " +import sqlite3;con=sqlite3.connect(\"file:/app/data/cache/cache.db?mode=ro\",uri=True); +print(list(con.execute(\"select task_id,status,completed_at from task_status where task_id=?\",(\"task_7c71ffc0e4f64b3bbdf47a46b65ada8a\",))))"' + +# 7) 服务端同任务日志(只读,按时间窗 grep 关键字,不整段回显) +ssh mac-studio 'grep -a "19:25:5" "$CAPSWRITER_HOME/logs/pm2-out.log" | cut -c1-120' +``` + +安全约束遵守情况:全程未打印 `.env` / `config.jsonc` / `users.json` 内容、未打印 token 值、未回显转录正文/标题、 +未输出原始日志大段(均为白名单字段投影 + 行号定序);每条网络请求单独 `curl` 并显式落私有文件; +SSH 多行脚本一律走 stdin,未拼多层引号;凭据只以长度与哈希前 8 位形式出现。 \ No newline at end of file diff --git a/docs/sessions/sdk65-upgrade-261004/progress/deploy-progress.md b/docs/sessions/sdk65-upgrade-261004/progress/deploy-progress.md new file mode 100644 index 00000000..516698a8 --- /dev/null +++ b/docs/sessions/sdk65-upgrade-261004/progress/deploy-progress.md @@ -0,0 +1,70 @@ + + +# SDK 65 部署进度(card/sdk65-deploy-n305-261004) + +- 派发:`dlg-20261004-111840-b18694` +- Issue:#166(本卡不关) +- 固定主干:`ffbddbd10042b8f56f81af711252aa1a257d3a23`(PR #168 MERGED) +- 锁定决策:只换 API 镜像;SDK pin `b0818dc7859d1d8100e42f5c70cb75d34da422f7`; + server/config/users/env/compose 零更改;凭据不轮换。 + +## 里程碑 + +- [x] M0 现场核查(2026-10-04T11:21Z):worktree 干净,`git log -1` = `ffbddbd1`,`git diff --stat HEAD` 空。 +- [x] M0.1 确认未接 D3 自动部署:本仓 `.github/workflows/` 只有 `gate.yml` / `gate-disposition.yml` / + `gate-shadow.yml`(无 build/push/deploy job);gate-hub `runner/fleet-manifest.json` 只把本仓列在 + `managed_callers`(gate-v2 调用者),`.github/workflows/gate.yml` 只 `uses:` gate-v2 + (quality/primary/aggregator/notify,无部署);n305 `crontab -l` 无本项目部署条目; + 生产 compose 用 `image: ${VIDEO_TRANSCRIPT_IMAGE:?...}` 钉 digest,无自动拉取。 +- [x] M0.2 升级前生产取证(n305): + - 容器 `video-transcript-api`:`Image=ghcr.io/zj1123581321/video-transcript-api@sha256:950a1600da44a341b96886dc47e30585b97c227aebbac6f411307a8353ed0487`, + `ImageID=sha256:ca46d3a86cebe92684b2cd1ac7d6f1e311ec51890381160c059b08bf46ab8374`, + `StartedAt=2026-10-03T16:11:07.973881807Z`,`Health=healthy`,`RestartCount=0`。 + - 端口 `8200->8000`,挂载 `config/`、`data/`。 + - `.deploy-image` = 同一 digest `sha256:950a1600…`。 + - 容器内 `GIT_SHA=ff92a175243d`(旧 pin 构建)。 + - 容器内 SDK `client.py` SHA-256 = `ff476ad7cd40401b7ed77c7606cb4fc14dc3d529043c29c4714b199c13423cdf`(**旧 pin,缺陷在生产在线**), + Python 3.11.17,websockets 15.0.1。 + - CapsWriter 服务端(mac-studio,只读):PID 22665,`start_server.py`,启动于 Thu Oct 1 18:36:04 2026, + 服务仓 HEAD `6b7a2b82fbc3ebe862250a8804902e5bf37f9211`,`/health`=200。部署前后必须不变。 + - 四文件升级前 SHA-256: + - `docker-compose.yml` `7b0a89b50de84f71c480c8ef7398d64d479bbaf429805b9401d748294e498188` + - `config/config.jsonc` `a349b81837e0cb06e2ce3dd8ff670df7e4c107b00e911a8b60670a8a334c25cc` + - `config/users.json` `237cefb5a2d3bda3ea4e6a227750c348f34475cba1dd0fa804c82458cf28f7ad` + - `.env` `5885382b22606aafaa4aeb55d529d56739c6555347286183dcae4ddf577b3b1e` + - 服务器 `docker/pull_and_deploy.sh` SHA-256 = `1d98c3eb9a2f5c6e4e4714b5c54d0915645ff1ae08a834ef7f065eb81d1d5db1`, + 与本 worktree 仓内同名脚本逐字节相同 → **无需同步,无 CRLF 问题**。 +- [x] M1 构建并推送:`ghcr.io/zj1123581321/video-transcript-api:ffbddbd10042-sdk65-261004` + (`--provenance=false --sbom=false --build-arg GIT_SHA=ffbddbd10042b8f56f81af711252aa1a257d3a23`)。 + 推前本地镜像内核对 SDK `client.py` = `eccec1a69b81c4a2360d33e15f0ddb8adffb85725dc93d41f8e3150a0863f6c7`。 + registry 独立复核 digest = `sha256:20d683a70c95ef976def9f2c78f1eb31e25bb7fe69634860cfdf9e176d643340`。 +- [x] M2 部署(`COMPOSE_FILE_OVERRIDE=/opt/media/VideoTranscriptAPI/docker-compose.yml`, + 未触发 `[MIGRATE]`):容器 `ImageID=sha256:6116e950…`(= 本地构建产物 config digest)、 + `StartedAt=2026-10-04T11:24:17.07081266Z`、`Health=healthy`、`RestartCount=0`(两次复读未增长)、 + `.deploy-image` = registry 实际 digest、容器内 `GIT_SHA=ffbddbd10042b8f56f81af711252aa1a257d3a23`、 + SDK = `eccec1a6…`(旧 `ff476ad7…` 已不在运行镜像)。四文件 hash 与 M0.2 逐字符相同。 + CapsWriter 服务端 PID 22665 / HEAD `6b7a2b82…` / health 200 全程不变。 +- [x] M3 真实 API 验收(仅 1 次,上限 2):`POST http://127.0.0.1:8200/api/transcribe` + → HTTP 202,`task_7c71ffc0e4f64b3bbdf47a46b65ada8a`,终态 `success`, + POST→终态 11.232s,纯 ASR `transcription: 299ms`,管线 total 575ms。 + 缓存未命中 → 下载 `asr_example_zh.wav`(0.17MB)→ CapsWriter 调用(尝试 1/5,无重试/无 timeout)→ + 1 个 segment、`text_accu=20 字符`;产物 `transcript_capswriter.txt` 60 字节(非空)。 + 服务端同任务:源 IP 为 n305 内网地址(已脱敏)、音频 5.55s、19:25:55 连接 → 19:25:56 断开(≈1s,非 120s)。 +- [x] M4 样本字节核验:n305 独立下载 `http=200 / 177572 bytes / sha256=a1bd32dc78493c123f9625a66deee562aed2895f53fbc39f2cca3be7e6f4f20f`, + 带本次 `?v=` query 的同一 URL 字节数与哈希完全相同。 +- [x] M5 清理:本卡 `/tmp/sdk65-verify`(含 0600 凭据头文件)已删除并复核不存在; + 未删任何既有缓存/数据,未 prune 镜像,未轮换凭据,未改 compose/config。 +- [x] M6 报告落盘:`docs/sessions/sdk65-upgrade-261004/deploy/n305-verification.md`。 + +## 遗留(交主脑裁决,本卡未自行处理) + +- **D1**:判据 4 的「服务端同 UUID `task_end(done)`/`result_dispatched`」在生产日志格式下结构性不存在 + (服务端日志近 5000 行这三个关键字计数均为 0;镜像内 SDK `client.py` 只生成 UUID、全文件无相关日志语句)。 + 已改用四元组关联(源 IP + 音频 5.55s + 连接窗口 ≈1s + 非空输出)。再发一次任务也不会改变,故未消耗第 2 次额度。 +- **D2**:`notification_config.channel="none"` **不是**抑制路由 —— `tasks.py` 只在同时给出 `webhook` 时才赋 + `effective_channel`,本次验收因此产生了一次计划外的企微/飞书运维通知(`task_terminal_notifications.notified_at` + 已被写、attempts=1)。已排除他因(同窗口无其它租户请求、无积压 pending 行)。未改全局配置、未改代码。 +- **D3**:卡面指定的 `agent-config/claude/skills/docker-deploy/SKILL.md` 不存在;实际依据 + `agent-config/memory/repos/VideoTranscriptAPI/n305-docker-deploy.md` + `claude/skills/deploy-ops/SKILL.md`。 +- **D4**:主脑基线 `gh api request failed`,本卡未跑 CI/测试(阶段 verifying,只部署 + 生产取证)→ + 继承红**未能判定**,新红**无**。 \ No newline at end of file diff --git a/docs/sessions/sdk65-upgrade-261004/reviews/downstream-verdict.md b/docs/sessions/sdk65-upgrade-261004/reviews/downstream-verdict.md new file mode 100644 index 00000000..e8dbaf1a --- /dev/null +++ b/docs/sessions/sdk65-upgrade-261004/reviews/downstream-verdict.md @@ -0,0 +1,86 @@ + +failure-visibility: clean + +# SDK65 下游接入独立审查结论 + +## 结论 + +**Review verdict:PASS;无 P1/P2 finding。** + +审查对象固定为 `ff92a175243dde36143da867a95e2fdad384e16c..ac429d6e54ec8ee988f9dc6239bd72c3a0d8daad`,未把 review 工作树切换到目标分支,也未把目标分支合并进 review 分支。冻结 diff 中生产接入只把 `capswriter-asr` 从旧 pin 换到锁定的 `b0818dc7859d1d8100e42f5c70cb75d34da422f7`;回归测试覆盖真实同步入口、真实 SDK 序列化帧、真实 loopback WebSocket 服务、保持连接、UUID 回传、非空产物和及时返回。 + +## 不变式核对 + +| 条款 | 结果 | 证据 | +| --- | --- | --- | +| I1 final 到达且连接保持打开时及时返回 Transcript 和非空产物 | PASS | 目标 pin 的新增用例在真实 CPython 3.11.15、临时 detached worktree、真实 ffmpeg 与真实 `websockets.serve` 下连续 5 次通过;每次均断言 final 先到、同步入口随后返回、三类产物非空且正文等于服务端文本。 | +| I2 不通过调大预算掩盖问题 | PASS | `pyproject.toml:87`、`uv.lock:196`、`uv.lock:4511` 只更新 SDK rev;冻结 diff 不包含 `capswriter_client.py`、预算常量或 SDK 默认预算语义。真实 CI 的全量 pytest 也通过。 | +| I3 真实解释器、真实 SDK、真实序列化边界 | PASS | `tests/unit/test_capswriter_sdk_transport.py:47-169` 在子进程内启动真实 SDK 与 `websockets.serve`;父子进程用 SDK `client.py` SHA-256 互证,运行时报告 `eccec1a69b81c4a2360d33e15f0ddb8adffb85725dc93d41f8e3150a0863f6c7`。 | +| I4 不访问公网/生产 | PASS | 测试服务绑定 `127.0.0.1:0`,媒体由测试现场生成 0.2 秒 WAV;运行期间的仓库网络守卫明确阻断非 loopback AF_INET/AF_INET6 连接。 | +| I5 pin、lock、运行时 SDK 一致 | PASS | 目标提交本地 SHA 与 `origin/card/sdk65-upgrade-261004` 远端 SHA 均为 `ac429d6e54ec8ee988f9dc6239bd72c3a0d8daad`;`pyproject.toml` 与 `uv.lock` 指向同一 pin;临时环境实际安装 `capswriter-asr` b0818dc。 | +| I6 3.12 以上显式 skip,不把 skip 当回归通过 | PASS | `tests/unit/test_capswriter_sdk_transport.py:249-255` 显式 skip;PR #168 真实质量作业日志使用 CPython 3.11.17,故本次 CI 没有落入 skip。未来 CI 若切到 3.12,仍会失去该回归的观测能力,这是设计已披露的盲区。 | +| I7 子进程硬截止和清理边界 | PASS | `tests/unit/test_capswriter_sdk_transport.py:271-278` 使用 45 秒父进程硬截止,子进程正常/异常路径均 `os._exit`;5 次运行后无临时 detached worktree 残留。旧 pin 红验也在约 6.028 秒以断言失败结束,没有触发 45 秒 kill。 | + +## Finding 分诊 + +没有 P1/P2 finding。累计 diff 为 `6 files changed, 367 insertions(+), 3 deletions(-)`,超过任务卡 `Diff-Lines-Target: 120` 与 `Diff-Lines-Hard: 250`;这是 delegate 预算的 `over_hard` 状态,按预算规则只作状态记录、不作为阻塞性 review finding。其中文档新增 128 行,非文档改动为 248 additions、3 deletions。 + +## 独立测试证据 + +### 目标 pin 绿验 + +在 `scratch-worktree.sh` 创建的 `ac429d6e54ec8ee988f9dc6239bd72c3a0d8daad` detached 沙箱中,以沙箱自己的 `.venv` 执行: + +```text +uv venv --python .venv +uv sync --frozen +uv run --frozen python -VV +uv run --frozen python -c "import hashlib, importlib.metadata as md; import capswriter_asr.client as c; print(md.version('websockets')); print(hashlib.sha256(open(c.__file__, 'rb').read()).hexdigest())" +for run in 1 2 3 4 5; do + uv run --frozen pytest -q tests/unit/test_capswriter_sdk_transport.py::test_final_result_returns_and_writes_products_on_python311 +done +``` + +结果:CPython 3.11.15、`websockets=15.0.1`、SDK 文件 SHA 与 I5 一致;`GREEN_RUN=1..5` 均 `[100%]`,scratch worktree 已清理。 + +### 旧 pin 红验 + +在 base `ff92a175243dde36143da867a95e2fdad384e16c` 的独立 detached 沙箱中,仅拷入目标新增测试文件后执行同一用例。结果为 `RED_EXIT=1`、`EXPECTED_ASSERTION_RED=1`;失败落在 `tests/unit/test_capswriter_sdk_transport.py:306` 的 `payload["success"] is True` 断言,子进程日志显示: + +```text +elapsed=6.028s +code=timeout +原因: 转录超过deadline_total:远端转录阶段超时 +``` + +这是预期的 AssertionError 红,不是导入错误、依赖错误或硬超时 kill;旧 pin 实际为 `858c6b975d8bdd2be0e47ac5a36483119894c529`,锁定 `websockets` 仍为 15.0.1。 + +### 真实 CI + +PR #168 真实 `gate / quality` 作业日志记录 `Using CPython 3.11.17`,执行 `uv run --frozen pytest -q`,进度到 `[100%]` 且作业 conclusion 为 `SUCCESS`。这是 draft PR:`gate / primary` 为 `SKIPPED`,aggregator 的 draft 绿不能当作主审已执行;本报告仅把 quality 作业作为运行时与全量测试证据。 + +### OCR 前置扫描 + +`ocr-review` envelope 为 `status=reviewed`、`coverage=complete`、`findings=[]`。外层 zsh 收尾因误写特殊变量 `status` 返回非零,但 envelope 已完整产出且主腿为 `minimax`;不把该 shell 收尾错误误判成 OCR skipped。 + +## 已知盲区与未判定项 + +- 3.12 以上按设计 skip,不能证明该环境仍守住 ≤3.11 的回归;本次已用真实 CI 3.11.17 和本地 3.11.15 覆盖。 +- 主干基线作业在派发时 `gh api request failed`,因此无法用同名首失败步骤判定继承红;本次结论来自冻结 diff 和独立运行证据。 +- 未调用公网或生产 ASR,也未把不存在的媒体夹具 skip 当作本次引入的通过;新增回归使用现场合成媒体并实际执行。 + +## 四问记录 + +- **踩坑**:review 工作树 `HEAD` 是 base `ff92a175`,冻结审查头是另一个已验证的 `ac429d6e`;若直接用 `HEAD` 会漏掉全部目标 diff。 +- **闸**:pin/lock 唯一性、运行时 SDK SHA、CPython 3.11、真实 WebSocket/health、UUID、保持连接、产物、旧 pin AssertionError 红和 5 次绿均已逐项核对;未部署、未发公网 ASR 请求、未修改业务源。 +- **偏差**:当前默认解释器是 3.12.3,因而所有关键运行改用 uv 管理的 3.11.15;CI 实际为 3.11.17。记忆探针原样失败:`memory 巡检报告不可用:memory_dir_mismatch(memory-doctor/latest.json)`。巡检摘要:`summary: orphan 0 owned 0 unattributable 0 too-new 0 recent-7d 0 stale-over-7d 0 missing_ledger_repos 0`。 +- **最贵步骤**:目标 pin 沙箱的 `uv sync --frozen` 加 5 次真实子进程回归,以及读取真实 CI 全量测试日志;它们分别验证了依赖可安装性、重复性和 3.11 消费环境,而不是只看静态 diff。 + +## 实际 SHA 与现场 + +```text +base local: ff92a175243dde36143da867a95e2fdad384e16c +head local: ac429d6e54ec8ee988f9dc6239bd72c3a0d8daad +head remote: ac429d6e54ec8ee988f9dc6239bd72c3a0d8daad +review tree: clean before verdict write; no scratch worktree remained +``` diff --git a/docs/sessions/triage-261004/caps-short-diagnosis.md b/docs/sessions/triage-261004/caps-short-diagnosis.md new file mode 100644 index 00000000..bd1af9f3 --- /dev/null +++ b/docs/sessions/triage-261004/caps-short-diagnosis.md @@ -0,0 +1,518 @@ + + +# #166 CapsWriter 短音频失败边界诊断(只读) + +派发:`dlg-20261004-023618-c714a0`(修订 `dlg-20261004-030118-009132`);代码基线:`ff92a175243dde36143da867a95e2fdad384e16c`(= 生产运行版本);Verify-Mode `diagnostic-report`。 + +> **修订说明(本版)**:上一版(`86e9d55a`)在归因上过强,本版按主脑审阅收窄—— +> ① SDK 文件 sha256 一致**只证明版本一致**,不能排除 SDK 侧缺陷; +> ② 本地假服务端「永不回复」**只证明客户端预算行为**,不能证明生产服务端没发 final; +> ③ 「分段缺陷」「时限太短」由「已排除」降为**未证实**(`is_final=true` 单帧只说明 SDK 发送侧,服务端如何做短尾分段未被观测); +> ④ 删除「只有该 wav 撞 120 秒下限」等基于同期任务耗时的推断——那些是**处理耗时**不是媒体时长,没有解码后时长就算不出它们的预算值; +> ⑤ `GenericDownloader.download_file` 不调用 `_validate_media_file` **不等于本仓从未 ffprobe**(共享音轨准入等路径可能探测过但不写 `last_media_duration`);只保留「`last_media_duration` 缺失」这一条已知; +> ⑥ 时间线中的媒体标题/文件名全部匿名化; +> ⑦ 墙钟按实际执行起止纠正为约 24 分钟(上一版「约 50 分钟、超软限」的说法不成立,已撤回); +> ⑧ 探针源码与真实输出从 `/tmp` 迁入本文附录(附录 A/B)。 + +## 一句话结论 + +**客户端侧已证**:这次失败发生在客户端计时边界——本仓在拿不到 `last_media_duration` 时不传 `deadline_total`,SDK 用自动预算(`client.py:418` 的 `max(120, 解码后音频时长 + 60)`),到点后客户端主动放弃并显式报 `AsrError(code=timeout, 「转录超过自动预算:远端转录阶段超时」)`。 +**未证**:真实网络传输、生产服务端的接收与终态、以及「服务端为什么没有在该预算内给出结果」。根因保持未知。 + +## 一、事实(真实生产调用观测) + +时间均为日志本地时间(UTC+8),UTC = 本地 − 8h。媒体身份一律匿名化。 + +| 观测 | 值 | 出处 | +| --- | --- | --- | +| 输入文件(样本 A,两次失败同一文件) | wav,直链下载成功,0.17 MB | `app.log` `downloaders.generic:download_file:717` | +| 下载器 | `downloaders.generic` | 同上 | +| 时限传递 | `transcription_deadline duration=unknown fallback=sdk_auto` | `capswriter_client:_transcription_deadline:141`,两个失败任务各一条 | +| 终态消息 | `code=timeout, 原因: 转录超过自动预算:远端转录阶段超时` | `capswriter_client:log:648` | +| 尝试次数 | `尝试 1/5`,无重试 | 同上 | +| 转录阶段耗时 | `120190ms` / `120183ms` | `perf_tracker:track:68` | +| 任务时间 | 样本 A 第一次 00:22:18→00:24:19;第二次 00:47:18→00:49:19 | 同上 | +| 容器内 SDK 文件指纹 | `capswriter_asr/client.py` sha256 `ff476ad7…34cdf`,与本仓 pin `858c6b9` 同名文件 sha256 相同 | `docker exec sha256sum` vs 本地 `sha256sum` | +| ASR 端点与探活 | `ws://:6016`(内网端点,公开仓脱敏),GET `/health` → `protocol_version=2, role=server, encodings=[f32le,s16le,flac,ogg_opus], model=paraformer` | 生产 config 单键 grep + n305 单 URL GET | + +**关于 SDK 指纹的正确读法**:它只回答「生产跑的 SDK 代码与本仓 pin 的同一份」——排除的是「版本漂移」这一个解释。它**不能**排除 SDK 侧存在缺陷(例如帧构造、`is_final` 语义或预算算法的正确性),因为本卡没有对 SDK 做行为层面的正确性审计。 + +**关于 `/health` 的正确读法**:它只证明该时刻进程活着且声明协议 v2。它**不能**证明任何具体任务的服务端终态,也不能证明当时该进程就是处理这两个任务的实例(多实例/重启可能)。 + +## 二、本仓这一侧已经走通的路径(有观测支撑) + +``` +直链 wav → GenericDownloader.download_file(不写 last_media_duration) + → transcription.py: getattr(actual_downloader, "last_media_duration", None) → None + → transcriber.transcribe(audio, base) # 两参形状,media_duration 不传 + → capswriter_client._transcription_deadline(None) → None + 日志 fallback=sdk_auto + → 不带 deadline_total 调 transcribe_file_sync(encoding=flac, seg_duration=25, seg_overlap=2) + → SDK client.py:418 set_deadline(max(120, 解码后PCM时长+60)) → 5.55s 落在 120 秒下限 + → deadline_watch 到点 → AsrError(timeout, 转录超过自动预算:远端转录阶段超时), retryable=False + → 只试一次,任务 failed +``` + +**这里唯一可以说的**是:失败发生在客户端预算到点的那一刻,且该预算值由「无时长 → 自动预算下限」这一条决定。 +**这里不能说的**是:服务端在此期间做了什么。 + +## 三、未知(三态保留,不合并) + +| 层 | 状态 | 依据 | +| --- | --- | --- | +| 客户端预算与失败上报 | **已证** | 生产日志 + 本地 synthetic 探针逐字复现(见第五节、附录 B) | +| 真实网络传输(帧是否真的到达生产服务端、是否中途丢帧/断连) | **未知** | 生产无抓包、服务端无日志观测;本地探针的「实收帧」是本地假服务端的观测,对生产不成立 | +| 生产服务端对该任务的接收与终态(是否收到、是否处理、何时回 final、是否回了但客户端没等到) | **未知** | `/health` 不构成任务级证据;本卡禁止向生产 ASR 提交音频 | +| 「时限太短」 | **未证实**(既不能断言成立,也不能断言排除) | 缺「服务端实际处理这段音频需要多久」这一项观测 | +| 「短音频分段缺陷(5.55s 与 `file_seg_duration=25` 的关系)」 | **未证实**(同上) | 探针只看到 SDK **发送侧**是单帧 `is_final=true`;服务端如何切分、如何决定 final 时机**完全未被观测**,不能据此排除服务端侧的短尾/分段问题 | +| 输入音频真实身份与时长 | **未证实** | 见第六节 | + +## 四、同期任务时间线(只读投影,输入已匿名) + +**读法限制(重要)**:`perf` 的「转录阶段耗时」是**处理耗时**,不是媒体时长。没有 SDK 解码后时长(`samples_total/16000`)就无法算出这些任务各自的自动预算值,因此本表**不能**用来断言「谁撞了 120 秒下限」「谁没撞」「为什么只有某个输入失败」。上一版基于此表的强推断已删除。 + +| 本地时间 | 输入(匿名) | 时限传递 | 终态 | 转录阶段耗时 | +| --- | --- | --- | --- | --- | +| 10-03 18:14:58 | 样本 B(前一日任务) | `duration=93.09 value=492.4`(显式 `deadline_total`) | 不在本次查询范围 | — | +| 10-03 19:31→19:42 | 样本 C(m4a) | `duration=unknown` → sdk_auto | 成功 | — | +| 10-03 23:11→23:17 | 样本 D(m4a) | `duration=unknown` → sdk_auto | 成功 | — | +| 10-04 00:22:18→00:24:19 | **样本 A**(wav,0.17 MB) | `duration=unknown` → sdk_auto | **failed timeout** | 120190ms | +| 10-04 00:25:46→00:31:33 | 样本 E(m4a) | (窗口内) | 成功 | — | +| 10-04 00:31:19→05:25:00 | 样本 F(mp4,494 MB) | `duration=unknown` → sdk_auto | **failed timeout** | 17621038ms | +| 10-04 00:31:24→00:36:45 | 样本 G(m4a) | `duration=unknown` → sdk_auto | 成功 | 321208ms | +| 10-04 01:23:17→01:28:51 | 样本 H(m4a) | (窗口内) | 成功 | — | + +这张表唯一支持的结论很窄:**同一份 app.log 里,既有走 sdk_auto 成功的任务,也有 sdk_auto 失败的任务,且输入形态不止一种**。它不支持任何关于预算值大小、短音频特殊性的因果推断。 + +## 五、本地 synthetic 探针(不连生产 ASR) + +环境:worktree 内 `uv sync --offline` 建的 `.venv`,Python 3.11,SDK pin `858c6b9`;ffmpeg 生成 5.55s/16kHz/mono 正弦 wav(sha256 `c08fd13b…6aca7`,177678 bytes,ffprobe `5.550000`)。源码与真实输出见附录 A/B。 + +**边界声明**: +- 这些探针是 **synthetic** 的:输入是自造正弦波不是样本 A,服务端是本地假实现不是生产 ASR。**不构成生产复现**。 +- 探针 A 证明的命题:「本仓传给 SDK 的 kwargs 在各种 `media_duration` 取值下长什么样」+「`GenericDownloader.download_file` 之后 `last_media_duration` 仍为 None」。 +- 探针 B 证明的命题:「SDK 在**服务端永不回复**这一合成条件下,按自动预算/显式预算到点放弃,并给出该文案」。它**不**证明生产服务端没发 final——假服务端是被人为写成永不回复的。 +- 唯一一次真实外网访问:探针 A 早期版本误 mock 了 `requests.get`,而 `GenericDownloader` 走 `_safe_request` 钉 IP 路径,导致真的向合成域名 `example.com` 发了 3 次请求(全部 404,无凭据、无生产数据)。修正版 mock 的是 `_safe_request`,本次重跑未产生任何网络请求。因此本卡**不能**声称「全程无网络」。 + +结果摘要(明细见附录 B): + +- `media_duration=None` → kwargs 无 `deadline_total`; +- `media_duration=0.0` → `deadline_total=120.0`(数值上与自动预算下限相同的另一条路径;生产日志是 `duration=unknown` 而非 `duration=0.00`,故本例走的是前者); +- `media_duration=5.55` → `deadline_total=142.2`;`93.08898` → `492.35592`; +- `GenericDownloader.download_file` 真实执行后 `last_media_duration is None`,且其源码内无 `_validate_media_file` 调用; +- 假服务端条件下:不传 `deadline_total` → 120.2s 后 `AsrError(timeout, 转录超过自动预算:远端转录阶段超时, retryable=False)`;传 `142.2` → 142.2s 后 `AsrError(timeout, 转录超过deadline_total:…)`。 + +## 六、输入样本可复核程度:低 + +生产日志只留下「wav、0.17 MB」两项;该文件已随任务结束从 `data/temp` 清理(`ls data/temp` 为空)。5.55s 来自验收输入资料,**任务表与日志都没有媒体时长字段**。本地探针的 5.55s wav 是自造正弦波,只用于复现预算行为,不能替代原样本。因此「样本 A 确实是 5.55s 的中文语音」这一前提本身也属于未证实项。 + +## 七、修复归属建议(不下定论) + +- **本仓(可能,但需要证据支撑才成立)**:`last_media_duration` 缺失使这次落回自动预算下限。**不能**据此断言「服务端稍晚回包就必须改本仓」——即使把时长补上、显式按 5.55s 传 `deadline_total`,预算也只有 **142.2 秒**;若服务端实际需要的延迟大于 142.2 秒,改本仓同样会失败。要判定归属,至少需要:样本 A 的真实身份与时长、服务端实际处理延迟、以及延迟归属(网络 / 客户端 / 服务端)三类证据,目前**一项都没有**。 +- **服务端(可能)**:若服务端对这类输入不返回或不及时返回,本仓改时限只是把失败推后。 +- **SDK(不能排除)**:指纹一致只排除版本漂移;SDK 的帧构造、`is_final` 语义、预算算法都没有被审计过,不能写「SDK 无证据」。 +- **不提出**:通用兼容层、fallback 引擎、重试/超时堆叠——锁定决策已排除,且无第二个消费者。 + +## 八、锁定决策与方案状态 + +锁定(本卡遵守):仅诊断;保留根因未知;不得把 FunASR 成功当 CapsWriter 修复;不得从 121 秒直接认定时限过短;不得从 5.55s < `file_seg_duration=25` 推定分段缺陷。 + +方案状态(区分「已否决」与「未证实」很重要): + +| 说法 | 状态 | 理由 | +| --- | --- | --- | +| 「调大自动预算 / 传更大的 `deadline_total` 就修好了」 | **已否决** | 只改失败时刻;且现有测试 `test_unknown_duration_omits_deadline_kwarg` 把「拿不到时长就不传」锁成有意决策 | +| 「换 FunASR 引擎绕过」 | **已否决** | 锁定决策排除,属掩盖失败 | +| 「重开 #155 套旧修法」 | **已否决** | #155 修的是长媒体按实测吞吐算预算,前提是拿得到时长;本例前提不成立 | +| 「上游已有同因修复,直接升级」 | **未证实** | 上游在办 PR/issue(#52/#53/#59/#60、open 的 #61~#64、#43/#55/#56/#61)均未出现「短音频客户端预算/短尾分段」条目;「没有相关条目」不等于「已排除同类缺陷」 | +| 「FunASR 成功 = CapsWriter 已修」 | **已否决** | 另一条链路,不构成 CapsWriter 的对照成功 | +| 「时限太短」 | **未证实** | 见第三节 | +| 「短音频分段缺陷」 | **未证实** | 见第三节 | + +## 九、上游与本仓的只读复核 + +| 来源 | 观察 | +| --- | --- | +| 上游 issue #52(OPEN)音轨限定导致长文件 120s 超时 | 对应修法 PR #53 已合入 pin;样本 A 是纯 wav,不涉视频轨 | +| 上游 issue #43 / #55 / #56 / #61(OPEN) | 分别是容器时长估算、测试环境红、跨 ffmpeg 版本验证、HTTP 计费边界 | +| 上游 open PR #61~#64 | 均在 HTTP 容量三闸、QA、baseline 采集线上 | +| 本仓主干 `ff92a175` | 无相关提交;`tests/unit/test_capswriter_deadline_budget.py` 锁死「拿不到时长就不传 `deadline_total`」 | +| 本仓 `GenericDownloader.download_file` | 该方法内无 `_validate_media_file` 调用(源码检查 + 真实执行双证)。**注意**:这只说明这条路径不写 `last_media_duration`;本仓其他路径(如共享音轨准入 `_ensure_audio_track`)是否探测过时长、以及探测结果去了哪里,本卡未核实 | + +## 十、命令与退出码(可复核) + +生产只读(n305): + +| 命令 | 退出码 | 用途 | +| --- | --- | --- | +| `ssh n305 'docker ps --format … \| grep -i video; ls data'` | 0 | 容器 running/healthy、日志目录 | +| `ssh n305 'grep -n "\"server_url\"" config/config.jsonc'` | 0 | ASR 端点(第 18 行 = capswriter,第 165 行 = funasr) | +| `ssh n305 'grep -c "task_cc2eeeeb…" logs/app.log'` | 0 | 30 行命中 | +| `ssh n305 'grep -E "开始转录文件\|转录完成,生成文件\|转录文件失败" logs/app.log'` | 0 | 转录时间线白名单 | +| `ssh n305 'grep -c "transcription_deadline" logs/app.log'` | 0 | 9 行,8 行 `duration=unknown` | +| `ssh n305 'curl -s --max-time 8 http://:6016/health \| python3 -c …'` | 0 | 只投影 4 个白名单字段 | +| `ssh n305 'docker exec … sha256sum …/capswriter_asr/client.py'` | 0 | 与 pin 比对(版本一致性) | + +本地(worktree): + +| 命令 | 退出码 | 用途 | +| --- | --- | --- | +| `uv sync --offline` | 0 | 建 `.venv` | +| `PYTHONPATH=src uv run --no-sync python ` | 0 | producer 实参 + `last_media_duration` 缺失(附录 A.1/B.1) | +| `PYTHONPATH=src uv run --no-sync python ` | 0 | 120.2s / 142.2s 放弃行为(附录 A.2/B.2),一次约 262 秒墙钟 | + +上游只读:`gh issue view 166`、`gh api repos/zlxlabs/CapsWriter-ASR-Server/commits|pr list|issue list`(仅读,未评论未改)。 + +## 十一、继承红与新红 + +卡面写明主干基线 API 不可用(`gh api request failed`),因此**继承红未能判定**。本卡无代码改动、未跑全量测试(`make test` 未执行),因此不存在新红判定依据。 + +## 十二、踩到的坑 + +1. 卡面要求先读的两个技能文件 `~/.pi/agent/skills/deploy-ops/SKILL.md` 与 `~/.pi/agent/skills/investigate/SKILL.md` 均返回 `Permission denied`,**未能读取**;本卡按全局约定自行执行同口径纪律。此为卡面执行前置条件失败,已如实报告。 +2. 探针初版用 `aiohttp` 起假服务端,dev 依赖里没有(aiohttp 在 perf extra)→ `ModuleNotFoundError`;改用已安装的 `websockets 15` 的 `process_request` 钩子同端口兼做 `/health`。 +3. `transcribe_file_sync` 内部是 `asyncio.run()`,在已有 loop 里调用直接 `RuntimeError`,且同时把 coroutine 泄漏成 never-awaited 警告——只看该警告会误判超时逻辑有问题。改用等价 async 入口 `transcribe_file`。 +4. `websockets.http11.Response` 的 headers 必须传 `Headers` 对象,传 `dict` 会在握手时抛 `AttributeError: 'dict' object has no attribute 'serialize'`。 +5. 探针 A 初版 mock 了 `requests.get`,而 `GenericDownloader` 实际走 `_safe_request`/`_dispatch_pinned_request` 钉 IP 路径,**真的解析 DNS 并向合成域名 example.com 发了 3 次请求**(全部 404)。改为 mock `_safe_request` 后不再出网。这是本卡唯一的意外外网访问,无凭据、无生产数据。 +6. `GenericDownloader.__init__` 需要 `config/config.jsonc`(本 worktree 无此文件,且本卡不改配置),改用 `__new__` 绕过并按基类初值补 `last_media_duration=None`。 +7. 公开仓 pre-push 扫描拦截内网 IP,报告内 ASR 端点改为 `:6016` 占位符后才推送成功。 +8. 上一版把「同期任务处理耗时」当成预算证据、把媒体标题写进报告、把墙钟估成 50 分钟——三处都是本卡自己的推断错误,不是环境问题;本版已逐条纠正,历史提交不改写(见第十四节)。 + +## 十三、闸与绕过 + +- 只写本文件与 `progress/caps-short-progress.md`;未改业务代码、依赖、config、CI。 +- 生产侧只有:容器/日志只读 grep、固定 task id 过滤、单个 `/health` GET、容器内文件 sha256。未 POST 新任务、未向 ASR 提交音频、未重启/部署、未操作 issue、未读取 users.json 或任何凭据(`server_url` 是主机名+端口,非凭据)。 +- 日志只投影白名单字段(时间、级别、模块行号、错误码、耗时),task id 与路径前缀截断;媒体标题/文件名已从本版移除。 +- 绕过说明:为不读 config 而用 `__new__` 构造 downloader;为在已有 loop 内可用而用 SDK 的 async 入口替代 `transcribe_file_sync`(语义等价,仅少一层 `asyncio.run` 包装)。 +- 预算:定向检索约 20 次、n305 SSH 8 次(卡面限 12),单条均 ≤30s。墙钟以实际执行起止计:派发 `02:36Z` 至最后一次 push `≈03:00Z`,约 24 分钟,**在 40 分钟软限内**(上一版「约 50 分钟、超软限」的说法不成立,已撤回)。 + +## 十四、与卡面的偏差 + +- 卡面要求「比较历史任务运行版本」:历史任务运行的就是基线本身,无版本差;改为核对容器内实际安装 SDK 文件的 sha256,且只把它当作版本一致性证据。 +- 卡面禁止向生产 ASR 提交音频,故缺失的服务端侧实验未执行;本版进一步明确:网络层与服务端层的观测同样是缺口,不能只用客户端复现代替。 +- 输入样本复核为「低」:原文件已清理,无法取 hash;且 5.55s 本身未经服务端记录确认。 +- **历史提交含非必要信息(不改写,供审计)**:`66d00751`/`86e9d55a` 的正文里出现过同期任务的媒体标题与临时文件名、以及已被撤回的强推断和错误的墙钟估计。修订未授权改写历史,故以本节与本版正文为准,旧提交可从 GitHub 历史中查到。 + +## 十五、最贵的一步 + +把「121 秒」从数字变成一条可复核的客户端机制链:生产两条日志(`duration=unknown` + `code=timeout` 原文)→ 代码路径逐跳核对 → 本地 synthetic 探针把 120.2 秒与逐字文案复现,并额外拿到「显式 5.55s 只有 142.2 秒」这个反向刻度。 +**这一步的代价是它容易诱导过度归因**:机制链闭合到客户端为止,服务端与网络两段没有任何观测;本版的修订正是把这条闭合链的边界重新画回事实。 + +--- + +## 附录 A:探针源码(原样迁入,未改写) + +### A.1 `probe_producer.py` + +```python +"""Issue #166 本地无网络探针:观察本仓真实传给 SDK 的参数(producer 侧)。 + +不做生产复现,不连任何 ASR;只捕获 CapsWriterClient.transcribe_file 发给 +capswriter_asr.transcribe_file_sync 的 kwargs,以及 GenericDownloader 真实 +download_file 路径是否写 last_media_duration。 +""" + +import json +import subprocess +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from capswriter_asr import Transcript + +from video_transcript_api.downloaders.generic import GenericDownloader +from video_transcript_api.transcriber.capswriter_client import ( + CapsWriterClient, + Config, +) + +OUT = {} + + +def _make_client(output_dir: Path) -> CapsWriterClient: + with patch.object(CapsWriterClient, "__init__", lambda self: None): + client = CapsWriterClient() + client.output_dir = str(output_dir) + client.max_retries = 1 + client.retry_delay = 0 + client.log = MagicMock() + return client + + +def _fake_transcript(): + raw = {"task_id": "task-probe", "time_start": 1.0, "time_complete": 2.0, "text_accu": "ok."} + return Transcript( + text="ok.", + tokens=list("ok."), + timestamps=[0.0, 0.5, 1.0], + duration=5.55, + raw=raw, + ) + + +def probe_sdk_kwargs(audio: Path, tmp: Path, media_duration): + seen = {} + + def _capture(path, url, **kwargs): + seen["path"] = str(path) + seen["url"] = url + seen["kwargs"] = dict(kwargs) + return _fake_transcript() + + Config.generate_funasr_compat = False + Config.generate_txt = True + Config.generate_json = False + Config.generate_merge_txt = False + Config.server_addr = "probe-host" + Config.server_port = 6016 + with patch( + "video_transcript_api.transcriber.capswriter_client.transcribe_file_sync", + side_effect=_capture, + ): + ok, _ = _make_client(tmp).transcribe_file(str(audio), media_duration=media_duration) + OUT[f"sdk_kwargs_media_duration={media_duration!r}"] = { + "success": ok, + "url": seen["url"], + "kwargs": seen["kwargs"], + } + + +def probe_generic_duration(tmp: Path): + """真实调用 GenericDownloader.download_file(只 mock HTTP 响应,不发网络请求)。""" + # 用 __new__ 绕过 __init__:避免读 config/config.jsonc(本卡不改配置)。 + # last_media_duration / config 用等价初值补齐(基类 __init__ 就是这个初值)。 + dl = GenericDownloader.__new__(GenericDownloader) + dl.last_media_duration = None + dl.config = MagicMock() + dl.temp_manager = MagicMock() + dl.temp_manager.get_current_task_dir.return_value = str(tmp / "taskdir") + payload = b"RIFF" + b"\0" * 64 + resp = MagicMock(status_code=200, headers={"Content-Length": str(len(payload))}) + resp.iter_content = MagicMock(return_value=iter([payload])) + resp.__enter__ = lambda s: s + resp.__exit__ = lambda s, *a: False + # 只 mock SSRF 校验后的出网点 _safe_request:否则会真的解析 DNS 并请求外网 + with patch.object(GenericDownloader, "_safe_request", return_value=resp): + path = dl.download_file("https://example.com/probe.wav", "probe.wav") + OUT["generic_download"] = { + "returned_path_is_str": isinstance(path, str), + "last_media_duration": repr(dl.last_media_duration), + "calls_base_probe": "_validate_media_file" in ( + subprocess.run( + [sys.executable, "-c", "import inspect;from video_transcript_api.downloaders.generic import GenericDownloader as G;print('_validate_media_file' in inspect.getsource(G.download_file))"], + capture_output=True, text=True, + ).stdout + ), + } + + +def main(): + tmp = Path(tempfile.mkdtemp(prefix="caps-short-probe-")) + audio = tmp / "probe-5.55s.wav" + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", + "-i", "sine=frequency=440:duration=5.55", "-ar", "16000", "-ac", "1", str(audio)], + check=True, + ) + probe_real = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "json", str(audio)], capture_output=True, text=True + ) + OUT["fixture"] = { + "path": str(audio), + "bytes": audio.stat().st_size, + "sha256": subprocess.run(["sha256sum", str(audio)], capture_output=True, text=True).stdout.split()[0], + "ffprobe_duration": json.loads(probe_real.stdout)["format"]["duration"], + } + for dur in (None, 0.0, 5.55, 93.08898): + probe_sdk_kwargs(audio, tmp, dur) + probe_generic_duration(tmp) + print(json.dumps(OUT, ensure_ascii=False, indent=2, default=str)) + + +if __name__ == "__main__": + main() +``` + +### A.2 `probe_sdk_deadline.py` + +```python +"""Issue #166 本地无网络探针(SDK 侧真实观测;transcribe_file_sync 在已有 loop 内不可用,改用等价的 async 入口):自动预算到底等多久、报什么。 + +同一端口上用 websockets 15 的 process_request 钩子同时提供: + - GET /health -> HTTP 200 JSON(protocol_version=2, encodings=["flac"]) + - 其余路径升级为 WS,接收音频帧后**永不回应**(模拟服务端收下任务却不返回终态) + +用真实 SDK 的 transcribe_file_sync 跑两次: + 1. 不传 deadline_total(自动预算)——输入是 5.55s wav,理论预算 max(120, 5.55+60)=120 + 2. 显式 deadline_total=142.2(本仓公式 5.55*4+120)——观测失败时刻是否后移 + +不连任何真实 ASR,不碰生产。 +""" + +import asyncio +import json +import subprocess +import tempfile +import time +from pathlib import Path + +from websockets.asyncio.server import serve +from websockets.datastructures import Headers +from websockets.http11 import Response + +from capswriter_asr import AsrError, transcribe_file + +HOST, PORT = "127.0.0.1", 8791 +RECEIVED = [] + + +def process_request(connection, request): + if request.path == "/health": + body = json.dumps( + {"protocol_version": 2, "encodings": ["flac"], "role": "server"} + ).encode() + return Response(200, "OK", Headers([("Content-Type", "application/json")]), body) + return None + + +async def handler(ws): + async for message in ws: + frame = json.loads(message) + RECEIVED.append( + { + "source": frame.get("source"), + "seg_duration": frame.get("seg_duration"), + "seg_overlap": frame.get("seg_overlap"), + "encoding": frame.get("encoding"), + "is_final": frame.get("is_final"), + "samples_total": frame.get("samples_total"), + "b64_bytes": len(frame.get("data") or ""), + "keys": sorted(frame.keys()), + } + ) + # 故意不回应任何 result 帧:模拟服务端挂住 + + +async def main(): + tmp = Path(tempfile.mkdtemp(prefix="caps-short-sdk-")) + audio = tmp / "probe-5.55s.wav" + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", + "-i", "sine=frequency=440:duration=5.55", "-ar", "16000", "-ac", "1", str(audio)], + check=True, + ) + dur = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", str(audio)], + capture_output=True, text=True, + ).stdout + + async with serve(handler, HOST, PORT, process_request=process_request, ping_interval=None): + url = f"ws://{HOST}:{PORT}" + results = [] + for label, deadline in (("auto_budget_no_deadline_total", None), ("deadline_total_142.2", 142.2)): + kwargs = {"encoding": "flac", "seg_duration": 25, "seg_overlap": 2} + if deadline is not None: + kwargs["deadline_total"] = deadline + started = time.monotonic() + try: + transcript = await transcribe_file(str(audio), url, **kwargs) + results.append({"label": label, "elapsed": round(time.monotonic() - started, 1), + "outcome": "success", "duration": transcript.duration}) + except AsrError as exc: + results.append({"label": label, "elapsed": round(time.monotonic() - started, 1), + "outcome": "asr_error", "code": exc.code, + "message": exc.message, "retryable": exc.retryable}) + except Exception as exc: # noqa: BLE001 + results.append({"label": label, "elapsed": round(time.monotonic() - started, 1), + "outcome": type(exc).__name__, "message": str(exc)[:200]}) + + print(json.dumps( + {"fixture": {"ffprobe": json.loads(dur)["format"]["duration"], + "bytes": audio.stat().st_size, + "sha256": subprocess.run(["sha256sum", str(audio)], capture_output=True, text=True).stdout.split()[0]}, + "results": results, "frames_received": RECEIVED}, + ensure_ascii=False, indent=2, + )) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## 附录 B:探针真实输出(原样,未编辑) + +### B.1 `probe_producer.py` → `producer_out.json` + +> 说明:原始运行只把 JSON 打到终端、未落盘;修订时按**同一脚本**重跑一次(秒级、本地、无网络请求)并落盘。数值与原次一致(fixture sha256 相同)。不重跑 262 秒的 SDK 探针。 + +```json +{ + "fixture": { + "path": "/tmp/caps-short-probe-_6_w46k6/probe-5.55s.wav", + "bytes": 177678, + "sha256": "c08fd13b7d5eace2614478a1e03692d8f2bfee72c1390715a535caa0ebf6aca7", + "ffprobe_duration": "5.550000" + }, + "sdk_kwargs_media_duration=None": { + "success": true, + "url": "ws://probe-host:6016", + "kwargs": { "encoding": "flac", "seg_duration": 25, "seg_overlap": 2 } + }, + "sdk_kwargs_media_duration=0.0": { + "success": true, + "url": "ws://probe-host:6016", + "kwargs": { "encoding": "flac", "seg_duration": 25, "seg_overlap": 2, "deadline_total": 120.0 } + }, + "sdk_kwargs_media_duration=5.55": { + "success": true, + "url": "ws://probe-host:6016", + "kwargs": { "encoding": "flac", "seg_duration": 25, "seg_overlap": 2, "deadline_total": 142.2 } + }, + "sdk_kwargs_media_duration=93.08898": { + "success": true, + "url": "ws://probe-host:6016", + "kwargs": { "encoding": "flac", "seg_duration": 25, "seg_overlap": 2, "deadline_total": 492.35592 } + }, + "generic_download": { + "returned_path_is_str": true, + "last_media_duration": "None", + "calls_base_probe": false + } +} +``` + +同次运行的 stderr(日志行,只含合成路径与合成域名): + +``` +11:02:41 | WARNING | capswriter_client:_transcription_deadline:141 - transcription_deadline duration=unknown fallback=sdk_auto +11:02:41 | INFO | capswriter_client:_transcription_deadline:144 - transcription_deadline duration=0.00 value=120.0 +11:02:41 | INFO | capswriter_client:_transcription_deadline:144 - transcription_deadline duration=5.55 value=142.2 +11:02:41 | INFO | capswriter_client:_transcription_deadline:144 - transcription_deadline duration=93.09 value=492.4 +11:02:41 | INFO | downloaders.generic:download_file:628 - 开始下载文件 (尝试 1/3): https://example.com/probe.wav +11:02:41 | INFO | downloaders.generic:download_file:717 - 文件下载成功: /tmp/caps-short-probe-/taskdir/probe.wav (大小: 0.00 MB) +``` + +### B.2 `probe_sdk_deadline.py` → `sdk_probe_out.json`(原始那次,约 262 秒) + +```json +{ + "fixture": { "ffprobe": "5.550000", "bytes": 177678, "sha256": "c08fd13b7d5eace2614478a1e03692d8f2bfee72c1390715a535caa0ebf6aca7" }, + "results": [ + { "label": "auto_budget_no_deadline_total", "elapsed": 120.2, "outcome": "asr_error", + "code": "timeout", "message": "转录超过自动预算:远端转录阶段超时", "retryable": false }, + { "label": "deadline_total_142.2", "elapsed": 142.2, "outcome": "asr_error", + "code": "timeout", "message": "转录超过deadline_total:远端转录阶段超时", "retryable": false } + ], + "frames_received": [ + { "source": "file", "seg_duration": 25, "seg_overlap": 2, "encoding": "flac", + "is_final": true, "samples_total": 88800, "b64_bytes": 56540, + "keys": ["data","encoding","is_final","samples_total","seg_duration","seg_overlap","source","task_id","time_start"] }, + { "source": "file", "seg_duration": 25, "seg_overlap": 2, "encoding": "flac", + "is_final": true, "samples_total": 88800, "b64_bytes": 56540, + "keys": ["data","encoding","is_final","samples_total","seg_duration","seg_overlap","source","task_id","time_start"] } + ] +} +``` + +`frames_received` 是**本地假服务端**收到的两帧(两次运行各一帧),只说明 SDK 发送侧的形状;它**不**说明生产服务端收到了什么,也不排除服务端侧的短尾/分段处理问题。 diff --git a/docs/sessions/triage-261004/progress/caps-short-progress.md b/docs/sessions/triage-261004/progress/caps-short-progress.md new file mode 100644 index 00000000..d85ffb7c --- /dev/null +++ b/docs/sessions/triage-261004/progress/caps-short-progress.md @@ -0,0 +1,47 @@ +# #166 CapsWriter 短音频诊断 进度 + +- 派发:`dlg-20261004-023618-c714a0`;基线 `ff92a175`(= 生产运行版本);分支 `card/caps-short-diagnose-261004`。 + +## 阶段一:现场与证据链(已完成) + +- 结论:`121 秒` 已被解释到客户端计时边界——直链 wav 走 `GenericDownloader`,无 ffprobe → `media_duration=None` → 不传 `deadline_total` → SDK 自动预算下限 `max(120, 解码时长+60)=120` → `AsrError(timeout, 转录超过自动预算:远端转录阶段超时)`,`retryable=false` 故只试一次。 +- 生产观测:`_transcription_deadline duration=unknown fallback=sdk_auto`、`code=timeout` 原文、`尝试 1/5`、perf `120190ms`/`120183ms`;容器 SDK `client.py` sha256 与 pin `858c6b9` 逐字一致。 +- 本地无网络探针:producer 实参(`None`→无 `deadline_total`;`0.0`→`120.0`;`5.55`→`142.2`)、`GenericDownloader.download_file` 不探测(`last_media_duration is None`)、假服务端复现 120.2s/142.2s 超时与逐字消息、实收帧 `is_final=true, samples_total=88800`。 + +## 阶段二:定性与收尾(已完成) + +- 决策:根因保持**未知**——服务端对这份 5.55s wav 为何不给 final 未证;本卡禁止向生产 ASR 提交音频,该实验写成方案交主脑裁决。 +- 已否决:调大预算/重试堆叠、换 FunASR 绕过、套 #155 旧修法(其前提是拿得到时长)、「上游已有同因修复」(上游在办 PR 均为 HTTP 容量/QA 线上)。 +- 归属建议:本仓(`GenericDownloader` 不探测)**或**服务端,取决于缺失实验的结果;SDK 无证据。 +- 输入样本可复核程度=低:原 wav 已随任务清理,日志只留文件名与 0.17 MB。 + +## 下一步(需主脑裁决) + +1. 是否授权一次生产 ASR 提交实验(同 wav + 服务端侧观测)以判定服务端终态。 +2. 若确认服务端会正常返回 → 本仓补最小时长探测(不引入兼容层/fallback);若服务端不回 → 转上游/服务端侧,本仓不改。 + +## 过程记录 + +- 卡面要求读的两个技能文件权限拒绝(`Permission denied`),按全局纪律自行执行。 +- 探针三次返工:aiohttp 不在 dev 依赖 → `asyncio.run` 不能嵌套 loop → `websockets.Response` headers 需 `Headers` 对象。 +- 一次 mock 位置错误导致真的向 example.com 发了 3 次请求(已改 mock `_safe_request`),无凭据无生产数据。 +- 继承红:卡面主干基线不可用,**未能判定**;本卡无代码改动、未跑全量测试。 + +--- + +## 阶段三:主脑审阅后的归因收窄(`dlg-20261004-030118-009132`,追加,不改写上文历史) + +诊断**暂不验收**;本轮只修文档,不做生产实验、不改代码/配置/范围。 + +纠正项(逐条对应主脑意见): + +1. **归因收窄**:SDK 文件 sha256 一致只证明**版本一致**,不能排除 SDK 缺陷;本地假服务端「永不回复」只证明**客户端预算行为**,不能证明生产服务端没发 final。真实网络传输与服务端终态此前被隐含为已知,现明确列为**未知**(三态:客户端已证 / 网络未观测 / 服务端未观测)。 +2. **降级为未证实**:「分段缺陷」「时限太短」由「已排除」改为「未证实」。`is_final=true` 单帧只反映 SDK 发送侧,服务端如何切分/何时定 final 未被观测,不能据此排除服务端短尾分段问题。 +3. **删除过强推断**:同期 321s/17621s 是**处理耗时**不是媒体时长;无解码后时长就算不出各自预算值,也不能断言「只有该 wav 撞 120 秒下限」。相关推论与时间线读法已重写,输入全部匿名化。 +4. **归因边界**:`GenericDownloader.download_file` 不调用 `_validate_media_file` ≠ 本仓从未 ffprobe(共享音轨准入等路径可能探测过但不写 `last_media_duration`);只保留「`last_media_duration` 缺失」这一条已知。并且**不提出**「服务端稍晚回包就必修本仓」——显式按 5.55s 传预算也只有 142.2 秒,仍需实际延迟、音频身份、归属三类证据。 +5. **契约留档**:探针源码与真实输出从 `/tmp` 迁入诊断文档附录 A/B,标注 synthetic(自造正弦波 + 本地假服务端)与 production 边界。SDK 探针未重跑;producer 探针因原输出未落盘,按同一脚本重跑一次(秒级、无网络)后落盘,已在附录注明。 +6. **信息最小化**:最终文档不再出现同期任务的媒体标题与临时文件名。旧提交 `66d00751`/`86e9d55a` 含非必要媒体名与被撤回的推断,未授权改写历史,已在正文第十四节声明。 +7. **墙钟纠正**:以实际执行起止计(派发 02:36Z 至末次 push ≈03:00Z),**约 24 分钟,在 40 分钟软限内**;上文「约 50 分钟、超软限」的说法不成立,撤回。 +8. **偏差如实化**:技能文件 `Permission denied` 属卡面前置条件失败;探针初版误发 3 次 example.com 请求,故本卡**不声称全程无网络**。 + +结论状态不变:**根因未知**。归属建议改为不下定论(本仓 / 服务端 / SDK 均不能排除)。 diff --git a/docs/sessions/triage-261004/progress/real-probe-progress.md b/docs/sessions/triage-261004/progress/real-probe-progress.md new file mode 100644 index 00000000..1c8935db --- /dev/null +++ b/docs/sessions/triage-261004/progress/real-probe-progress.md @@ -0,0 +1,12 @@ +# #166 真实关联诊断进度 + +## 2026-10-04 · 续修更正与停止决定 + +- 阶段:verifying;诊断卡完成,业务转录未验证。 +- 更正:固定历史 task ID 的只读 SQLite 记录恢复出同一公开 WAV URL;当前下载为 177572 bytes、SHA-256 a1bd32dc78493c123f9625a66deee562aed2895f53fbc39f2cca3be7e6f4f20f、5.546688 秒。历史输入无 hash,不能证明历史字节相等。 +- 更正:旧卡与当前活动卡均不含生产维护/部署;实际部署和 /health 已只读核实,无维护冲突证据。 +- 决定:API task ID 未传给 SDK;SDK 的失败异常不保留其自生成 UUID,服务端只记录 SDK UUID。提交前无法保证失败请求跨端关联,故真实请求数保持 0/2。 +- 已做:确认 API 构建 SHA、CapsWriter 部署 SHA、固定历史行、样本当前字节、健康白名单、API/SDK/服务端 task ID 链路;清理自建临时 WAV。 +- 未做:真实转录、代码修改、配置变更、重启、部署、测试。 +- 下一步:需在另行授权的任务中提供失败路径的可验证跨端关联字段后再考虑生产探针;本卡不加机制或改 SDK。 +- 本里程碑与报告同提交。续修起始 2026-10-04T03:26:29.458898Z;完整命令/输出边界见 docs/sessions/triage-261004/real-probe.md。 diff --git a/docs/sessions/triage-261004/progress/wire-probe-progress.md b/docs/sessions/triage-261004/progress/wire-probe-progress.md new file mode 100644 index 00000000..bc9299e8 --- /dev/null +++ b/docs/sessions/triage-261004/progress/wire-probe-progress.md @@ -0,0 +1,3 @@ +# CapsWriter wire probe 进度 + +- 2026-10-04T04:13:24Z:完成本地 fake WebSocket 正例/负例;核对 n305 API/SDK 版本、真实分段参数及 CapsWriter 日志可读;唯一真实 SDK 请求发送 1 帧并关联到服务端 `task_end status=done` 与 result dispatch,客户端在 120.152 秒后报 timeout。完整证据、白名单源码/输出及范围限制见 [`wire-probe.md`](../wire-probe.md)。 diff --git a/docs/sessions/triage-261004/real-probe.md b/docs/sessions/triage-261004/real-probe.md new file mode 100644 index 00000000..3c7194a1 --- /dev/null +++ b/docs/sessions/triage-261004/real-probe.md @@ -0,0 +1,88 @@ + + +# #166 原样本恢复与真实转录关联诊断 + +## 执行结果 + +**诊断卡完成;生产转录实验因关联前提未满足而停止。** 旧报告中的“原样本 URL 不可恢复”和“ASR 服务维护冲突”均已更正:固定历史任务记录恢复出同一公开 WAV;两项活动派发的卡面都排除生产维护,且实际服务端只读核验通过。当前剩余阻塞是:API 任务 ID 没有传入 CapsWriter SDK;SDK 自行生成的服务端任务 ID 在成功结果中可见,但失败异常不保留它。因此提交前无法保证失败时把 API 任务与服务端接收/处理/final 事件一一关联。 + +- 续修窗口:2026-10-04T03:26:29.458898Z 至本报告归档时间(见归档收据)。 +- 新增真实转录请求:0/最多 2;新建生产转录任务:否。 +- 累计只读远程调用:16 次 SSH(n305 及 Mac Studio),加首次阶段 2 次 GitHub 只读查询,共 18/20;未超过卡面预算。 +- 唯一一次公开样本下载和一次服务健康 GET;未发送转录请求。未改配置、未重启、未部署。 +- 最早可证失败边界仍未知;没有以健康检查或代码静态追踪冒充业务复现。 + +## 样本证据 + +固定历史任务为 task_cc2eeeebb6894a11a91e25035be021db、task_c3b6e0bee2cc4432abe812b21a6ae8bc(CapsWriter 失败)及 task_b0556bea197b44bf9208e17dd66e875b(FunASR 成功)。先读 task_status schema,再以 SQLite mode=ro、PRAGMA query_only=ON 只查这三个 ID。三行都存在;前两条时间分别为 2026-10-03 16:22:18–16:24:19 和 16:47:18–16:49:19,状态均为 failed;FunASR 行为 success。三条的 url 均为以下同一公开 URL,download_url 均为空,cache_id 均为 None: + +https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_zh.wav + +URL 在进程内检查 HTTPS、无 userinfo、无 fragment、无凭据类 query key,并确认 DNS 地址为 global;只在通过检查后输出 URL。容器内经 HTTPS 下载当前字节一次,逐跳重复同样的 URL 校验,读取上限 8 MiB,并对白名单字段执行 ffprobe: + +- HTTP 200;Content-Type: audio/wav;177,572 bytes。 +- SHA-256:a1bd32dc78493c123f9625a66deee562aed2895f53fbc39f2cca3be7e6f4f20f。 +- WAV 5.546688 秒,单声道 pcm_s16le,16 kHz。 +- /tmp/vta166-*.wav 清理后计数为 0。 +- 历史任务没有保留输入 hash;以上只证明同 URL 当前下载字节,不能证明与 2026-10-03 的原字节相同。没有把 5.55 秒当作样本身份依据,也没有使用替代样本。 + +## 服务、维护冲突与健康检查 + +旧报告把 CapsWriter 仓中一个 running 派发误当成生产维护冲突。定向读取其卡面后确认该任务明确排除部署、ready/merge,工作范围仅为独立 worktree 中的代码、测试和文档;另一项活动卡明确为 synthetic/test-only、无生产访问。活动状态本身不证明生产资源冲突,因此撤销旧判断;未联系或打断执行器。 + +安全投影核实 n305 上 API 容器为 running,启动时间 2026-10-03T16:11:07.973881807Z,镜像内 GIT_SHA=ff92a175243d。API JSONC 配置中的服务端端口与监听端口匹配;配置主机名和地址未写入报告。唯一一次对配置端点 /health 的 GET 返回 HTTP 200、status=ok、protocol_version=2、model=paraformer、编码 f32le/flac/ogg_opus/s16le;响应仅存于受限临时文件并删除。 + +Mac Studio 的 6016 端口恰有一个 Python 监听进程,运行时长 02-16:58:39,工作目录 basename 为 capswriter_server_main,部署源码 SHA 6b7a2b82fbc3ebe862250a8804902e5bf37f9211。core/server、core/proxy、sdk 跟踪文件干净;仓库其他区域状态未输出。该部署源码按 task_id 记录接收/最终分段事件,最终结果包含同一 ID。健康检查和部署身份已确认,但这不补足 API 任务与 SDK ID 的失败路径映射。 + +## 客户端与服务端关联证据 + +- 本仓 src/video_transcript_api/transcriber/capswriter_client.py:808-822 调用 transcribe_file_sync(file_path, server_url, **sdk_kwargs);参数为 encoding=flac、配置的 seg_duration/seg_overlap 和按时长计算的可选 deadline_total。调用没有传 API task ID,也没有传 on_progress。这些是代码参数,不是本次真实请求的实测参数。 +- 锁定 SDK 858c6b9 的 sdk/capswriter_asr/client.py:338-354 在客户端内部生成 UUID 并写入音频帧;284-297 仅在收到 final result 后把返回的 task_id 放进 Transcript。 +- 同文件 18-34,300-318 的 AsrError 只保留 code、message、retryable、recovery_path;接收错误帧时没有保存其中的服务端 ID。连接丢失也只产生不带 task ID 的 AsrError。故失败路径上没有 API task ID → SDK UUID 的映射。 +- 部署 SHA 6b7a2b82fbc3ebe862250a8804902e5bf37f9211 的 core/server/connection/ws_recv.py 和 ws_send.py 会记录/回传 SDK UUID。服务端可观测自身 ID,却没有现成字段关联上游 API ID。 +- 历史三行 cache_id=None,没有已知产物侧边文件能补出失败请求的 SDK UUID。仅按时间猜配不构成同一任务证据。 + +因此,只有成功响应可能把 SDK ID 带回调用端;而本卡要诊断的失败路径不能保证映射。按“提交前确认两端可关联”的闸停止,不发送最多两次授权内的真实请求。 + +## 实际命令与白名单结果 + +以下保留实际安全命令入口与查询条件;不输出配置全集、私有端点、原始响应、日志或无关用户字段。 + + timeout 30 ssh -o ConnectTimeout=10 n305 'docker inspect video-transcript-api --format "{{.State.Status}}|{{.State.StartedAt}}"' + timeout 30 ssh -o ConnectTimeout=10 n305 'docker exec video-transcript-api printenv GIT_SHA' + +输出分别为 running|2026-10-03T16:11:07.973881807Z、ff92a175243d。数据库检查使用: + + timeout 30 ssh -o ConnectTimeout=10 n305 'docker exec -i video-transcript-api python -' + +Python 只连接 file:/app/data/cache/cache.db?mode=ro,执行 PRAGMA query_only=ON 和 PRAGMA table_info(task_status);随后仅对固定三 ID 执行 SELECT task_id,url,status,created_at,completed_at,cache_id FROM task_status WHERE task_id IN (?,?,?)。另一次固定 ID 查询只读 task_id,url,download_url 并对白名单 URL 做公开性校验。结果为 3/3 行、三条 URL 相同、三个 download_url=empty;没有选取标题、用户信息或 raw 行。 + +样本下载通过 timeout 30 ssh -o ConnectTimeout=10 n305 'docker exec -i video-transcript-api python -' 在容器内执行 Python;健康检查通过 timeout 30 ssh -o ConnectTimeout=10 n305 'docker exec -i video-transcript-api uv run --no-sync python -' 执行。下载实际使用 urllib Request + opener.open(timeout=20),HTTPS 重定向逐跳校验,流式读取上限 8 MiB;ffprobe argv 为 [ffprobe,-v,error,-show_entries,format=duration,format_name:stream=codec_name,codec_type,sample_rate,channels,-of,json,<本卡临时 WAV>]。白名单 JSON 含 source_url、http_status、content_type、bytes、sha256、format_name、duration_seconds、streams;首次投影的 temp_copy_removed=false 是在 finally 清理前生成,随后独立计数确认 0。健康检查由 API 实际使用的 commentjson 解析器读取配置并构造 /health;curl argv 为 [curl,--silent,--show-error,--max-time,5,-o,,-w,%{http_code},<进程内 URL>],响应只投影 HTTP 状态、status、protocol version、model、encoding 白名单,最后删除文件。成功投影为 {"api_status":"ok","configured_port_matches_listener":true,"encodings":["f32le","flac","ogg_opus","s16le"],"http_status":"200","model":"paraformer","protocol_version":2,"response_temp_removed":true}。临时 WAV 前缀清理核验命令为: + + timeout 30 ssh -o ConnectTimeout=10 n305 'docker exec video-transcript-api sh -c '\''find /tmp -maxdepth 1 -type f -name "vta166-*.wav" -print | wc -l'\''' + +白名单输出为 0。Mac Studio 只读核验限定在 lsof -nP -tiTCP:6016 -sTCP:LISTEN -b、对应 PID 的 ps/lsof -d cwd 投影,以及工作树内 git rev-parse HEAD 和对 core/server core/proxy sdk 的状态查询。没有读取服务日志。代码核对用 git show 858c6b9:sdk/capswriter_asr/client.py、部署 SHA 下 git grep/git show 与本仓行号范围;未修改代码。 + +## 失败边界、结论与范围 + +| 边界 | 结论 | +| --- | --- | +| 客户端发出请求 | 未提交,未知 | +| 服务端接收/处理 | 未提交,未知 | +| 服务端返回 final/错误 | 未提交,未知 | +| 客户端接收/解析 | 未提交,未知 | + +根因、最早失败边界和最小修复归属均未证实。没有请求/响应、实测 producer 参数、服务端同任务时间线或探针源码;没有跨边界探针,也没有可声称通过的 known-negative 断言。未改代码、依赖或配置;未重启、部署、切换后端或改分段/超时;未读含凭据的原执行器会话日志或 users.json,未改 DNS/tunnel,未删业务数据。临时 WAV 已清理。 + +## 踩到的坑、闸与偏差 + +- 旧报告把“URL 不在已有报告”误判成“来源无法恢复”,把另一仓 worktree 的 running 误判成生产维护冲突。按用户续修澄清重新查固定 SQLite 行和两张任务卡后均已纠正。 +- 一次健康检查预检先用了不适合 JSONC 的标准 JSON 解析器,在发出 HTTP 请求前失败;改用应用实际的 commentjson 解析器后才执行一次白名单 GET。另一次 lsof 遇到系统卷警告,改用 -b 的同端口定向查询,输出不含警告。 +- 卡面允许样本、部署及关联只读预检;这些已完成。真实转录、实测 payload、同一任务时间线因失败路径无法可靠关联而未做。报告存在性命令不表示业务通过;没有运行测试。 +- 最贵的一步是沿 API task ID → SDK UUID → 服务端事件/错误返回逐层核查,确认错误对象丢失 UUID。没有用时间相邻、健康状态或版本相同替代任务关联证据。 +- 原部署报告中的“客户端约 121 秒后失败”仍只是历史记录;它不能证明是否发出 final,也不能排除 SDK、网络或服务端问题。 + +## 归档收据 + +- 分支:card/caps-real-probe-261004;基线 ff92a175243dde36143da867a95e2fdad384e16c。 +- 初次诊断起始:2026-10-04T03:14:26.584648Z;续修起始:2026-10-04T03:26:29.458898Z。最终终止时间、提交号、diffstat、工作树状态、远端 SHA 和验收命令记录在派发报告文件。 diff --git a/docs/sessions/triage-261004/wire-probe.md b/docs/sessions/triage-261004/wire-probe.md new file mode 100644 index 00000000..dccc766a --- /dev/null +++ b/docs/sessions/triage-261004/wire-probe.md @@ -0,0 +1,359 @@ + + +# CapsWriter 一次性 wire 诊断报告 + +诊断完成;真实 SDK 调用在服务端完成并分发 final 结果后,客户端调用仍以 `timeout` 结束。诊断任务本身成功取得了真实 SDK UUID 和双端安全时间线。此结果不是 #166 的修复验收,也不证明 API POST 端到端成功。 + +## 执行结果 + +- 执行基线:分支 card/caps-wire-probe-261004,base ff92a175243dde36143da867a95e2fdad384e16c。pickup 简报未找到本 worktree 的交接单;未借用其他会话交接单。 + +- 本卡真实 ASR 调用:**1/最多 2**;没有重试、没有第二次调用。 +- 本卡 SSH:**7/最多 12**(n305 2 次,CapsWriter 主机 5 次;含一次只读投影脚本错误后的修正查询)。每次命令均在 30 秒内完成;真实 SDK 观察窗口约 120 秒,外层硬截止 175 秒。 +- 没有改应用代码、配置、依赖或运行服务;没有重启、部署、API POST、读 `users.json` 或写其他仓。工作树改动仅限本报告和追加进度。 +- 诊断 outcome 为 `succeeded`:实验得到有界、可核对的证据。被诊断的请求 outcome 为 `failed`:SDK 抛出 `AsrError(code=timeout)`。 +- 根因只归到已证最早失败边界:SDK 底层 `recv()` 收到同 ID 的 final result 后,`transcribe_file_sync` 未向调用方返回 Transcript,等到 SDK 默认 120 秒预算后报超时。更细的 SDK 内部触发点未证实。 + +## 复用的基线与当前身份 + +复用前一轮报告 `前一轮指定的私有安全报告` 中的固定公开样本来源、当前样本 SHA-256、部署版本和服务端日志代码位置;没有重查该报告所述历史任务或重复旧预检。前一轮真实 ASR 调用为 0/2。 + +- 样本 URL:`https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_zh.wav`。 +- 历史已记录的当前字节:177,572 bytes,SHA-256 `a1bd32dc78493c123f9625a66deee562aed2895f53fbc39f2cca3be7e6f4f20f`,5.546688 秒,mono `pcm_s16le` 16 kHz。历史原字节没有 hash,本报告不声称与历史请求字节完全相同。 +- n305 容器仍为 running,启动时间 `2026-10-03T16:11:07.973881807Z`,`GIT_SHA=ff92a175243d`。 +- 容器安装的 SDK `client.py` SHA-256 为 `ff476ad7cd40401b7ed77c7606cb4fc14dc3d529043c29c4714b199c13423cdf`,与本地 SDK checkout 提交 `858c6b975d8bdd2be0e47ac5a36483119894c529` 的该文件哈希相等。 +- CapsWriter 6016 listener 唯一,工作目录 basename 为 `capswriter_server_main`,运行代码 SHA `6b7a2b82fbc3ebe862250a8804902e5bf37f9211`。当前 `server_latest.log` 可读;随机未命中 UUID 查询成功且为 0 行。生产前已满足日志可读/可按任务 ID 查询的闸。 +- n305 运行配置只投影出 `file_seg_duration=25`、`file_seg_overlap=2`;应用调用固定 `encoding=flac`。探针从容器当前 JSONC 配置内读取服务端 URL 但不输出。`media_duration=None` 且不传 `deadline_total` 是本卡指定的直接 SDK 参数;SDK 默认 idle timeout 为 300 秒,样本对应自动总预算为 120 秒。 + +## 本地假服务端旁路验证 + +在本机用同一 SDK 提交和真实 `websockets` 客户端,临时生成 0.2 秒静音 WAV,经 localhost 假服务端接收。旁路包装器只解析发送帧的安全字段,随后把同一个 `payload` 对象原样交给原始 `send()`。假服务端把收到的 WebSocket 应用层 payload 字节与 producer 保存于内存的字节逐字节比较。它不向磁盘保存音频帧。 + +已知正例为 SDK 自己生成的 UUID `2bae7cd8-6244-48c5-bb28-6840374bae1d`,发送端和假服务端都收到该 ID,整帧长度 11,313 bytes、SHA-256 `012ebab2208791deef7d5d5665b7826419d3528e4f4fcc8f93e4afb11f02bacf`,`application_payload_byte_equal=true`。故意传入不匹配 UUID 和空事件列表均返回 `false`;查询失败返回 `null`(未知),没有把“无事件”混成“查询失败”。假服务端的 synthetic result 仅用于结束本地 SDK 调用,不是真实识别。 + +以下是该本地验证源码;生产探针源码在下节。运行环境为本机 Python 3.11、`websockets=16.0`、`numpy=2.5.0`,SDK 源码来自前述 pin。 + +```python +import asyncio, base64, hashlib, json, tempfile, uuid, wave +from pathlib import Path +import websockets +from websockets.asyncio.server import serve +from websockets.datastructures import Headers +from websockets.http11 import Response +from capswriter_asr import client + +observed, received, sent_application_payloads = [], [], [] +real_connect = websockets.connect + +class ObservedSocket: + def __init__(self, raw): self.raw = raw + async def send(self, payload, *args, **kwargs): + data = payload.encode('utf-8') if isinstance(payload, str) else bytes(payload) + frame = json.loads(data) + sent_application_payloads.append(data) + observed.append({k: frame.get(k) for k in ('task_id','source','encoding','is_final','seg_duration','seg_overlap','samples_total') if k in frame} | {'frame_bytes':len(data),'frame_sha256':hashlib.sha256(data).hexdigest(),'media_bytes':len(base64.b64decode(frame['data']))}) + return await self.raw.send(payload, *args, **kwargs) + async def recv(self): return await self.raw.recv() + +class ObservedContext: + def __init__(self, cm): self.cm = cm + async def __aenter__(self): return ObservedSocket(await self.cm.__aenter__()) + async def __aexit__(self, *args): return await self.cm.__aexit__(*args) + +def observe_connect(*args, **kwargs): return ObservedContext(real_connect(*args, **kwargs)) + +async def health(connection, request): + if request.path == '/health': + body = b'{"protocol_version":2,"encodings":["flac"]}' + return Response(200, 'OK', Headers([('Content-Type','application/json'),('Content-Length',str(len(body)))]), body) + return None + +async def fake_asr(ws): + message = await ws.recv() + data = message.encode('utf-8') if isinstance(message, str) else bytes(message) + frame = json.loads(data) + received.append((data, {'task_id':frame.get('task_id'),'event':'received','is_final':frame.get('is_final')})) + await ws.send(json.dumps({'type':'result','is_final':True,'task_id':frame['task_id'],'text':'','tokens':[],'timestamps':[],'duration':0,'text_accu':''})) + +async def main(): + server = await serve(fake_asr, '127.0.0.1', 0, process_request=health) + port = server.sockets[0].getsockname()[1] + websockets.connect = observe_connect + try: + with tempfile.TemporaryDirectory(prefix='vta166-local-wire-') as td: + wav_path = Path(td) / 'silence.wav' + with wave.open(str(wav_path),'wb') as wav: + wav.setnchannels(1); wav.setsampwidth(2); wav.setframerate(16000); wav.writeframes(b'\0\0'*3200) + result = await client.transcribe_file(wav_path, f'ws://127.0.0.1:{port}', encoding='flac', seg_duration=25, seg_overlap=2) + assert result.is_final is True + assert len(observed) == len(received) == 1 + sent, server_event = received[0] + assert sent_application_payloads[0] == sent + assert observed[0]['task_id'] == server_event['task_id'] + assert str(uuid.UUID(observed[0]['task_id'])) == observed[0]['task_id'] + events = [server_event] + def correlated(expected, state, records): + if state != 'ok': return None + return any(e.get('task_id') == expected for e in records) + mismatch = str(uuid.uuid4()) + negatives = {'empty_events':correlated(observed[0]['task_id'],'ok',[]),'mismatched_id':correlated(mismatch,'ok',events),'query_failure':correlated(observed[0]['task_id'],'error',events)} + assert negatives == {'empty_events':False,'mismatched_id':False,'query_failure':None} + print(json.dumps({'local_fake_server':'passed','sdk_source_commit':'858c6b975d8bdd2be0e47ac5a36483119894c529','sdk_task_id_observed':observed[0]['task_id'],'producer_payload_safe':observed[0],'server_received_payload':{'task_id':server_event['task_id'],'event':server_event['event'],'is_final':server_event['is_final'],'frame_bytes':len(sent),'frame_sha256':hashlib.sha256(sent).hexdigest()},'application_payload_byte_equal':True,'known_negative':negatives},sort_keys=True)) + finally: + websockets.connect = real_connect + server.close(); await server.wait_closed() + +asyncio.run(main()) +``` + +本地白名单 stdout: + +```json +{"application_payload_byte_equal": true, "known_negative": {"empty_events": false, "mismatched_id": false, "query_failure": null}, "local_fake_server": "passed", "producer_payload_safe": {"encoding": "flac", "frame_bytes": 11313, "frame_sha256": "012ebab2208791deef7d5d5665b7826419d3528e4f4fcc8f93e4afb11f02bacf", "is_final": true, "media_bytes": 8323, "samples_total": 3200, "seg_duration": 25, "seg_overlap": 2, "source": "file", "task_id": "2bae7cd8-6244-48c5-bb28-6840374bae1d"}, "sdk_source_commit": "858c6b975d8bdd2be0e47ac5a36483119894c529", "sdk_task_id_observed": "2bae7cd8-6244-48c5-bb28-6840374bae1d", "server_received_payload": {"event": "received", "frame_bytes": 11313, "frame_sha256": "012ebab2208791deef7d5d5665b7826419d3528e4f4fcc8f93e4afb11f02bacf", "is_final": true, "task_id": "2bae7cd8-6244-48c5-bb28-6840374bae1d"}} +``` + +## 一次真实调用与安全时间线 + +生产调用直接在 `video-transcript-api` 容器中运行 SDK;不是 API POST/端到端验收。启动前再次断言 API SHA、SDK 文件 hash 与生产前置检查一致。脚本先将唯一公开样本下载到权限为 0700 的唯一临时目录,逐个 HTTPS 重定向目标做公开 URL 校验,受限 8 MiB,核对完整 SHA/长度/ffprobe 格式后才调用 SDK;`finally` 删除临时目录。外层 SSH 观察窗口 `timeout 175`,每次 SDK 只调用一次。 + +实际样本 SHA 与已记录值完全相等:177,572 bytes,`audio/wav`,5.546688 秒,单声道 `pcm_s16le` 16 kHz。调用参数:`encoding=flac`、`seg_duration=25`、`seg_overlap=2`,不传 `deadline_total` 或 `idle_timeout`;SDK 对该样本的自动总预算为 120 秒,默认 idle timeout 为 300 秒。旁路包装器在 `send` 前读取 `task_id` 和帧安全摘要,把原始 `payload` 原样交给原始 `send`;不改 UUID、序列化、帧内容或预算。 + +实发任务 ID:`c590f7c3-482b-4d9e-b7c7-f7b8e6a38abc`。producer 安全 payload 为: + +```json +{"encoding":"flac","frame_bytes":125883,"frame_sha256":"448f4aa01c665df5bbe0e95cb80a0bddc9f8f19e600cc323857dcbfc6c930bb8","is_final":true,"media_bytes":94250,"samples_total":88747,"seg_duration":25,"seg_overlap":2,"source":"file","task_id":"c590f7c3-482b-4d9e-b7c7-f7b8e6a38abc","time_utc":"2026-10-04T04:06:45.241+00:00"} +``` + +该摘要保留的是 SDK 实际 producer 帧的白名单投影与完整应用层帧 hash;base64 媒体字段没有写入报告或日志。客户端 `send` 返回成功,发送 1 帧。以下是按这个 UUID 固定过滤后,从服务端日志中投影的事件;实际读了 6 个 `server_latest.log*` 文件,只输出时间、级别、阶段、来源行、终态状态和错误码: + +| 服务端日志本地时间 | 级别 | 阶段 | 来源 | 安全字段 | +| --- | --- | --- | --- | --- | +| 2026-10-04 12:06:44.755 | INFO | receive_complete | `ws_recv.py:315` | 无错误码 | +| 2026-10-04 12:06:44.755 | DEBUG | final_submit | `ws_recv.py:344` | 无错误码 | +| 2026-10-04 12:06:44.870 | INFO | task_end | `state.py:276` | status=done,未设错误码 | +| 2026-10-04 12:06:44.870 | DEBUG | result_dispatched | `ws_send.py:204` | 无错误码 | + +客户端时间为 UTC;SDK `recv()` 旁路观测到与实发 UUID 相同的终态结果帧。随后 SDK 调用仍超时: + +| UTC 时间 | 阶段 | 结果 | +| --- | --- | --- | +| 2026-10-04T04:06:45.157Z | SDK 调用开始 | 一次调用,未重试 | +| 2026-10-04T04:06:45.241Z | client send 开始 | task_id=`c590f7c3-482b-4d9e-b7c7-f7b8e6a38abc`,is_final=true | +| 2026-10-04T04:06:45.246Z | client send 返回 | 成功 | +| 2026-10-04T04:06:45.414Z | client recv 收到帧 | type=result,is_final=true,同一 task_id;不记录识别正文 | +| 2026-10-04T04:08:45.338Z | SDK 调用结束 | `AsrError(code=timeout)`;调用耗时 120.152 秒,未返回 Transcript | + +生产前随机 UUID 查询是 `query_status=ok`、0 命中;真实 UUID 查询也是 `query_status=ok`,6 个日志文件中共 4 条匹配事件。没有把“无事件”当成查询失败;真实查询没有错误码,也没有匹配到服务端 error 事件。服务器日志时间保留其本地原值,不猜偏移量。临时样本文件已删除。client result 对象没有返回,因此没有正文,也没有可报告的正文长度。 + +## 结论与边界 + +已证发送帧含真实 SDK UUID,服务端以同一 ID 接收最终音频、提交最终片段、记录 `task_end status=done` 并分发结果;客户端底层 `recv()` 收到相同 ID 的 `is_final=true` 结果帧。最早可证失败点在客户端收到该帧之后、SDK 调用返回 Transcript 之前。当前证据不能区分 SDK final-frame 处理、异步任务收尾或预算监督中的具体原因,故不宣称根因已定位到更细函数,也不做修复。 + +此实验没有经过 FastAPI、业务重试、下载器调用、缓存或用户认证。任务卡要求直 SDK probe 使用 `media_duration=None`;当前应用代码则会从 `actual_downloader.last_media_duration` 读取可选时长,并在有值时计算 `deadline_total`。因此本报告准确记录本次直 SDK 实际参数,不把它冒称为 API POST 的端到端参数复现。该偏差不影响 UUID 与双端事件关联,但限制对 API 调用超时参数的外推。 + +本次短样本当前下层路线也没有成功返回到 SDK 调用方;服务端确实完成并分发了结果。它不等于历史失败已修复,或 API 端到端通过。没有运行项目测试;卡面 Verify-Command 只验证诊断报告存在。 + +## 生产探针完整源码 + +以下源码就是在 n305 容器执行的单次探针。它不输出服务端 URL、原始日志、媒体正文或转录内容;错误只投影类别和白名单错误码。配置 URL 仅在内存中交给 SDK。 + +```python +import base64, hashlib, ipaddress, json, os, re, shutil, socket, subprocess, tempfile, time, uuid +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import parse_qsl, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener +import commentjson, websockets +from capswriter_asr import client, transcribe_file_sync + +EXPECTED_API_SHA = 'ff92a175243d' +EXPECTED_SDK_SHA = 'ff476ad7cd40401b7ed77c7606cb4fc14dc3d529043c29c4714b199c13423cdf' +EXPECTED_SAMPLE_SHA = 'a1bd32dc78493c123f9625a66deee562aed2895f53fbc39f2cca3be7e6f4f20f' +SOURCE_URL = 'https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_zh.wav' +SENSITIVE_KEYS = {'token','access_token','api_key','key','signature','sig','auth','authorization','credential','password','secret','x_amz_credential','x_amz_signature','x_amz_security_token'} + +def now(): return datetime.now(timezone.utc).isoformat(timespec='milliseconds') +def safe_uuid(value): + try: return str(uuid.UUID(value)) if value else None + except (ValueError, TypeError, AttributeError): return None + +def validate_public_url(value): + parts = urlsplit(value) + if parts.scheme != 'https' or not parts.hostname or parts.username or parts.password or parts.fragment: + raise ValueError('public_url_check_failed') + keys = [k.lower().replace('-', '_') for k, _ in parse_qsl(parts.query, keep_blank_values=True)] + if any(k in SENSITIVE_KEYS or any(x in k for x in ('token','secret','password')) for k in keys): + raise ValueError('public_url_credential_key') + try: addresses = [ipaddress.ip_address(parts.hostname)] + except ValueError: addresses = [ipaddress.ip_address(item[4][0]) for item in socket.getaddrinfo(parts.hostname, parts.port or 443, type=socket.SOCK_STREAM)] + if not addresses or not all(a.is_global for a in addresses): raise ValueError('public_url_not_global') + +class PublicRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + validate_public_url(newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + +async def unused_health(*args): return None + +class ObservedSocket: + def __init__(self, raw, observations, retained): self.raw, self.observations, self.retained = raw, observations, retained + async def send(self, payload, *args, **kwargs): + data = payload.encode('utf-8') if isinstance(payload, str) else bytes(payload) + frame = json.loads(data) + record = {k: frame[k] for k in ('source','encoding','is_final','seg_duration','seg_overlap','samples_total') if k in frame} + record.update({'task_id':safe_uuid(frame.get('task_id')), 'frame_bytes':len(data), 'frame_sha256':hashlib.sha256(data).hexdigest(), 'media_bytes':len(base64.b64decode(frame['data'])), 'event':'client_send_started', 'time_utc':now()}) + self.retained.append(data) + self.observations.append(record) + await self.raw.send(payload, *args, **kwargs) + self.observations.append({'event':'client_send_returned','time_utc':now(),'task_id':record['task_id']}) + async def recv(self): + try: message = await self.raw.recv() + except Exception as exc: + self.observations.append({'event':'client_receive_error','time_utc':now(),'error_class':type(exc).__name__}) + raise + raw = message.encode('utf-8') if isinstance(message, str) else bytes(message) + try: frame = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError): frame = {} + kind = frame.get('type') if frame.get('type') in {'result','error'} else 'other' + record = {'event':'client_receive_frame','time_utc':now(),'message_type':kind,'task_id':safe_uuid(frame.get('task_id')),'is_final':frame.get('is_final') is True} + if kind == 'error' and re.fullmatch(r'[a-z0-9_]{1,64}', str(frame.get('code',''))): record['error_code'] = frame['code'] + self.observations.append(record) + return message + +class ObservedContext: + def __init__(self, cm, observations, retained): self.cm, self.observations, self.retained = cm, observations, retained + async def __aenter__(self): return ObservedSocket(await self.cm.__aenter__(), self.observations, self.retained) + async def __aexit__(self, *args): return await self.cm.__aexit__(*args) + +def run(): + if os.environ.get('GIT_SHA') != EXPECTED_API_SHA: raise RuntimeError('api_build_mismatch') + sdk_path = Path(__import__('inspect').getsourcefile(client)) + if hashlib.sha256(sdk_path.read_bytes()).hexdigest() != EXPECTED_SDK_SHA: raise RuntimeError('sdk_pin_mismatch') + config = commentjson.loads(Path('/app/config/config.jsonc').read_text(encoding='utf-8')) + caps = config.get('capswriter', {}) + seg_duration = caps.get('file_seg_duration', 25) + seg_overlap = caps.get('file_seg_overlap', 2) + server_url = caps.get('server_url') + parts = urlsplit(server_url or '') + if parts.scheme not in {'ws','wss'} or not parts.hostname: raise RuntimeError('server_url_invalid') + validate_public_url(SOURCE_URL) + work = Path(tempfile.mkdtemp(prefix='vta166-wire-probe-')) + sample = work / 'sample.wav' + result = {'status':'precondition_failed','api_git_sha':EXPECTED_API_SHA,'sdk_pin_match':True,'parameters':{'encoding':'flac','seg_duration':seg_duration,'seg_overlap':seg_overlap,'media_duration':None,'deadline_total_passed':False,'idle_timeout_passed':False,'sdk_default_idle_timeout_seconds':300,'automatic_retry':False}} + try: + opener = build_opener(PublicRedirect()) + request = Request(SOURCE_URL, headers={'User-Agent':'VideoTranscriptAPI-wire-probe'}) + with opener.open(request, timeout=20) as response: + validate_public_url(response.geturl()) + body = response.read(8 * 1024 * 1024 + 1) + sample_projection = {'http_status':response.status,'content_type':response.headers.get('Content-Type','').split(';',1)[0],'bytes':len(body),'sha256':hashlib.sha256(body).hexdigest()} + if sample_projection['http_status'] != 200 or sample_projection['bytes'] != 177572 or sample_projection['sha256'] != EXPECTED_SAMPLE_SHA: + raise RuntimeError('sample_identity_mismatch') + sample.write_bytes(body) + probe = subprocess.run(['ffprobe','-v','error','-show_entries','format=duration,format_name:stream=codec_type,codec_name,sample_rate,channels','-of','json',str(sample)],capture_output=True,text=True,check=True,timeout=8) + probe_data = json.loads(probe.stdout) + fmt = probe_data['format'] + streams = probe_data['streams'] + duration = float(fmt['duration']) + if fmt.get('format_name') != 'wav' or len(streams) != 1 or streams[0].get('codec_type') != 'audio' or streams[0].get('codec_name') != 'pcm_s16le' or streams[0].get('sample_rate') != '16000' or streams[0].get('channels') != 1: + raise RuntimeError('sample_format_mismatch') + sample_projection.update({'format_name':fmt.get('format_name'),'duration_seconds':duration,'streams':[{'codec_type':s.get('codec_type'),'codec_name':s.get('codec_name'),'sample_rate':s.get('sample_rate'),'channels':s.get('channels')} for s in streams]}) + result['sample'] = sample_projection + result['effective_sdk_default_total_deadline_seconds'] = max(120.0, duration + 60.0) + result['client_call_started_utc'] = now() + observations, retained = [], [] + real_connect = websockets.connect + def observed_connect(*args, **kwargs): return ObservedContext(real_connect(*args, **kwargs), observations, retained) + websockets.connect = observed_connect + client_error = None + transcript = None + started = time.monotonic() + try: transcript = transcribe_file_sync(sample, server_url, encoding='flac', seg_duration=seg_duration, seg_overlap=seg_overlap) + except Exception as exc: + client_error = {'error_class':type(exc).__name__} + if re.fullmatch(r'[a-z0-9_]{1,64}', str(getattr(exc,'code',''))): client_error['error_code'] = exc.code + finally: + websockets.connect = real_connect + elapsed = round(time.monotonic() - started, 3) + result.update({'status':'probe_completed','client_outcome':'succeeded' if client_error is None else 'failed','client_error':client_error,'client_call_ended_utc':now(),'client_elapsed_seconds':elapsed,'actual_send_count':len([e for e in observations if e.get('event') == 'client_send_started']),'producer_payload_safe':observations[0] if observations else None,'client_timeline':observations,'transcript_projection':None if transcript is None else {'task_id':safe_uuid(transcript.task_id),'is_final':transcript.is_final,'text_chars':len(transcript.text or ''),'text_accu_chars':len(transcript.text_accu or '')}}) + if observations and observations[0].get('task_id') is None: result['observer_uuid_valid'] = False + else: result['observer_uuid_valid'] = bool(observations) + return result + finally: + shutil.rmtree(work) + result['sample_temp_removed'] = not work.exists() + +try: + output = run() +except Exception as exc: + output = {'status':'stopped_before_or_during_probe','error_class':type(exc).__name__} + if str(exc) in {'api_build_mismatch','sdk_pin_mismatch','sample_identity_mismatch','sample_format_mismatch','server_url_invalid'}: output['stop_reason'] = str(exc) +print(json.dumps(output, sort_keys=True)) +``` + +真实探针唯一 stdout(完整原始 frame body、URL endpoint、转录内容均未输出): + +```json +{"actual_send_count": 1, "api_git_sha": "ff92a175243d", "client_call_ended_utc": "2026-10-04T04:08:45.338+00:00", "client_call_started_utc": "2026-10-04T04:06:45.157+00:00", "client_elapsed_seconds": 120.152, "client_error": {"error_class": "AsrError", "error_code": "timeout"}, "client_outcome": "failed", "client_timeline": [{"encoding": "flac", "event": "client_send_started", "frame_bytes": 125883, "frame_sha256": "448f4aa01c665df5bbe0e95cb80a0bddc9f8f19e600cc323857dcbfc6c930bb8", "is_final": true, "media_bytes": 94250, "samples_total": 88747, "seg_duration": 25, "seg_overlap": 2, "source": "file", "task_id": "c590f7c3-482b-4d9e-b7c7-f7b8e6a38abc", "time_utc": "2026-10-04T04:06:45.241+00:00"}, {"event": "client_send_returned", "task_id": "c590f7c3-482b-4d9e-b7c7-f7b8e6a38abc", "time_utc": "2026-10-04T04:06:45.246+00:00"}, {"event": "client_receive_frame", "is_final": true, "message_type": "result", "task_id": "c590f7c3-482b-4d9e-b7c7-f7b8e6a38abc", "time_utc": "2026-10-04T04:06:45.414+00:00"}], "effective_sdk_default_total_deadline_seconds": 120.0, "observer_uuid_valid": true, "parameters": {"automatic_retry": false, "deadline_total_passed": false, "encoding": "flac", "idle_timeout_passed": false, "media_duration": null, "sdk_default_idle_timeout_seconds": 300, "seg_duration": 25, "seg_overlap": 2}, "producer_payload_safe": {"encoding": "flac", "event": "client_send_started", "frame_bytes": 125883, "frame_sha256": "448f4aa01c665df5bbe0e95cb80a0bddc9f8f19e600cc323857dcbfc6c930bb8", "is_final": true, "media_bytes": 94250, "samples_total": 88747, "seg_duration": 25, "seg_overlap": 2, "source": "file", "task_id": "c590f7c3-482b-4d9e-b7c7-f7b8e6a38abc", "time_utc": "2026-10-04T04:06:45.241+00:00"}, "sample": {"bytes": 177572, "content_type": "audio/wav", "duration_seconds": 5.546688, "format_name": "wav", "http_status": 200, "sha256": "a1bd32dc78493c123f9625a66deee562aed2895f53fbc39f2cca3be7e6f4f20f", "streams": [{"channels": 1, "codec_name": "pcm_s16le", "codec_type": "audio", "sample_rate": "16000"}]}, "sample_temp_removed": true, "sdk_pin_match": true, "status": "probe_completed", "transcript_projection": null} +``` + +## 过滤日志查询源码 + +下面是可单独运行的完整只读过滤器。它固定按传入 task ID 筛行,只输出时间、级别、阶段、来源位置、终态状态和错误码;读失败会输出 query_status=error,与成功但零命中分开。 + +~~~python +import glob, json, pathlib, re, sys + +log_dir = pathlib.Path(sys.argv[1]) +task_id = sys.argv[2] +events = [] +try: + for name in sorted(glob.glob(str(log_dir / 'server_latest.log*'))): + with open(name, 'r', encoding='utf-8', errors='replace') as stream: + for line in stream: + if task_id not in line: + continue + ts = re.match(r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d+)', line) + level = re.search(r'\b(DEBUG|INFO|WARNING|ERROR|CRITICAL)\b', line) + loc = re.search(r'\[\s*([^:\]]+):(\d+)', line) + file_name = loc.group(1).strip() if loc else None + line_no = int(loc.group(2)) if loc else None + if file_name == 'ws_recv.py' and line_no == 315: phase = 'receive_complete' + elif file_name == 'ws_recv.py' and line_no == 344: phase = 'final_submit' + elif file_name == 'state.py' and line_no == 276: phase = 'task_end' + elif file_name == 'ws_send.py' and line_no == 204: phase = 'result_dispatched' + elif level and level.group(1) in {'ERROR','CRITICAL'}: phase = 'error' + else: phase = 'task_id_event' + event = {'time_local':ts.group(1) if ts else None,'level':level.group(1) if level else None,'phase':phase,'source_file':file_name,'source_line':line_no} + code = re.search(r'\bcode=([a-z0-9_-]{1,64})', line) + if code and code.group(1) != '-': event['error_code'] = code.group(1) + status = re.search(r'\bstatus=(done|failed)\b', line) + if status: event['task_status'] = status.group(1) + events.append(event) + print(json.dumps({'task_id':task_id,'query_status':'ok','event_count':len(events),'events':events}, sort_keys=True)) +except OSError as exc: + print(json.dumps({'task_id':task_id,'query_status':'error','error_class':type(exc).__name__}, sort_keys=True)) + raise SystemExit(2) +~~~ + +## 坑、闸、偏差与最贵的一步 + +- pickup 巡检摘要为 summary: orphan 0 owned 0 unattributable 0 too-new 0 recent-7d 0 stale-over-7d 0 missing_ledger_repos 0;巡检项未展开,需要时跑 /worksite-audit。memory 探针原行是“memory 巡检报告不可用:memory_dir_mismatch(本机 latest.json)”;需运行 memory_doctor_run.sh 重建。 + +- 先本地正例/负例验证关联判据,再生产调用;随机 UUID 的空命中证明日志查询通道可读,不用“看到任何日志”冒充匹配。 +- 第一次 Mac `lsof -b` 因系统卷扫描限制输出了大量 warning;输出被工具截断,未用于结论。后续在远端把该命令的 stderr 重定向到 `/dev/null`,只取目标 listener PID/CWD,投影结果不含这些警告。 +- 一次补充日志投影脚本因正则写错在读取日志后退出;前一条按 UUID 查询已经成功。修正后按相同 ID 只读查询成功,输出仍只有白名单字段。该脚本问题不导致 ASR 重发。 +- 首次推送被仓库公开内容扫描器拦下,因为报告引用了本机报告/巡检路径;已将仓内文档改为无本机绝对路径表述,私有派发报告保留详细路径。 +- 探测本机 SDK 时误用 `uv run --no-sync`,它新建了空 `.venv` 后因 SDK 未安装而导入失败;立即删除这次新建的工作树 `.venv`,之后从现成 SDK 源码树加载,没有安装依赖。工作树最终状态复核干净(文档提交前)。 +- `docs/project-memory.md` 在本 worktree 不存在;部署事实复用用户卡片和 agent memory 索引命中的上一轮安全报告,没有臆测补写。 +- 卡片的 `media_duration=None` 用于本次直接 SDK 探针;当前 API 代码可从 downloader 取得时长并据此算 deadline。因为没有走 API 下载器,本报告没有验证该值会否在同样 URL 的 API 流程中非空,作为范围偏差明确保留。 +- 最贵的一步是等待唯一 SDK 请求走完默认自动总预算:服务端在约 0.3 秒内收到并完成任务,但调用方直到 120.152 秒才返回 timeout。没有为“多一次可能解释更多”消耗第二次真实调用。 +- 未能判定任何 CI 继承红/新红:本卡不跑 CI 或测试,派发卡基线记录 `gh api request failed`。 + +## 交付 + +- 文档新增行数超过 target 200、低于 hard 600;增量主要是任务要求保留的本地与生产完整探针源码、producer 安全 payload 和白名单输出。 +- 仅有本报告和 `progress/wire-probe-progress.md` 两个文档在提交范围内;测试未运行。 +- 验收命令:`test -s docs/sessions/triage-261004/wire-probe.md`。 +- 本地 fake server 断言属于任务要求的观察器契约验证,不是项目测试套件。 +- 最终 commit、push 远端 SHA、diff 行数、`git diff --check` 与工作树收据在本卡最终回复中给出。