Retain live processing timings across UI reruns
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"""Exercise the real executor and Streamlit reruns without model calls."""
|
||||
|
||||
from pathlib import Path
|
||||
from threading import current_thread
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import streamlit
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from mka.application.meeting_service import AppProgressEvent, SpeakerMappingReview, SpeakerReview
|
||||
from mka.application.progress_timing import ProcessingTimer
|
||||
from mka.ui import streamlit_app as ui
|
||||
|
||||
|
||||
class GuardedStreamlit:
|
||||
"""Fail if a worker touches the UI session proxy, including callback reads."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name == "session_state" and current_thread().name.startswith("ThreadPoolExecutor"):
|
||||
raise AssertionError("Worker accessed Streamlit session state")
|
||||
return getattr(streamlit, name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fails", [False, True])
|
||||
@pytest.mark.parametrize("mapped", [False, True])
|
||||
def test_regeneration_captures_ui_values_before_real_worker_and_retains_timing(
|
||||
monkeypatch, fails, mapped
|
||||
):
|
||||
outcome = SimpleNamespace(
|
||||
succeeded=True,
|
||||
run_dir=Path("/tmp/alpha-mapping-test"),
|
||||
speaker_attribution_available=True,
|
||||
original_protocol="Existing protocol",
|
||||
)
|
||||
review = SpeakerMappingReview((SpeakerReview("SPEAKER_00", ()),), (("a", "A"),), {})
|
||||
calls = []
|
||||
|
||||
class Service:
|
||||
def __init__(self, *args):
|
||||
self.timer = ProcessingTimer()
|
||||
|
||||
def load_speaker_mapping_review(self, run_dir):
|
||||
return review
|
||||
|
||||
def regenerate_protocol(self, run_dir, selections, *, progress_sink, performance_profile):
|
||||
assert current_thread().name.startswith("ThreadPoolExecutor")
|
||||
calls.append((run_dir, selections, performance_profile))
|
||||
self.timer.start_run()
|
||||
self.timer.start_stage("protocol_generation")
|
||||
progress_sink(AppProgressEvent("protocol_generation", "started", 0.0))
|
||||
if fails:
|
||||
raise RuntimeError("model unavailable")
|
||||
self.timer.finish_stage("protocol_generation")
|
||||
self.timer.finish_run()
|
||||
progress_sink(AppProgressEvent("protocol_generation", "completed", 0.0))
|
||||
return outcome
|
||||
|
||||
monkeypatch.setattr(ui, "st", GuardedStreamlit())
|
||||
monkeypatch.setattr(ui, "MeetingProcessingService", Service)
|
||||
monkeypatch.setattr(ui.AppSettings, "from_environment", lambda: None)
|
||||
monkeypatch.setattr(ui, "MeetingLabGateway", lambda: None)
|
||||
|
||||
def result_app(outcome):
|
||||
import streamlit as st
|
||||
|
||||
from mka.ui.streamlit_app import _render_result
|
||||
|
||||
st.session_state.outcome = outcome
|
||||
st.session_state.performance_profile = "fast"
|
||||
_render_result()
|
||||
|
||||
app = AppTest.from_function(result_app, args=(outcome,)).run()
|
||||
if mapped:
|
||||
app.selectbox[0].select("a").run()
|
||||
button = next(button for button in app.button if "Regenerate" in button.label)
|
||||
assert not button.disabled
|
||||
button.click().run()
|
||||
assert not app.exception
|
||||
assert calls == [(outcome.run_dir, {"SPEAKER_00": "a"} if mapped else {}, "fast")]
|
||||
states, message, timing = app.session_state["processing_progress"]
|
||||
assert states["protocol_generation"] == ("failed" if fails else "completed")
|
||||
assert not timing.running
|
||||
assert timing.total_duration >= timing.stage_durations["protocol_generation"] >= 0
|
||||
app.run()
|
||||
assert not app.exception
|
||||
assert app.session_state["processing_progress"][2] == timing
|
||||
assert any("total runtime" in item.value for item in app.info)
|
||||
@@ -14,6 +14,7 @@ from mka.application.meeting_service import (
|
||||
ParticipantInput,
|
||||
ProcessingOptions,
|
||||
)
|
||||
from mka.application.progress_timing import ProcessingTimer
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -623,3 +624,111 @@ def test_process_passes_only_active_explicit_glossary_aliases(tmp_path: Path) ->
|
||||
|
||||
assert gateway.config_values is not None
|
||||
assert gateway.config_values["glossary_aliases"] == {"Lumini": "Luminy"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fails", [False, True])
|
||||
def test_process_persists_completed_and_failed_timings(tmp_path: Path, fails: bool) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
gateway.fail = fails
|
||||
values = iter([0.0, 1.0, 9.0, 10.0, 20.0])
|
||||
service.timer = ProcessingTimer(lambda: next(values))
|
||||
audio = tmp_path / "meeting.wav"
|
||||
audio.write_bytes(b"audio")
|
||||
|
||||
outcome = service.process(audio, meeting(), participants(), ProcessingOptions())
|
||||
|
||||
metadata = json.loads((gateway.run_dir / "run_metadata.json").read_text(encoding="utf-8"))
|
||||
assert metadata["timing"] == {
|
||||
"stages_seconds": {"preparing": 8.0, "transcription": 10.0},
|
||||
"total_seconds": 20.0,
|
||||
}
|
||||
assert outcome.succeeded is not fails
|
||||
if fails:
|
||||
assert metadata["failure"]["type"] == "TranscriptionError"
|
||||
|
||||
|
||||
def test_regeneration_starts_fresh_timer_and_retains_final_duration(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
write_speaker_review_artifacts(gateway.run_dir)
|
||||
clock = SimpleNamespace(value=10.0)
|
||||
service.timer = ProcessingTimer(lambda: clock.value)
|
||||
audio = tmp_path / "meeting.wav"
|
||||
audio.write_bytes(b"audio")
|
||||
service.process(audio, meeting(), participants(), ProcessingOptions())
|
||||
|
||||
clock.value = 100.0
|
||||
observed = []
|
||||
|
||||
def regenerate(run_dir: Path, context: Any, progress_sink: Any, **options: Any) -> Any:
|
||||
progress_sink(
|
||||
SimpleNamespace(
|
||||
stage="protocol_generation",
|
||||
status="started",
|
||||
elapsed_seconds=0.0,
|
||||
progress=None,
|
||||
message=None,
|
||||
)
|
||||
)
|
||||
clock.value = 105.0
|
||||
observed.append(service.timer.snapshot())
|
||||
progress_sink(
|
||||
SimpleNamespace(
|
||||
stage="protocol_generation",
|
||||
status="completed",
|
||||
elapsed_seconds=5.0,
|
||||
progress=None,
|
||||
message=None,
|
||||
)
|
||||
)
|
||||
clock.value = 109.0
|
||||
protocol = run_dir / "protocol.md"
|
||||
protocol.write_text("# Regenerated protocol\n", encoding="utf-8")
|
||||
return SimpleNamespace(run_dir=run_dir, protocol_path=protocol)
|
||||
|
||||
monkeypatch.setattr(gateway, "regenerate_protocol", regenerate)
|
||||
|
||||
service.regenerate_protocol(gateway.run_dir, {"SPEAKER_00": "martin"})
|
||||
final = service.timer.snapshot()
|
||||
|
||||
assert observed[0].active_stage == "protocol_generation"
|
||||
assert observed[0].stage_durations == {"protocol_generation": 5.0}
|
||||
assert observed[0].total_duration == 5.0
|
||||
assert observed[0].running is True
|
||||
assert final.stage_durations == {"protocol_generation": 5.0}
|
||||
assert final.total_duration == 9.0
|
||||
assert final.running is False
|
||||
|
||||
|
||||
def test_failed_regeneration_freezes_active_and_total_durations(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
write_speaker_review_artifacts(gateway.run_dir)
|
||||
clock = SimpleNamespace(value=20.0)
|
||||
service.timer = ProcessingTimer(lambda: clock.value)
|
||||
|
||||
def fail_regeneration(run_dir: Path, context: Any, progress_sink: Any, **options: Any) -> Any:
|
||||
progress_sink(
|
||||
SimpleNamespace(
|
||||
stage="protocol_generation",
|
||||
status="started",
|
||||
elapsed_seconds=0.0,
|
||||
progress=None,
|
||||
message=None,
|
||||
)
|
||||
)
|
||||
clock.value = 27.0
|
||||
raise RuntimeError("generation failed")
|
||||
|
||||
monkeypatch.setattr(gateway, "regenerate_protocol", fail_regeneration)
|
||||
|
||||
with pytest.raises(RuntimeError, match="generation failed"):
|
||||
service.regenerate_protocol(gateway.run_dir, {})
|
||||
clock.value = 99.0
|
||||
final = service.timer.snapshot()
|
||||
|
||||
assert final.stage_durations == {"protocol_generation": 7.0}
|
||||
assert final.total_duration == 7.0
|
||||
assert final.running is False
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from mka.application.progress_timing import ProcessingTimer
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self, value: float = 0.0) -> None:
|
||||
self.value = value
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.value
|
||||
|
||||
|
||||
def test_completed_duration_is_retained_while_active_stage_increases() -> None:
|
||||
clock = FakeClock(10.0)
|
||||
timer = ProcessingTimer(clock)
|
||||
timer.start_run()
|
||||
timer.start_stage("preparing")
|
||||
clock.value = 18.0
|
||||
timer.finish_stage("preparing")
|
||||
timer.start_stage("transcription")
|
||||
|
||||
clock.value = 20.0
|
||||
first = timer.snapshot()
|
||||
clock.value = 25.5
|
||||
second = timer.snapshot()
|
||||
|
||||
assert first.stage_durations == {"preparing": 8.0, "transcription": 2.0}
|
||||
assert second.stage_durations == {"preparing": 8.0, "transcription": 7.5}
|
||||
assert second.total_duration == 15.5
|
||||
|
||||
|
||||
def test_failed_active_stage_and_total_duration_are_frozen() -> None:
|
||||
clock = FakeClock(2.0)
|
||||
timer = ProcessingTimer(clock)
|
||||
timer.start_run()
|
||||
clock.value = 5.0
|
||||
timer.start_stage("transcription")
|
||||
clock.value = 14.0
|
||||
timer.finish_run()
|
||||
clock.value = 99.0
|
||||
|
||||
snapshot = timer.snapshot()
|
||||
|
||||
assert snapshot.stage_durations["transcription"] == 9.0
|
||||
assert snapshot.total_duration == 12.0
|
||||
assert snapshot.running is False
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import date
|
||||
|
||||
from mka.application.meeting_service import ParticipantInput
|
||||
from mka.application.progress_timing import TimingSnapshot
|
||||
from mka.application.run_inputs import RunInputState
|
||||
from mka.ui import streamlit_app
|
||||
|
||||
@@ -66,3 +67,29 @@ def test_imported_inputs_are_applied_via_pending_state_before_widgets(
|
||||
assert "source_media_1" not in state
|
||||
assert "pending_run_inputs" not in state
|
||||
assert state["source_media_0"] is existing_upload
|
||||
|
||||
|
||||
def test_duration_formatting_covers_seconds_minutes_and_hours() -> None:
|
||||
assert streamlit_app._format_duration(8.9) == "00:08"
|
||||
assert streamlit_app._format_duration(7 * 60 + 18) == "07:18"
|
||||
assert streamlit_app._format_duration(3600 + 3 * 60 + 42) == "1:03:42"
|
||||
|
||||
|
||||
def test_completed_regeneration_timing_survives_frontend_rerun(monkeypatch) -> None:
|
||||
state = {}
|
||||
monkeypatch.setattr(streamlit_app.st, "session_state", state)
|
||||
timing = TimingSnapshot(
|
||||
stage_durations={"protocol_generation": 4.5},
|
||||
active_stage=None,
|
||||
total_duration=4.5,
|
||||
running=False,
|
||||
)
|
||||
states = {stage: "skipped" for stage in streamlit_app.STAGES}
|
||||
states["protocol_generation"] = "completed"
|
||||
|
||||
streamlit_app._remember_regeneration_timing(states, "Protocol regenerated", timing)
|
||||
|
||||
saved_states, saved_message, saved_timing = state["regeneration_progress"]
|
||||
assert saved_states["protocol_generation"] == "completed"
|
||||
assert saved_message == "Protocol regenerated"
|
||||
assert saved_timing == timing
|
||||
|
||||
Reference in New Issue
Block a user