feat: 手术视频消耗、待确认与持久化改造
- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测 Made-with: Cursor
This commit is contained in:
@@ -100,7 +100,7 @@ open http://localhost:38081/
|
||||
- §4.1 `POST /client/surgeries/start` — 含 `surgery_id` 校验、`camera_ids` 多值输入、`candidate_consumables` 标签编辑器(初始值从 `/labels.json` 载入,可增删)
|
||||
- §4.2 `POST /client/surgeries/end`
|
||||
- §4.3 `GET /client/surgeries/{id}/result` — 以表格渲染 `details` 与 `summary`
|
||||
- §4.4 `GET /client/surgeries/{id}/pending-confirmation` — 支持手动拉取与 2s 自动轮询
|
||||
- §4.4 `GET /client/surgeries/{id}/pending-confirmation` — 支持手动拉取与 **10s** 自动轮询(请求串行排队,避免与 §4.5 上传后紧接拉取竞态)
|
||||
- §4.5 `POST .../resolve` — 本地麦克风录音 → 16 kHz 单声道 WAV → `multipart/form-data` 上传
|
||||
- **调试:无摄像头** — 两路视频选择与 `camera_id`;一键联调见上文;手跑假流见 `fake_rtsp_from_file.py` 与本文「调试:无真实摄像头」
|
||||
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
</label>
|
||||
<span id="voice-status" class="small muted"></span>
|
||||
</div>
|
||||
<p id="voice-pipeline-hint" class="small muted" style="margin:6px 0 0">默认策略:<strong>Top1 置信度 < 0.9</strong> 且达语音下沿时多会<strong>入队待确认</strong>;≥ <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code>(默认 0.9)且标签在 <code>candidate_consumables</code> 内则<strong>直接记 vision</strong>,拉取待确认为 404。可在环境变量中调整 <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code>。确认时在「语音确认(录音)」上传 WAV 即可。</p>
|
||||
<p id="voice-pipeline-hint" class="small muted" style="margin:6px 0 0">默认策略:<strong>Top1 置信度 < 0.9</strong> 且达语音下沿时多会<strong>入队待确认</strong>;≥ <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code>(默认 0.9)且标签在 <code>candidate_consumables</code> 内则<strong>直接记 vision</strong>,拉取待确认为 404。可在环境变量中调整 <code>VIDEO_AUTO_CONFIRM_CONFIDENCE</code>。<strong>§4.1 开录返回 200 后本页会自动排入一次 §4.4 拉取</strong>;§4.5 上传成功后也会串行拉取下一条,多条待确认按服务端 FIFO 逐条处理。若在轮询 GET 尚未返回时已提交 §4.5,本页会丢弃过期响应并自动再拉一次,避免旧 <code>confirmation_id</code> 盖住新队首。</p>
|
||||
<div id="pending-render" class="pending-box" hidden></div>
|
||||
</section>
|
||||
|
||||
@@ -641,16 +641,24 @@
|
||||
const detail = (body && (body.detail !== undefined)) ? body.detail : body;
|
||||
const errText = (typeof detail === "object" && detail !== null) ? JSON.stringify(detail, null, 2) : String(detail || body || "错误");
|
||||
alert("一键开录失败 HTTP " + res.status + "\n\n" + errText);
|
||||
return;
|
||||
}
|
||||
// 开录成功后立即排入 §4.4;并使此前进行中的 pending GET 失效(避免旧 id 覆盖)
|
||||
_pendingSyncSeq++;
|
||||
fetchPendingOnce();
|
||||
return;
|
||||
}
|
||||
const camera_ids = $("camera-ids").value.split(",").map(s => s.trim()).filter(Boolean);
|
||||
if (camera_ids.length === 0) { alert("camera_ids 至少要 1 个"); return; }
|
||||
await apiJson("POST", "/client/surgeries/start", {
|
||||
const { res } = await apiJson("POST", "/client/surgeries/start", {
|
||||
surgery_id: sid,
|
||||
camera_ids,
|
||||
candidate_consumables: [...tags],
|
||||
});
|
||||
if (res.ok) {
|
||||
_pendingSyncSeq++;
|
||||
fetchPendingOnce();
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
@@ -710,11 +718,23 @@
|
||||
|
||||
// ============================================================
|
||||
// §4.4 pending-confirmation(响应内带 Base64 MP3)+ 可选自动播报
|
||||
// §4.5 依赖本节的 confirmation_id;避免并发拉取竞态与「已无待确认仍保留旧 id」
|
||||
// ============================================================
|
||||
let pollTimer = null;
|
||||
/** 仅在一次成功播出音频/TTS 后更新,避免未播成功却跳过 */
|
||||
/**
|
||||
* 自动播报去重:在「开始排程」时同步写入当前 confirmation_id,避免串行 GET 在首段尚未播完时
|
||||
* 再次命中 !== 判断而启动第二遍播报(_pendingSyncSeq 重拉、开录后立即拉取等会紧挨着第二次 fetch)。
|
||||
* 仅当播放失败时在 catch 中清除,便于轮询/手动重试。
|
||||
*/
|
||||
let lastSpokenConfirmationId = null;
|
||||
let lastPendingPayload = null;
|
||||
/**
|
||||
* 与「进行中的 GET pending」比对:§4.5 resolve 成功、手术 id 变更、重新开录时递增。
|
||||
* 解决竞态:GET 已发出后用户才提交 resolve,晚到的响应会带着旧 confirmation_id,不得写回 UI。
|
||||
*/
|
||||
let _pendingSyncSeq = 0;
|
||||
/** 串行化 GET pending:链式 Promise,支持 await fetchPendingOnce()(§4.5 成功后拉取下一条) */
|
||||
let _pendingFetchChain = Promise.resolve();
|
||||
|
||||
/** 方案1:首次用户手势内播放极短静音,解锁自动播放;之后待确认 MP3 复用同一 Audio */
|
||||
const SILENT_UNLOCK_DATA_URL =
|
||||
@@ -852,23 +872,27 @@
|
||||
$("surgery-id").addEventListener("input", () => {
|
||||
lastSpokenConfirmationId = null;
|
||||
lastPendingPayload = null;
|
||||
_pendingSyncSeq++;
|
||||
});
|
||||
|
||||
async function playLastPendingManually() {
|
||||
const p = lastPendingPayload;
|
||||
if (!p || !p.confirmation_id) return;
|
||||
const pt = (p.prompt_text || "").trim();
|
||||
const cid = p.confirmation_id;
|
||||
try {
|
||||
lastSpokenConfirmationId = cid;
|
||||
await playPromptAudioBase64(p.prompt_audio_mp3_base64, pt);
|
||||
lastSpokenConfirmationId = p.confirmation_id;
|
||||
} catch (e) {
|
||||
console.warn("[demo-client] 手动播放失败", e);
|
||||
if (lastSpokenConfirmationId === cid) lastSpokenConfirmationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPendingOnce() {
|
||||
async function runFetchPendingOnce() {
|
||||
const sid = surgeryId();
|
||||
if (!/^\d{6}$/.test(sid)) return;
|
||||
const startSeq = _pendingSyncSeq;
|
||||
const path = `/client/surgeries/${sid}/pending-confirmation`;
|
||||
const url = baseUrl() + path;
|
||||
let res;
|
||||
@@ -878,6 +902,10 @@
|
||||
addLog("GET", url, "NETWORK", String(e), { error: true });
|
||||
return;
|
||||
}
|
||||
if (startSeq !== _pendingSyncSeq) {
|
||||
fetchPendingOnce();
|
||||
return;
|
||||
}
|
||||
const raw = await res.text();
|
||||
let body;
|
||||
try {
|
||||
@@ -885,6 +913,10 @@
|
||||
} catch {
|
||||
body = raw;
|
||||
}
|
||||
if (startSeq !== _pendingSyncSeq) {
|
||||
fetchPendingOnce();
|
||||
return;
|
||||
}
|
||||
if (res.status === 404) {
|
||||
// 无待确认为常态,不写入右侧「响应日志」,减少刷屏
|
||||
} else {
|
||||
@@ -892,6 +924,15 @@
|
||||
}
|
||||
const box = $("pending-render");
|
||||
if (res.status === 200 && body && body.confirmation_id) {
|
||||
const prevId = lastPendingPayload && lastPendingPayload.confirmation_id;
|
||||
if (prevId && prevId !== body.confirmation_id) {
|
||||
recordingWav = null;
|
||||
$("btn-resolve").disabled = true;
|
||||
$("audio-preview").hidden = true;
|
||||
$("btn-download").style.display = "none";
|
||||
$("rec-info").textContent = "新待确认已入队,请重新录音后上传";
|
||||
$("rec-info").className = "warn small";
|
||||
}
|
||||
box.hidden = false;
|
||||
lastPendingPayload = body;
|
||||
$("confirmation-id").value = body.confirmation_id;
|
||||
@@ -911,26 +952,48 @@
|
||||
if (btnPlay) btnPlay.onclick = () => void playLastPendingManually();
|
||||
const pt = (body.prompt_text || "").trim();
|
||||
const ttsOn = $("tts-pending") && $("tts-pending").checked;
|
||||
if (ttsOn && pt && body.confirmation_id !== lastSpokenConfirmationId) {
|
||||
const cidForTts = body.confirmation_id;
|
||||
if (ttsOn && pt && cidForTts !== lastSpokenConfirmationId) {
|
||||
lastSpokenConfirmationId = cidForTts;
|
||||
void (async () => {
|
||||
try {
|
||||
await playPromptAudioBase64(body.prompt_audio_mp3_base64, pt);
|
||||
lastSpokenConfirmationId = body.confirmation_id;
|
||||
} catch (e) {
|
||||
console.warn("[demo-client] 自动播报未完成(可点「播放话术」)", e);
|
||||
if (lastSpokenConfirmationId === cidForTts) lastSpokenConfirmationId = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
} else if (res.status === 404) {
|
||||
lastPendingPayload = null;
|
||||
lastSpokenConfirmationId = null;
|
||||
box.hidden = false;
|
||||
box.innerHTML = '<span class="muted">暂无待确认项。</span>';
|
||||
box.innerHTML = '<span class="muted">暂无待确认项。请先 §4.4 拉取到待确认后再 §4.5 录音上传。</span>';
|
||||
$("confirmation-id").value = "";
|
||||
$("btn-resolve").disabled = true;
|
||||
recordingWav = null;
|
||||
$("audio-preview").hidden = true;
|
||||
$("btn-download").style.display = "none";
|
||||
$("rec-info").textContent = "无待确认:无需录音;有新任务时会自动填入 confirmation_id";
|
||||
$("rec-info").className = "muted small";
|
||||
} else {
|
||||
box.hidden = false;
|
||||
box.innerHTML = `<span class="err">HTTP ${res.status}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 对外入口:重叠调用自动排队;返回的 Promise 在整段链完成后 settle(便于 §4.5 await) */
|
||||
function fetchPendingOnce() {
|
||||
const run = _pendingFetchChain.then(
|
||||
() => runFetchPendingOnce(),
|
||||
() => runFetchPendingOnce(),
|
||||
);
|
||||
_pendingFetchChain = run.catch((e) => {
|
||||
console.warn("[demo-client] pending fetch", e);
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
$("btn-pending").onclick = fetchPendingOnce;
|
||||
function applyAutoPoll() {
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
@@ -1110,6 +1173,7 @@
|
||||
$("audio-preview").hidden = true;
|
||||
$("btn-download").style.display = "none";
|
||||
lastSpokenConfirmationId = null;
|
||||
_pendingSyncSeq++;
|
||||
$("rec-info").textContent = "已提交,正在拉取下一条待确认…";
|
||||
$("rec-info").className = "ok small";
|
||||
await fetchPendingOnce();
|
||||
|
||||
Reference in New Issue
Block a user