513 lines
18 KiB
Python
513 lines
18 KiB
Python
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)
|