Files
zyt/app/tests/test_live_video_ai.py
T
2026-10-30 13:41:35 +08:00

513 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
from __future__ import annotations
from copy import deepcopy
from threading import Event
import pytest
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiTimeoutError
from doctor_workstation.video.live_ai import LiveAiController
def result(call=71, session="tr-local", **changes):
value = {
"event": "done", "diagnosis_id": 9,
"live_context": {
"call_record_id": call, "transcription_session_id": session,
"revision": "a" * 64, "segment_count": 151,
"through_timestamp_ms": 1793332800000, "transcription_status": "running",
"sources": [{"id": "patient-1", "role": "patient", "timestamp_ms": 1793332800000,
"text": "这是用于软件测试的合成对话。"}],
},
"live_assistance": {
"summary": [{"text": "合成测试要点", "evidence_ids": ["patient-1"]}],
"questions": [], "checks": [], "review": [],
},
}
value.update(changes)
return value
class Repo:
def __init__(self):
self.calls = []
self.cancels = []
self.response = result()
self.started = Event()
self.release = None
self.fail = False
def stream_video_ai(self, diagnosis_id, **kwargs):
self.calls.append((diagnosis_id, kwargs))
self.started.set()
if self.release is not None:
assert self.release.wait(3)
if self.fail:
raise RuntimeError("private connection details must not leak")
yield deepcopy(self.response)
def cancel_diagnosis_ai(self, diagnosis_id, request_id):
self.cancels.append((diagnosis_id, request_id))
return {"accepted": True}
@pytest.fixture
def configured():
repo = Repo()
clock = [100.0]
controller = LiveAiController(repo, diagnosis_id=9, enabled=True, clock=lambda: clock[0])
controller.bind(71, "tr-local")
yield controller, repo, clock
if repo.release is not None:
repo.release.set()
controller.close()
assert controller.wait(3)
def run(controller, clock):
clock[0] += 10
controller.tick()
assert controller.wait(3)
return controller.take_state()
def test_only_persisted_current_call_changes_trigger(configured):
controller, repo, clock = configured
controller.changed("another-call")
run(controller, clock)
assert not repo.calls
controller.changed("tr-local")
state = run(controller, clock)
assert state["summary"][0]["evidence_ids"] == ["patient-1"]
assert state["segmentCount"] == 151
assert "transcript" not in repo.calls[0][1]
assert repo.calls[0][1]["call_record_id"] == 71
run(controller, clock)
assert len(repo.calls) == 1
def test_cloud_retry_clears_failure_without_losing_saved_evidence(configured):
controller, repo, clock = configured
controller.changed("tr-local")
before = run(controller, clock)
controller.transcription_error("云转写中断", status="failed")
controller.transcription_running("stale-session")
assert controller.take_state()["transcriptionStatus"] == "failed"
controller.transcription_running("tr-local")
after = controller.take_state()
assert after["transcriptionStatus"] == "running"
assert after["error"] == ""
assert after["summary"] == before["summary"]
assert after["revision"] == before["revision"]
run(controller, clock)
assert len(repo.calls) == 1
def test_one_inflight_and_many_changes_coalesce(configured):
controller, repo, clock = configured
repo.release = Event()
controller.changed("tr-local")
clock[0] += 2
controller.tick()
assert repo.started.wait(2)
for _ in range(50):
controller.changed("tr-local")
controller.tick()
assert len(repo.calls) == 1
repo.release.set()
assert controller.wait(3)
run(controller, clock)
assert len(repo.calls) == 2
run(controller, clock)
assert len(repo.calls) == 2
def test_pause_stops_auto_but_explicit_refresh_works(configured):
controller, repo, clock = configured
controller.handle("toggle", False)
controller.changed("tr-local")
run(controller, clock)
assert not repo.calls
controller.handle("refresh")
state = run(controller, clock)
assert len(repo.calls) == 1
assert state["enabled"] is False
def test_no_retry_loop_on_failure(configured):
controller, repo, clock = configured
repo.fail = True
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error"
assert "private" not in str(state)
for _ in range(5):
run(controller, clock)
assert len(repo.calls) == 1
repo.fail = False
controller.handle("refresh")
assert run(controller, clock)["phase"] == "ready"
@pytest.mark.parametrize("mutation", [
lambda r: r.update(diagnosis_id=10),
lambda r: r["live_context"].update(call_record_id=72),
lambda r: r["live_context"].update(transcription_session_id="other"),
lambda r: r["live_context"].update(revision="invalid"),
lambda r: r["live_context"].update(segment_count=-1),
lambda r: r["live_context"].update(through_timestamp_ms=float("nan")),
lambda r: r["live_context"]["sources"][0].update(role="guessed"),
lambda r: r["live_context"]["sources"][0].update(timestamp_ms=True),
lambda r: r["live_assistance"]["summary"][0].update(evidence_ids=["made-up"]),
lambda r: r["live_assistance"]["summary"][0].update(evidence_ids=[]),
])
def test_invalid_identity_or_citations_never_published(configured, mutation):
controller, repo, clock = configured
mutation(repo.response)
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error"
assert state["summary"] == []
def test_new_call_discards_late_result(configured):
controller, repo, clock = configured
repo.release = Event()
controller.changed("tr-local")
clock[0] += 2
controller.tick()
assert repo.started.wait(2)
controller.bind(72, "tr-next")
repo.release.set()
assert controller.wait(3)
state = controller.take_state()
assert state["summary"] == []
assert state["revision"] == ""
def test_dialing_clears_previous_call_before_new_transcription_exists(configured):
controller, repo, clock = configured
controller.changed("tr-local")
assert run(controller, clock)["summary"]
controller.reset()
state = controller.take_state()
assert state["summary"] == [] and state["sources"] == []
assert state["transcriptionStatus"] == "idle"
controller.changed("tr-local")
controller.handle("review")
run(controller, clock)
assert len(repo.calls) == 1
def test_complete_long_source_quote_is_not_truncated(configured):
controller, repo, clock = configured
source = "合成测试原文。" * 400
repo.response["live_context"]["sources"][0]["text"] = source
repo.response["live_assistance"]["summary"][0]["text"] = "患者原话:" + source
controller.changed("tr-local")
state = run(controller, clock)
assert state["summary"][0]["text"] == "患者原话:" + source
def test_previous_good_result_retained_during_update_and_failure(configured):
controller, repo, clock = configured
controller.changed("tr-local")
assert run(controller, clock)["summary"]
repo.release = Event()
repo.fail = True
controller.changed("tr-local")
clock[0] += 10
controller.tick()
state = controller.take_state()
assert state["busy"] and state["summary"]
repo.release.set()
assert controller.wait(3)
assert controller.take_state()["summary"]
def test_review_only_after_transcript_finish(configured):
controller, repo, clock = configured
controller.changed("tr-local")
run(controller, clock)
controller.handle("review")
run(controller, clock)
assert len(repo.calls) == 1
controller.finish("tr-local", "partial")
controller.handle("review")
state = run(controller, clock)
assert repo.calls[-1][1]["task"] == "live_review"
assert state["transcriptionStatus"] == "partial"
def test_no_permission_no_work(configured):
_, repo, clock = configured
disabled = LiveAiController(repo, diagnosis_id=9, enabled=False)
disabled.bind(71, "tr-local")
disabled.changed("tr-local")
disabled.handle("refresh")
disabled.tick()
assert not repo.calls
assert disabled.take_state()["available"] is False
disabled.close()
def test_first_and_subsequent_updates_have_short_bounded_coalescing(configured):
controller, repo, clock = configured
controller.changed("tr-local")
clock[0] += 0.79
controller.tick()
assert not repo.calls
# More speech does not indefinitely postpone the first request.
controller.changed("tr-local")
clock[0] += 0.02
controller.tick()
assert controller.wait(3)
assert len(repo.calls) == 1
controller.changed("tr-local")
clock[0] += 1.99
controller.tick()
assert len(repo.calls) == 1
clock[0] += 0.02
controller.tick()
assert controller.wait(3)
assert len(repo.calls) == 2
def test_validated_suggestions_are_visible_before_stream_finishes(configured):
controller, repo, clock = configured
delivered = Event()
repo.release = Event()
def stream(*args, **kwargs):
yield result(event="live_update")
delivered.set()
assert repo.release.wait(3)
yield result()
repo.stream_video_ai = stream
controller.changed("tr-local")
clock[0] += 1
controller.tick()
assert delivered.wait(2)
state = controller.take_state()
assert state["busy"] is True and state["phase"] == "analyzing"
assert state["firstResultMs"] == 0
assert state["summary"] and state["revision"] == "a" * 64
assert state["pendingSegments"] == 0
controller.changed("tr-local")
clock[0] += 3
controller.tick()
state = controller.take_state()
assert state["elapsedMs"] == 3000 and state["pendingSegments"] == 1
repo.release.set()
assert controller.wait(3)
state = controller.take_state()
assert state["busy"] is False and state["lastDurationMs"] == 3000
assert state["firstResultMs"] == 0
assert state["pendingSegments"] == 1
def test_slow_request_does_not_add_another_wait_for_queued_speech(configured):
controller, repo, clock = configured
repo.release = Event()
controller.changed("tr-local")
clock[0] += 1
controller.tick()
assert repo.started.wait(2)
controller.changed("tr-local")
clock[0] += 20
controller.tick()
assert len(repo.calls) == 1
repo.release.set()
assert controller.wait(3)
# The queued snapshot is already due; no new sleep after the slow request ends.
controller.tick()
assert controller.wait(3)
assert len(repo.calls) == 2
assert controller.take_state()["pendingSegments"] == 0
def test_failed_stream_keeps_only_validated_progress(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
yield result(event="live_update")
raise ApiTimeoutError("secret raw upstream content")
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and state["summary"]
assert state["errorCode"] == "UPSTREAM_TIMEOUT"
assert "尚未全部完成" in state["message"]
assert "secret" not in str(state)
def test_progress_cannot_change_snapshot_mid_request(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
yield result(event="live_update")
different = result()
different["live_context"]["revision"] = "b" * 64
yield different
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error"
assert state["revision"] == "a" * 64
@pytest.mark.parametrize("scope", ["current_call_only", "authorized_case"])
def test_displayed_scope_comes_from_validated_server_result(configured, scope):
controller, repo, clock = configured
repo.response["analysis_scope"] = scope
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == scope
controller.bind(72, "new-session")
assert controller.take_state()["analysisScope"] == ""
def test_legacy_server_does_not_claim_dialogue_only(configured):
controller, repo, clock = configured
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == ""
@pytest.mark.parametrize("scope", [None, [], "invented"])
def test_unrecognized_result_scope_is_rejected(configured, scope):
controller, repo, clock = configured
repo.response["analysis_scope"] = scope
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and not state["summary"]
assert state["analysisScope"] == ""
def test_scope_cannot_change_within_one_stream(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
yield result(event="live_update", analysis_scope="current_call_only")
yield result(analysis_scope="authorized_case")
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and state["summary"]
assert state["analysisScope"] == "current_call_only"
def test_scope_of_previous_results_survives_review_start_or_failure(configured):
controller, repo, clock = configured
repo.response["analysis_scope"] = "current_call_only"
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == "current_call_only"
controller.finish("tr-local", "completed")
observed = []
def stream(*args, **kwargs):
assert kwargs["task"] == "live_review"
yield {"event": "start", "analysis_scope": "authorized_case"}
observed.append(controller.take_state())
raise ApiTimeoutError("synthetic timeout")
repo.stream_video_ai = stream
controller.handle("review")
state = run(controller, clock)
assert observed[0]["busy"] and observed[0]["analysisScope"] == "current_call_only"
assert state["phase"] == "error" and state["analysisScope"] == "current_call_only"
def test_successful_review_and_refresh_replace_displayed_scope(configured):
controller, repo, clock = configured
repo.response["analysis_scope"] = "current_call_only"
controller.changed("tr-local")
assert run(controller, clock)["analysisScope"] == "current_call_only"
controller.finish("tr-local", "completed")
repo.response["analysis_scope"] = "authorized_case"
controller.handle("review")
assert run(controller, clock)["analysisScope"] == "authorized_case"
assert repo.calls[-1][1]["task"] == "live_review"
repo.response["analysis_scope"] = "current_call_only"
controller.handle("refresh")
assert run(controller, clock)["analysisScope"] == "current_call_only"
assert repo.calls[-1][1]["task"] == "live_consultation"
def test_items_in_the_same_group_are_published_cumulatively(configured):
controller, repo, clock = configured
observed = []
def stream(*args, **kwargs):
response = result(event="live_update")
for index in range(3):
response["live_assistance"]["questions"].append({
"text": f"合成测试追问 {index + 1}?", "evidence_ids": ["patient-1"],
})
yield deepcopy(response)
observed.append(controller.take_state())
yield {**response, "event": "done"}
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert [len(item["questions"]) for item in observed] == [1, 2, 3]
assert all(item["busy"] and item["revision"] == "a" * 64 for item in observed)
assert len(state["questions"]) == 3 and not state["busy"]
def test_invalid_progress_never_publishes_or_accepts_a_later_done(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
bad = result(event="live_update")
bad["live_assistance"]["summary"][0]["evidence_ids"] = ["invented"]
yield bad
yield result()
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["phase"] == "error" and not state["summary"]
@pytest.mark.parametrize("error, expected", [
(ApiTimeoutError("private payload"), "UPSTREAM_TIMEOUT"),
(ApiHttpError("private payload", status_code=403), "PERMISSION_DENIED"),
(ApiHttpError("private payload", status_code=404), "SERVICE_UPDATE_REQUIRED"),
(ApiBusinessError("private payload", data={"error_code": "LIVE_RESULT_INVALID"}), "LIVE_RESULT_INVALID"),
(ApiBusinessError("private payload", data={"error_code": "UPSTREAM_TIMEOUT"}), "UPSTREAM_TIMEOUT"),
(ApiBusinessError("private payload", data={"error_code": {"secret": "payload"}}), "UPSTREAM_UNAVAILABLE"),
])
def test_failure_categories_do_not_leak_payloads(configured, error, expected, caplog):
controller, repo, clock = configured
def stream(*args, **kwargs):
raise error
yield
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert state["errorCode"] == expected
assert "private payload" not in str(state) + caplog.text
assert expected in caplog.text
def test_validated_error_reason_distinguishes_citations_from_format(configured):
controller, repo, clock = configured
def stream(*args, **kwargs):
raise ApiBusinessError("private source text", data={
"error_code": "LIVE_RESULT_INVALID", "live_validation_reason": "CITATION_INVALID",
})
yield
repo.stream_video_ai = stream
controller.changed("tr-local")
state = run(controller, clock)
assert "引用无法核对" in state["error"]
assert "private" not in str(state)