Files
operating-room-monitor-server/docs/客户端手术通信接口说明.md
Kevin ae6300b8b2 Add rotating file logs, access noise filters, and longer RTSP start timeout.
Persist app logs under logs/app with 7-day rotation, suppress routine health/pending access lines, raise default VIDEO_OPEN_TIMEOUT_SEC to 90s, and document consumable codes plus client timeout guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 13:11:51 +08:00

725 lines
37 KiB
Markdown
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 手术室监控服务:客户端手术通信接口说明
## Changelog
对接方请按部署版本核对本节;**已发布路径与字段语义以正文为准**,本节仅记录相对上一版的客户端需改动点。
### 2026-05-26
**行为 / 运维**
| 项 | 变更 |
| --- | --- |
| `POST /client/surgeries/start` | 默认 RTSP 开录等待由 45s 调至 **90s**;客户端 HTTP 超时建议 ≥ **300s** |
| 服务端日志 | 应用日志持久化至 `backend/logs/app/server.log`**7 天轮转/保留**access 日志隐藏 `GET /health` 200 与 pending 轮询 404 |
### 2026-05-26
**文档**
| 接口 | 变更 | 客户端需改动 |
| ---- | ---- | ------------ |
| `POST /internal/demo/offline-batch` 及配套 GET | **新增** §5.7§5.9:链路 3 离线 MP4 上传、耗时轮询、标注视频下载 | 非 RTSP 集成时可走上传视频路径;仍用 `GET /client/surgeries/{surgery_id}/result` 查消耗,**无需** `start` / `end` |
### 2026-05-25
**行为对齐(实现已与正文一致)**
| 接口 | 变更 | 客户端需改动 |
| ---- | ---- | ------------ |
| `GET /client/surgeries/{surgery_id}/result` | 无明细时的 `503` **统一**返回 `detail.code = RESULT_NOT_READY``detail.message` 仍为人可读原因) | 勿再依赖已废弃的 `SURGERY_NOT_STARTED``SURGERY_STARTING``SURGERY_IN_PROGRESS_NO_DETAILS``SURGERY_ENDED_NO_CONSUMPTION` 等细分码;判断 `503` 时用 `RESULT_NOT_READY` |
| `POST .../pending-confirmation/{confirmation_id}/resolve` | ASR/解析可重试失败(如 `VOICE_ASR_FAILED`)改回 **HTTP 422**,不再返回 `200` + `status: "failed"` | 删除对 `200`/`status=failed`/`error_code` 的处理;可重试失败按 `422` + `detail.code` 重录上传 |
| `POST /client/surgeries/start``candidate_consumables` | 新增:可仅传**产品编码**`label_id`,与 `consumable_classifier_labels.yaml` 一致) | 可传 `["14764-2-4"]``[{"消耗品编号":"14764-2-4"}]`;服务端解析为类名后参与推理,响应与其它字段不变 |
**未变更**
- `/client/...` 路由路径、HTTP 方法、`surgery_id` 约束、成功体字段名与 §5 各节描述一致。
---
## 能力概览
| **能力** | **说明** |
| --------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **探活** | `GET /health`,用于检查进程和数据库状态,详见 5.1 节。 |
| **开始手术** | `POST /client/surgeries/start`,只有在开录确认成功后才返回 `200`**链路 1 · 真 RTSP**)。 |
| **结束手术** | `POST /client/surgeries/end`,只有在停录确认成功后才返回 `200`(链路 1。 |
| **上传视频** | `POST /internal/demo/offline-batch`,上传单路 MP4 离线推理(**链路 3****无需** `start` / `end`,不触发语音终端;详见 5.7 节。需运维开启 `DEMO_ORCHESTRATOR_ENABLED=true`。 |
| **查询结果** | `GET /client/surgeries/{surgery_id}/result`,至少存在一条消耗明细时返回 `200`;否则返回 `503``detail.code` 为 `RESULT_NOT_READY`。链路 1 / 3 共用。 |
| **待确认播报** | **官方浏览器客户端**(仓库 `clients/voice-confirmation/`,可独立部署):同一 WebSocket 上推送 `voice_pending`(载荷与 `GET .../pending-confirmation` 成功体一致,另含 `type`);无队首时 `voice_pending_empty`**不轮询 GET**。**第三方**仍可用 `GET .../pending-confirmation` 拉取队首。**链路 3 无待确认。** |
| **待确认答复** | `POST /client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve`,上传医生答复的 WAV 录音,服务端完成 ASR 后入账或关闭。该录音与播报音频无关。 |
## 1. 服务与基础信息
| **项目** | **说明** |
| ----------------------- | ------------------------------------------- |
| **协议** | `HTTP/HTTPS` |
| **端口** | `38080`,生产环境以实际入口为准 |
| **路由** | 无全局前缀;业务接口位于 `/client/...`,健康检查位于 `/health`;链路 3 上传位于 `/internal/demo/...` |
| **`start` / `end` 请求体** | JSON |
| **链路 3 上传请求体** | `multipart/form-data`,字段见 5.7 节 |
| **`resolve` 请求体** | `multipart/form-data`,字段名为 `audio` |
| **在线文档** | `/docs``/redoc` |
| **语音确认官方页面** | 仓库 `clients/voice-confirmation/`(静态资源,与 API 分宿;需为浏览器配置 CORS见该目录 `README.md` |
局域网部署时API 应监听或发布到可被语音终端访问的地址(例如 `0.0.0.0:38080` 发布为 `http://192.168.1.100:38080`)。语音确认页面的 **服务端 Base URL** 必须填写该 API 根地址;浏览器端会用同一主机和端口生成 WebSocket 地址:`ws://192.168.1.100:38080/client/voice-terminals/ws?terminal_id=...`。如果语音页与 API 不同源,需配置 CORS 放行语音页来源。
## 2. 摄像头 ID 与 RTSP
RTSP 地址、账号、口令等由客户端对接工程师提供给服务端运维,运维再写入服务端环境。客户端只在 **链路 1**`POST /client/surgeries/start` 中传 `camera_ids`。**链路 3 上传视频不需要 `camera_ids`。**
| **camera_id** | **RTSP** | **备注** |
| ------------- | -------- | -------- |
| `or-cam-01` | `rtsp://admin:Aa183137@192.168.3.2:554/Streaming/Channels/101` | 测试手术室,主码流 ch.101 |
| `or-cam-02` | `rtsp://admin:Aa183137@192.168.3.3:554/Streaming/Channels/101` | 同上 |
| `or-cam-03` | `rtsp://admin:Aa183137@192.168.3.4:554/Streaming/Channels/101` | 同上 |
| `or-cam-04` | `rtsp://admin:Aa183137@192.168.3.5:554/Streaming/Channels/101` | 同上 |
**Docker 部署**API 在容器内拉流时,上表这类**术间摄像头局域网 IP**通常可继续使用(出站经宿主机路由,须宿主机已能访问该网段)。若 RTSP 实际跑在**宿主机本机**假流等URL 中的主机应使用 `host.docker.internal`,勿写 `127.0.0.1`;详见 `docs/video-backends.md``backend/docker-compose.yml`
## 3. HTTP 路由一览
| **序号** | **方法** | **路径** | **说明** |
| ------ | ------ | ------------------------------------------------------------------------------- | ------- |
| 1 | `GET` | `/health` | 探活 |
| 2 | `POST` | `/client/surgeries/start` | 开始手术(链路 1 |
| 3 | `POST` | `/client/surgeries/end` | 结束手术(链路 1 |
| 4 | `GET` | `/client/surgeries/{surgery_id}/result` | 查询手术结果(链路 1 / 3 共用) |
| 5 | `GET` | `/client/surgeries/{surgery_id}/pending-confirmation` | 拉取待确认耗材(链路 1 |
| 6 | `POST` | `/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve` | 提交医生答复(链路 1 |
| 7 | `POST` | `/internal/demo/offline-batch` | **链路 3**:上传 MP4 离线推理(需 `DEMO_ORCHESTRATOR_ENABLED=true` |
| 8 | `GET` | `/internal/demo/offline-batch/{surgery_id}/timing` | 链路 3查询文本 / 标注视频各阶段耗时 |
| 9 | `GET` | `/internal/demo/offline-batch/{surgery_id}/visualization` | 链路 3下载标注 MP4可选 |
| 10 | `GET` | `/internal/demo/recording-modes-status` | 可选:探测链路 3 是否已启用 |
| 11 | `GET` | `/client/voice-terminals/{terminal_id}/assignment` | 可选:查询当前指派(调试或简易集成;**官方浏览器客户端仅用 WebSocket** |
| 12 | `WS` | `/client/voice-terminals/ws?terminal_id=...` | 语音终端长连接,接收开录/停录指派(**推荐**;与 `clients/voice-confirmation` 共用) |
| | | | |
**术间与语音终端绑定(服务端配置)**
- **唯一配置源**:环境变量 **`OR_SITE_CONFIG_JSON_FILE`** 指向手术室 **站点 JSON**UTF-8须同时包含 `video_rtsp_urls``voice_or_room_bindings`(见仓库 `app/resources/or_site_config.sample.json`)。`voice_or_room_bindings` 为数组,每项含 `or_room_id``camera_ids``voice_terminal_id``camera_ids` 在数组内须唯一,`voice_terminal_id` 全局唯一。
- **`POST /client/surgeries/start`** 在 **HTTP 200 且开录已成功** 后:用请求体中的 `camera_ids``voice_or_room_bindings` 中解析终端(**精确匹配**术间 camera 集合,或 **开录路集为某术间 camera 集合的子集** 时匹配该术间);命中则向对应 `voice_terminal_id` 推送 **`action":"start"`**(并更新 assignment未配置站点文件、或数组为空、或未命中则仅打日志不影响 200。
- **`POST /client/surgeries/end`** 在停录 **HTTP 200** 后:向该手术会话记录的终端推送 **`action":"end"`**(并清除 assignment
- 推送 JSON 形如:`{"type":"voice_assignment","action":"start"|"end","surgery_id":"123456"}`
- **待确认队列(与 HTTP GET 成功体对齐)**:有新队首或队首变化时推送 `{"type":"voice_pending", "surgery_id":"...", "confirmation_id":"...", "pending_queue_length":1, "pending_queue_position":1, "pending_cumulative_ordinal":1, "prompt_text":"...", "prompt_audio_mp3_base64":"...", "options":[...], "model_top1_label":"...", "model_top1_confidence":0.0, "created_at":"..."}`(字段与 `GET /client/surgeries/{surgery_id}/pending-confirmation` 的 200 响应相同,并多一个 `type`。FIFO 无待确认项时推送 `{"type":"voice_pending_empty","surgery_id":"123456"}`。触发时机包括:开录指派后(后台任务)、视觉入队待确认后、医生 `resolve` 成功弹出队首后、终端 **WebSocket 刚连接且当前已有指派** 时(先补发 `voice_assignment` start再补发队首或 empty
- **多 worker**:当前实现为进程内内存;多 Uvicorn worker 时需 sticky session 或 Redis 等另行同步。
## 4. 流程
### 4.1 时序图(链路 1 · 真 RTSP
```mermaid
sequenceDiagram
participant Client as 客户端
participant Server as 服务端
Client->>Server: POST /client/surgeries/start
Note over Client,Server: body: surgery_id, camera_ids, candidate_consumables
Server-->>Client: 200 accepted开录已确认
par 术中
loop 轮询结果
Client->>Server: GET .../result
Server-->>Client: 200 或 503 RESULT_NOT_READY
end
loop 轮询待确认
Client->>Server: GET .../pending-confirmation
Server-->>Client: 200 或 404
opt 有待确认
Client->>Client: 播放 prompt_audio_mp3_base64
Client->>Server: POST .../resolvemultipart audio
Server-->>Client: 200 accepted
end
end
end
Client->>Server: POST /client/surgeries/end
Server-->>Client: 200 accepted停录已确认
Client->>Server: GET .../result
Server-->>Client: 200持久化后可查时返回
```
### 4.2 状态图(链路 1
```mermaid
flowchart LR
A[未开始] -->|start 200| B[录制 / 推理中]
B -->|result 200| C[有消耗数据可查]
B -->|pending 200| D[待确认]
D -->|resolve| B
B -->|end 200| E[已结束]
E -->|result 200| C
```
### 4.3 时序图(链路 3 · 上传 MP4
与 §4.1 并行存在,适用于**已有完整术间录像**、无需实时拉流与语音待确认的场景。
```mermaid
sequenceDiagram
participant Client as 客户端
participant Server as 服务端
opt 探测(可选)
Client->>Server: GET /internal/demo/recording-modes-status
Server-->>Client: demo_recording_modes_enabled
end
Client->>Server: POST /internal/demo/offline-batch
Note over Client,Server: multipart: surgery_id, video1, candidate_consumables_json, include_visualization
Server-->>Client: 200 acceptedTSV 推理已完成)
Client->>Server: GET /client/surgeries/{surgery_id}/result
Server-->>Client: 200 或 503 RESULT_NOT_READY
opt include_visualization=true
loop 直至 video_status=ready 或 failed
Client->>Server: GET .../offline-batch/{surgery_id}/timing
Server-->>Client: video_status pending / ready / failed
end
Client->>Server: GET .../offline-batch/{surgery_id}/visualization
Server-->>Client: video/mp4
end
```
**与链路 1 的差异**
| 项目 | 链路 1RTSP | 链路 3上传 MP4 |
| ---- | -------------- | ------------------ |
| 开录 / 停录 | 需要 `start` / `end` | **不需要** |
| 语音终端 / 待确认 | 有 | **无**(结果直接入库) |
| 查结果 | `GET .../result` | 同上 |
| 服务端开关 | RTSP 站点配置 | 另需 `DEMO_ORCHESTRATOR_ENABLED=true` |
## 5. 接口详情
**路径参数 `surgery_id`**
| **约束项** | **说明** |
| ------- | --------- |
| **长度** | 固定 `6` |
| **字符集** | 仅数字 |
| **正则** | `^\d{6}$` |
**业务错误响应**
`/client/...` 多数业务失败在 `4xx` 或 `5xx` 下返回如下 JSON
```
{
"detail": {
"code": "错误码字符串",
"message": "人类可读说明",
"surgery_id": "123456"
}
}
```
§5.7§5.9(链路 3的部分校验失败返回 FastAPI 默认 `detail` **字符串**(无 `code` 包装),见各节状态码表。
### 5.1 探活
**基本信息**
| **项目** | **内容** |
| ------- | --------- |
| **方法** | `GET` |
| **路径** | `/health` |
| **请求体** | 无 |
**响应说明**
| **HTTP** | **说明** | **响应体示例** |
| -------- | ----------- | ------------------------------------------------ |
| `200` | 进程正常且数据库可连通 | `{"status":"ok","database":"connected"}` |
| `503` | 数据库不可用(降级) | `{"status":"degraded","database":"unavailable"}` |
### 5.2 开始手术
**基本信息**
| **项目** | **内容** |
| ---------------- | --------------------------------- |
| **方法** | `POST` |
| **路径** | `/client/surgeries/start` |
| **Content-Type** | `application/json; charset=utf-8` |
**业务说明**
- 服务端会为 `camera_ids` 中的每个摄像头建立拉流与推理任务,只有在确认开录成功(如首帧就绪)后才返回 HTTP `200`
- 本接口**同步阻塞**直至开录确认;默认 RTSP 等待约 **95 秒/次**、最多 **3 次重试**。客户端 HTTP 超时建议 **≥ 300 秒**Apipost 等工具默认 3060 秒易触发 `ESOCKETTIMEDOUT`)。服务端可通过 `VIDEO_OPEN_TIMEOUT_SEC` 调大(默认 90
- `candidate_consumables` 缺省或 `[]` 时,服务端会展开为 `consumable_classifier_labels.yaml` 中的全部类名(无有效 yaml 时开录失败)。
- 非空时,每项可为**耗材名称**或**产品编码**`label_id`);编码通过 yaml 解析为类名后再参与推理与白名单。亦支持医院导出对象(见下表)。
- **41 类完整类名与产品编码对照**:见 [`docs/耗材产品编码与类名对照表.md`](耗材产品编码与类名对照表.md)(须用完整类名或编码,口语简称如「纱布」不会自动映射)。
**请求体JSON**
| **字段** | **类型** | **必填** | **说明** |
| ----------------------- | ---------- | ------ | ----------------------------------- |
| `surgery_id` | `string` | 是 | 6 位数字 |
| `camera_ids` | `string[]` | 是 | 至少 1 个;必须与运维配置的摄像头 ID 完全一致,见第 2 节 |
| `candidate_consumables` | `string[]` 或对象数组 | 否 | 非空时仅这些名称/编码参与自动记账与待确认;缺省或 `[]` 时使用全部候选。字符串可为类名或 `label_id`;对象见下 |
**`candidate_consumables` 数组元素**
| **形式** | **示例** | **说明** |
| -------- | -------- | -------- |
| 名称字符串 | `"医用纱布敷料"` | 须与 yaml / 模型类名**完全一致**(见对照表) |
| 编码字符串 | `"14764-2-4"` | 与 yaml 中 `label_id` 一致,服务端解析为类名 |
| 导出对象(名称) | `{"消耗品编号":"14764-2-4","名称":"一次性使用手术单"}` | 以 `名称`(或 `name`)为准 |
| 导出对象(仅编号) | `{"消耗品编号":"14764-2-4"}` | 按编号解析为类名 |
**响应体200**
| **字段** | **类型** | **说明** |
| ------------ | -------- | ----------------- |
| `surgery_id` | `string` | 与请求一致 |
| `status` | `string` | 成功时通常为 `accepted` |
| `message` | `string` | 说明文案 |
**状态码**
| **HTTP** | **说明** |
| -------- | -------------------------------------------------------- |
| `200` | 开录已确认 |
| `422` | 参数校验失败,例如 `surgery_id` 非 6 位或 `camera_ids` 为空数组 |
| `503` | 开录未确认或录制子系统故障;`detail.code` 常见为 `RECORDING_CANNOT_START` |
**请求示例**
```
{
"surgery_id": "123456",
"camera_ids": ["or-cam-01", "or-cam-02", "or-cam-03", "or-cam-04"],
"candidate_consumables": ["纱布", "缝线", "止血钳"]
}
```
仅传产品编码时:
```
{
"surgery_id": "123456",
"camera_ids": ["or-cam-01"],
"candidate_consumables": ["14764-2-4", "8036-5-22"]
}
```
**响应示例200**
```
{
"surgery_id": "123456",
"status": "accepted",
"message": "摄像头录制已开始,手术已启动。"
}
```
### 5.3 结束手术
**基本信息**
| **项目** | **内容** |
| ---------------- | --------------------------------- |
| **方法** | `POST` |
| **路径** | `/client/surgeries/end` |
| **Content-Type** | `application/json; charset=utf-8` |
**业务说明**
停止该 `surgery_id` 关联的全部摄像头任务,只有在确认停录完成后才返回 `200`
**请求体JSON**
| **字段** | **类型** | **必填** | **说明** |
| ------------ | -------- | ------ | ------ |
| `surgery_id` | `string` | 是 | 6 位数字 |
**响应体200**
字段含义与 5.2 节一致,`message` 示例为 `摄像头录制已停止,手术已结束。`
**状态码**
| **HTTP** | **说明** |
| -------- | -------------------------------------------------- |
| `200` | 停录已确认 |
| `422` | 参数校验失败 |
| `503` | 停录未确认或故障;`detail.code` 常见为 `RECORDING_NOT_STOPPED` |
**请求示例**
```
{
"surgery_id": "123456"
}
```
### 5.4 查询手术结果
**基本信息**
| **项目** | **内容** |
| -------- | --------------------------------------- |
| **方法** | `GET` |
| **路径** | `/client/surgeries/{surgery_id}/result` |
| **路径参数** | `surgery_id` |
| **请求体** | 无 |
**业务说明**
- 仅当存在至少一条消耗明细时返回 `200`
- 无明细(包括已归档但零消耗)、手术未开始、未成功开录或当前尚不可查时,返回 `503`
- 上述 `503``detail.code`**`RESULT_NOT_READY`**`detail.message` 说明具体原因(如未开始、进行中尚无明细、已结束无消耗等)。
- **链路 3**:离线 batch 完成后结果直接写入数据库,通过本接口查询;**无需**先调用 `start` / `end`。若 batch 识别结果为零条明细,本接口仍返回 `503`
**响应体200**
| **字段** | **类型** | **说明** |
| ------------ | -------- | ----------------------- |
| `surgery_id` | `string` | 手术号 |
| `status` | `string` | 成功时通常为 `completed` |
| `message` | `string` | 说明 |
| `details` | `array` | 消耗明细列表,字段见下文 |
| `summary` | `array` | 按 `item_id` 汇总的结果,字段见下文 |
**`details[]` 元素**
| **字段** | **类型** | **说明** |
| ----------- | --------- | ---------------------------------- |
| `item_id` | `string` | 物品 ID有 yaml 时多为 `label_id`,否则通常与类名一致 |
| `item_name` | `string` | 物品名称 |
| `qty` | `integer` | 本条记录数量,当前恒为 `1`;一次识别或一次人工确认只追加一条明细 |
| `doctor_id` | `string` | 记账关联的医生或系统标识 |
| `timestamp` | `string` | ISO 8601 时间(`date-time` |
**`summary[]` 元素**
| **字段** | **类型** | **说明** |
| ---------------- | --------- | -------------------------- |
| `item_id` | `string` | 与明细一致 |
| `item_name` | `string` | 名称,通常取该 `item_id` 首条明细中的名称 |
| `total_quantity` | `integer` | 该物品在本台手术中的合计数量,`>= 0` |
**状态码**
| **HTTP** | **说明** |
| -------- | ------------------------------ |
| `200` | 至少有一条明细 |
| `422` | `surgery_id` 路径不符合约束 |
| `503` | `detail.code``RESULT_NOT_READY``message` 为具体原因 |
**响应示例200**
```
{
"surgery_id": "123456",
"status": "completed",
"message": "查询成功。",
"details": [
{
"item_id": "19246-3-14",
"item_name": "医用纱布敷料",
"qty": 1,
"doctor_id": "6611",
"timestamp": "2026-04-21T10:30:00+08:00"
}
],
"summary": [
{
"item_id": "19246-3-14",
"item_name": "医用纱布敷料",
"total_quantity": 1
}
]
}
```
### 5.5 拉取待确认耗材
**基本信息**
| **项目** | **内容** |
| -------- | ----------------------------------------------------- |
| **方法** | `GET` |
| **路径** | `/client/surgeries/{surgery_id}/pending-confirmation` |
| **路径参数** | `surgery_id` |
| **请求体** | 无 |
**业务说明**
- 返回当前 FIFO 队首的一条低置信度识别任务。
- `prompt_audio_mp3_base64` 与 `prompt_text` 内容一致,为标准 Base64 的 MP3 字符串(无换行)。
- 客户端解码后应按 `audio/mpeg` 播放。
**响应体200**
| **字段** | **类型** | **说明** |
| ------------------------- | -------- | ------------------------- |
| `surgery_id` | `string` | 手术号 |
| `confirmation_id` | `string` | 待确认项 ID提交 5.6 节接口时原样放入路径 |
| `pending_queue_length` | `int` | 当前 FIFO 中仍为 pending 的条数(含本条) |
| `pending_queue_position` | `int` | 本条在 `pending_fifo` 中的排队序号1-based队首为 1 |
| `pending_cumulative_ordinal` | `int` | 本场手术中待确认任务累计入队序号(第几条入队) |
| `prompt_text` | `string` | 播报或展示用语,与 MP3 内容一致 |
| `prompt_audio_mp3_base64` | `string` | MP3 的 Base64 |
| `options` | `array` | 候选项列表,字段见下文 |
| `model_top1_label` | `string` | 模型原始 Top1 类名,可能不在本台候选内 |
| `model_top1_confidence` | `number` | Top1 置信度 |
| `created_at` | `string` | 创建时间ISO 8601 |
**`options[]` 元素**
| **字段** | **类型** | **说明** |
| ------------ | -------- | ---------- |
| `label` | `string` | 展示给医生的选项名称 |
| `confidence` | `number` | 该选项对应的置信度 |
**状态码**
| **HTTP** | **说明** |
| -------- | ----------------------------------------------------- |
| `200` | 当前有一条待确认 |
| `404` | 无待确认或手术未活跃;`detail.code` 为 `NO_PENDING_CONFIRMATION` |
| `422` | 例如话术为空导致无法 TTS`detail.code` 如 `TTS_TEXT_EMPTY` |
| `503` | 语音服务未配置或 TTS 失败;例如 `BAIDU_NOT_CONFIGURED``TTS_ERROR` |
### 5.6 提交待确认结果(医生语音)
**基本信息**
| **项目** | **内容** |
| ---------------- | ------------------------------------------------------------------------------- |
| **方法** | `POST` |
| **路径** | `/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve` |
| **Content-Type** | `multipart/form-data` |
**路径参数**
| **参数** | **约束** | **说明** |
| ----------------- | ---------- | -------------------------------- |
| `surgery_id` | 6 位数字 | 同 §5 路径参数 |
| `confirmation_id` | 长度 1 到 128 | 与 5.5 节响应中的 `confirmation_id` 一致 |
**请求体multipart**
| **字段名** | **类型** | **必填** | **说明** |
| ------- | ------ | ------ | ------------------------------------------------------ |
| `audio` | `file` | 是 | 单个 `.wav` 文件;建议使用 16 kHz 单声道 PCM `.wav` 扩展名会返回 `422` |
**业务说明**
音频上传至对象存储后执行 ASR 和候选解析。若识别为确认某个候选项则记一条消耗若识别为否认全部候选则不记消耗。ASR/解析可重试失败时队首待确认项**不弹出**,便于客户端重录。
**响应体200**
| **字段** | **类型** | **说明** |
| ------------------ | ---------------- | ------------------------ |
| `surgery_id` | `string` | 手术号 |
| `confirmation_id` | `string` | 待确认 ID |
| `status` | `string` | 成功时为 `accepted` |
| `message` | `string` | 说明 |
| `resolved_label` | `string \| null` | 确认后的耗材名称;否认全部候选时为 `null` |
| `rejected` | `boolean` | 是否否认全部候选,不记消耗时为 `true` |
| `asr_text` | `string \| null` | 语音识别文本 |
| `audio_object_key` | `string \| null` | 对象存储中的原始 WAV 键,便于追溯 |
**状态码**
| **HTTP** | **说明** |
| -------- | ------------------------------------------------------------------------------------- |
| `200` | 已受理并完成解析 |
| `404` | 确认项不存在或手术未活跃;`detail.code` 如 `CONFIRMATION_NOT_FOUND` |
| `409` | 当前确认项已处理过;`detail.code` 如 `CONFIRMATION_ALREADY_RESOLVED` |
| `422` | 空文件、非 `.wav``VOICE_AUDIO_INVALID`、**ASR/解析可重试失败**(如 `VOICE_ASR_FAILED``VOICE_TEXT_EMPTY``VOICE_PARSE_FAILED`)等 |
| `503` | MinIO、百度等依赖不可用例如 `MINIO_NOT_CONFIGURED``MINIO_UPLOAD_FAILED``BAIDU_NOT_CONFIGURED` |
**cURL 示例**
```
curl -sS -X POST \
"http://<主机>:38080/client/surgeries/123456/pending-confirmation/<confirmation_id>/resolve" \
-F "audio=@/path/to/voice.wav;type=audio/wav"
```
### 5.7 上传手术视频(链路 3 · 离线 batch
**基本信息**
| **项目** | **内容** |
| ---------------- | --------------------------------- |
| **方法** | `POST` |
| **路径** | `/internal/demo/offline-batch` |
| **Content-Type** | `multipart/form-data` |
**前置条件**
- 服务端环境变量 **`DEMO_ORCHESTRATOR_ENABLED=true`**。未开启时本路径返回 **`404`**`detail``Demo recording modes disabled (set DEMO_ORCHESTRATOR_ENABLED=true).`
- 可先调用 **`GET /internal/demo/recording-modes-status`** 查看 `demo_recording_modes_enabled` 是否为 `true`(该探测接口始终注册,不依赖上述开关)。
**业务说明**
- 上传**单路完整 MP4**(字段名 `video1`),服务端调用离线算法包(`algorithm_subprocesses/5.15/main.py`)完成整段推理,解析 TSV 后将消耗明细**直接写入数据库**。
- **不**启动 RTSP 实时会话,**不**调用 `start` / `end`**不**触发语音终端或待确认队列。
- 请求在服务端**同步阻塞**直至 TSV 推理结束才返回 `200`;长视频可能耗时较久,客户端应设置足够长的 HTTP 超时。
- 相同视频内容SHA-256+ 相同候选清单会命中结果缓存(跨 `surgery_id` 复用),响应 `message` 中含 `cache=hit``cache=miss`
- `candidate_consumables_json` 的语义与 5.2 节 `candidate_consumables` 一致(名称、产品编码、医院导出对象);缺省 `"[]"` 时展开为 yaml 全部类名。
**请求体multipart**
| **字段名** | **类型** | **必填** | **说明** |
| ------- | ------ | ------ | ------ |
| `surgery_id` | `string` | 是 | 6 位数字 |
| `video1` | `file` | 是 | 单路完整 MP4空文件返回 `422` |
| `candidate_consumables_json` | `string` | 否 | JSON 数组字符串,默认 `"[]"`;格式同 5.2 节候选清单 |
| `include_visualization` | `boolean` | 否 | 是否后台生成标注 MP4默认 `false`。为 `true` 时响应含 `visualization_url`,标注视频异步生成,需轮询 5.8 节 |
**响应体200**
| **字段** | **类型** | **说明** |
| -------- | -------- | -------- |
| `surgery_id` | `string` | 与请求一致 |
| `status` | `string` | 成功时为 `accepted` |
| `message` | `string` | 含 `rows=` 行数、`cache=hit|miss`、医生识别摘要等 |
| `visualization_url` | `string \| null` | `include_visualization=true` 时为相对路径 `/internal/demo/offline-batch/{surgery_id}/visualization`;否则为 `null` |
| `doctor_name` | `string \| null` | 算法输出的医生姓名(若识别到) |
| `doctor_id` | `string \| null` | 医生 ID若识别到 |
| `doctor_display` | `string \| null` | 展示用医生信息 |
| `text_duration_sec` | `number` | TSV / 主流程耗时(秒,保留 3 位小数) |
| `video_duration_sec` | `number \| null` | 标注视频耗时;未请求或仍在后台时为 `null` |
| `total_duration_sec` | `number` | 已完成阶段的合计耗时(秒) |
**状态码**
| **HTTP** | **说明** |
| -------- | -------- |
| `200` | 离线推理已完成,结果已入库(可能为零条明细) |
| `404` | Demo 模式未启用 |
| `422` | `surgery_id` 非 6 位、`candidate_consumables_json` 非法、`video1` 为空等 |
| `500` | 上传落盘失败 |
| `503` | 离线 batch 子进程失败;`detail` 形如 `offline batch failed: ...` |
**后续步骤**
1. 调用 **`GET /client/surgeries/{surgery_id}/result`**5.4 节)查询消耗明细。
2.`include_visualization=true`,轮询 **`GET .../offline-batch/{surgery_id}/timing`**5.8 节)直至 `video_status``ready``failed`,再 **`GET .../visualization`**5.9 节)下载 MP4。
**cURL 示例**
```
curl -sS -X POST "http://<主机>:38080/internal/demo/offline-batch" \
-F "surgery_id=123456" \
-F "video1=@/path/to/surgery.mp4;type=video/mp4" \
-F 'candidate_consumables_json=["14764-2-4","8036-5-22"]' \
-F "include_visualization=true"
```
**响应示例200**
```
{
"surgery_id": "123456",
"status": "accepted",
"message": "非实时精确视频处理完成rows=3 cache=miss医生=张三 (6611);标注视频后台生成中(完成后刷新 visualization URL24 小时内有效)",
"visualization_url": "/internal/demo/offline-batch/123456/visualization",
"doctor_name": "张三",
"doctor_id": "6611",
"doctor_display": "张三 (6611)",
"text_duration_sec": 842.5,
"video_duration_sec": null,
"total_duration_sec": 842.5
}
```
### 5.8 查询离线 batch 各阶段耗时
**基本信息**
| **项目** | **内容** |
| -------- | ------ |
| **方法** | `GET` |
| **路径** | `/internal/demo/offline-batch/{surgery_id}/timing` |
| **路径参数** | `surgery_id`6 位数字) |
| **请求体** | 无 |
**业务说明**
- 在 5.7 节上传完成后可用;用于轮询标注视频是否生成完毕。
- `video_status``skipped`(未请求标注视频)、`pending`(后台生成中)、`ready`(可下载)、`failed`(生成失败)。
**响应体200**
| **字段** | **类型** | **说明** |
| -------- | -------- | -------- |
| `surgery_id` | `string` | 手术号 |
| `text_duration_sec` | `number` | TSV 主流程耗时(秒) |
| `video_duration_sec` | `number \| null` | 标注视频耗时;未完成时为 `null` |
| `total_duration_sec` | `number` | 合计耗时(秒) |
| `video_status` | `string` | `skipped` / `pending` / `ready` / `failed` |
**状态码**
| **HTTP** | **说明** |
| -------- | -------- |
| `200` | 有该 `surgery_id` 的 timing 记录 |
| `404` | Demo 未启用,或尚无 timing 记录 |
| `422` | `surgery_id` 不符合约束 |
### 5.9 获取离线 batch 标注视频
**基本信息**
| **项目** | **内容** |
| -------- | ------ |
| **方法** | `GET` |
| **路径** | `/internal/demo/offline-batch/{surgery_id}/visualization` |
| **路径参数** | `surgery_id`6 位数字) |
| **请求体** | 无 |
**业务说明**
- 仅当 5.7 节 `include_visualization=true` 且后台生成成功时可用。
- 响应为 **`video/mp4`** 文件流,文件名 `{surgery_id}_result_vis.mp4`
- 标注文件默认保留 **24 小时**`VIDEO_BATCH_VIS_TTL_HOURS`),过期后返回 `404`
**状态码**
| **HTTP** | **说明** |
| -------- | -------- |
| `200` | MP4 文件流 |
| `404` | Demo 未启用、尚未生成或已过期 |
| `422` | `surgery_id` 不符合约束 |
### 5.10 语音终端 assignmentHTTP可选
**路径** `GET /client/voice-terminals/{terminal_id}/assignment`
仓库内 **手术室耗材语音确认浏览器客户端**`clients/voice-confirmation/`)仅通过 **§5.11 WebSocket** 接收指派,**不调用**本接口。此处供运维脚本、未实现 WS 的第三方临时拉取 `active_surgery_id`
**响应 200**
| **字段** | **类型** | **说明** |
| -------- | -------- | -------- |
| `voice_terminal_id` | `string` | 与路径一致 |
| `active_surgery_id` | `string \| null` | 当前指派手术 6 位号;无指派时为 `null` |
### 5.11 语音终端 WebSocket
**路径** `GET ws://<主机>:<端口>/client/voice-terminals/ws?terminal_id=<终端ID>`HTTPS 部署时使用 `wss://`
**说明**
- 连接成功后,若服务端已有该终端的 assignment会立即收到一条 **`action":"start"`** 的 JSON与下文推送格式一致
- 术中由服务端在 **`start` / `end` 成功后** 向已连接终端推送 JSON`{"type":"voice_assignment","action":"start"|"end","surgery_id":"123456"}`
- 客户端可发送任意文本作心跳;服务端当前仅依赖 WebSocket 协议级 ping由网关或客户端库实现