Retain live processing timings across UI reruns

This commit is contained in:
2026-09-12 11:55:19 +02:00
parent 500d2c4c67
commit 70cb1e0789
10 changed files with 608 additions and 63 deletions
+88
View File
@@ -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)
+109
View File
@@ -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
+45
View File
@@ -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
+27
View File
@@ -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