feat: add speaker mapping and workflow improvements
This commit is contained in:
@@ -5,6 +5,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mka.application.config import AppSettings
|
||||
from mka.application.meeting_service import (
|
||||
MeetingDetails,
|
||||
@@ -29,6 +31,7 @@ class FakeMeetingLab:
|
||||
self.context_data: dict[str, Any] | None = None
|
||||
self.config_values: dict[str, Any] | None = None
|
||||
self.fail = False
|
||||
self.regeneration: dict[str, Any] | None = None
|
||||
|
||||
def create_context(self, data: dict[str, Any]) -> FakeContext:
|
||||
self.context_data = data
|
||||
@@ -99,6 +102,36 @@ class FakeMeetingLab:
|
||||
protocol.write_text("# Generated protocol\n", encoding="utf-8")
|
||||
return SimpleNamespace(exit_code=0, run_dir=self.run_dir, protocol_path=protocol)
|
||||
|
||||
def regenerate_protocol(
|
||||
self,
|
||||
run_dir: Path,
|
||||
meeting_context: Any,
|
||||
progress_sink: Any,
|
||||
**options: Any,
|
||||
) -> Any:
|
||||
self.regeneration = {
|
||||
"run_dir": run_dir,
|
||||
"meeting_context": meeting_context,
|
||||
"options": options,
|
||||
}
|
||||
progress_sink(
|
||||
SimpleNamespace(
|
||||
stage="protocol_generation",
|
||||
status="started",
|
||||
elapsed_seconds=0.0,
|
||||
progress=None,
|
||||
message=None,
|
||||
)
|
||||
)
|
||||
protocol = Path(run_dir) / "protocol.md"
|
||||
protocol.write_text("# Regenerated protocol\n", encoding="utf-8")
|
||||
protocol_dir = Path(run_dir) / "protocol"
|
||||
protocol_dir.mkdir(exist_ok=True)
|
||||
(protocol_dir / "runtime_metadata.json").write_text(
|
||||
json.dumps({"speaker_attribution_available": True}), encoding="utf-8"
|
||||
)
|
||||
return SimpleNamespace(exit_code=0, run_dir=run_dir, protocol_path=protocol)
|
||||
|
||||
|
||||
def make_service(tmp_path: Path) -> tuple[MeetingProcessingService, FakeMeetingLab]:
|
||||
model = tmp_path / "model.bin"
|
||||
@@ -107,6 +140,7 @@ def make_service(tmp_path: Path) -> tuple[MeetingProcessingService, FakeMeetingL
|
||||
settings = AppSettings(
|
||||
data_root=tmp_path / "meetings",
|
||||
whisper_model=model,
|
||||
glossary_database=tmp_path / "glossary.sqlite3",
|
||||
whisper_executable="/opt/whisper-cli",
|
||||
protocol_model="test:model",
|
||||
diarization_mode="gpu",
|
||||
@@ -139,6 +173,60 @@ def participants() -> list[ParticipantInput]:
|
||||
]
|
||||
|
||||
|
||||
def write_speaker_review_artifacts(run_dir: Path) -> Path:
|
||||
diarization_dir = run_dir / "diarization"
|
||||
context_dir = run_dir / "context"
|
||||
diarization_dir.mkdir(parents=True)
|
||||
context_dir.mkdir()
|
||||
transcript_path = diarization_dir / "transcript_diarized.json"
|
||||
transcript_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"speaker_labels_anonymous": True,
|
||||
"segments": [
|
||||
{
|
||||
"speaker_id": "SPEAKER_01",
|
||||
"text": "I will prepare all raw materials before Wednesday.",
|
||||
},
|
||||
{"speaker_id": "SPEAKER_00", "text": "Yes."},
|
||||
{
|
||||
"speaker_id": "SPEAKER_00",
|
||||
"text": "We will run the production trial on Wednesday.",
|
||||
},
|
||||
{
|
||||
"speaker_id": "SPEAKER_00",
|
||||
"text": "The trial requires the complete production team.",
|
||||
},
|
||||
{"speaker_id": None, "text": "Unassigned text."},
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(context_dir / "meeting_context.yaml").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1",
|
||||
"meeting": {
|
||||
"meeting_id": "speaker-review",
|
||||
"title": "Speaker review",
|
||||
"language": "en",
|
||||
},
|
||||
"participants": [
|
||||
{"participant_id": "martin", "display_name": "Martin"},
|
||||
{"participant_id": "anna", "display_name": "Anna"},
|
||||
],
|
||||
"speaker_mappings": {},
|
||||
"mentioned_people": [],
|
||||
"organization": {"departments": []},
|
||||
"known_entities": {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return transcript_path
|
||||
|
||||
|
||||
def test_build_context_uses_actual_v1_shape(tmp_path: Path) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
|
||||
@@ -171,6 +259,40 @@ def test_build_context_preserves_explicit_speaker_mapping(tmp_path: Path) -> Non
|
||||
assert gateway.context_data["speaker_mappings"] == {"SPEAKER_00": "martin"}
|
||||
|
||||
|
||||
def test_build_context_includes_only_active_authoritative_glossary_terms(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
service.glossary.create("Secugrid HS", "product", aliases=("Sikirgut", "Secugrid H S"))
|
||||
inactive = service.glossary.create("Old Name", "other")
|
||||
service.glossary.set_active(inactive.id, False)
|
||||
|
||||
service.build_context(meeting(), participants())
|
||||
|
||||
assert gateway.context_data is not None
|
||||
assert gateway.context_data["known_entities"] == {
|
||||
"Authoritative terminology": ["Secugrid HS (aliases: Secugrid H S, Sikirgut)"]
|
||||
}
|
||||
rules = gateway.context_data["context_rules"]
|
||||
assert "do not invent matches" in rules["glossary_canonical_spelling"]
|
||||
assert "inside compounds" in rules["glossary_core_terms"]
|
||||
assert "Old Name" not in str(gateway.context_data)
|
||||
|
||||
|
||||
def test_glossary_is_rendered_into_meeting_lab_protocol_context(tmp_path: Path) -> None:
|
||||
meeting_context = pytest.importorskip("src.meeting_lab.models.meeting_context")
|
||||
service, gateway = make_service(tmp_path)
|
||||
service.glossary.create("PBAT", "acronym", aliases=("P B A T",))
|
||||
context = service.build_context(meeting(), participants())
|
||||
|
||||
real_context = meeting_context.create_meeting_context(context.data)
|
||||
prompt_context = meeting_context.render_meeting_context_for_prompt(real_context)
|
||||
|
||||
assert "Authoritative terminology: PBAT (aliases: P B A T)" in prompt_context
|
||||
assert "Use canonical glossary spellings" in prompt_context
|
||||
assert "use canonical core terms inside compounds" in prompt_context
|
||||
|
||||
|
||||
def test_build_context_translates_mentioned_only_person(tmp_path: Path) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
people = participants() + [
|
||||
@@ -331,6 +453,96 @@ def test_failure_reports_stage_and_preserves_run_dir(tmp_path: Path) -> None:
|
||||
assert (gateway.run_dir / "run_metadata.json").is_file()
|
||||
|
||||
|
||||
def test_speaker_review_lists_detected_labels_participants_and_excerpts(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
source = write_speaker_review_artifacts(gateway.run_dir)
|
||||
|
||||
review = service.load_speaker_mapping_review(gateway.run_dir, excerpts_per_speaker=2)
|
||||
|
||||
assert review is not None
|
||||
assert [speaker.speaker_label for speaker in review.speakers] == [
|
||||
"SPEAKER_00",
|
||||
"SPEAKER_01",
|
||||
]
|
||||
assert review.speakers[0].excerpts == (
|
||||
"We will run the production trial on Wednesday.",
|
||||
"The trial requires the complete production team.",
|
||||
)
|
||||
assert review.participants == (("martin", "Martin"), ("anna", "Anna"))
|
||||
assert review.current_mappings == {}
|
||||
assert "SPEAKER_00" in source.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_protocol_only_regeneration_persists_mappings_without_rewriting_transcript(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
service.glossary.create("ENLYZE", "organization", aliases=("Enlyse",))
|
||||
source = write_speaker_review_artifacts(gateway.run_dir)
|
||||
original_source = source.read_bytes()
|
||||
events = []
|
||||
|
||||
outcome = service.regenerate_protocol(
|
||||
gateway.run_dir,
|
||||
{"SPEAKER_00": "martin"},
|
||||
progress_sink=events.append,
|
||||
)
|
||||
|
||||
assert outcome.succeeded
|
||||
assert outcome.original_protocol == "# Regenerated protocol\n"
|
||||
assert outcome.speaker_attribution_available is True
|
||||
assert gateway.context_data is not None
|
||||
assert gateway.context_data["speaker_mappings"] == {"SPEAKER_00": "martin"}
|
||||
assert gateway.context_data["known_entities"] == {
|
||||
"Authoritative terminology": ["ENLYZE (aliases: Enlyse)"]
|
||||
}
|
||||
assert gateway.regeneration is not None
|
||||
assert gateway.regeneration["meeting_context"].data["speaker_mappings"] == {
|
||||
"SPEAKER_00": "martin"
|
||||
}
|
||||
assert gateway.regeneration["options"]["protocol_num_ctx"] == 32_768
|
||||
assert events[0].stage == "protocol_generation"
|
||||
assert source.read_bytes() == original_source
|
||||
|
||||
|
||||
def test_protocol_regeneration_allows_unmapped_and_rejects_duplicate_participant(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, gateway = make_service(tmp_path)
|
||||
write_speaker_review_artifacts(gateway.run_dir)
|
||||
|
||||
outcome = service.regenerate_protocol(gateway.run_dir, {})
|
||||
|
||||
assert outcome.succeeded
|
||||
assert gateway.context_data is not None
|
||||
assert gateway.context_data["speaker_mappings"] == {}
|
||||
|
||||
with pytest.raises(ValueError, match="only one speaker"):
|
||||
service.regenerate_protocol(
|
||||
gateway.run_dir,
|
||||
{"SPEAKER_00": "martin", "SPEAKER_01": "martin"},
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_attribution_loss_is_read_from_runtime_metadata(tmp_path: Path) -> None:
|
||||
run_dir = tmp_path / "run"
|
||||
protocol_dir = run_dir / "protocol"
|
||||
protocol_dir.mkdir(parents=True)
|
||||
(protocol_dir / "runtime_metadata.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"speaker_attribution_available": False,
|
||||
"speaker_attribution_loss_reason": "plain_transcript_fallback",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert MeetingProcessingService._speaker_attribution_available(run_dir) is False
|
||||
|
||||
|
||||
def test_uploaded_source_is_preserved_in_meeting_directory(tmp_path: Path) -> None:
|
||||
service, _ = make_service(tmp_path)
|
||||
source = SimpleNamespace(getbuffer=lambda: b"source audio")
|
||||
|
||||
Reference in New Issue
Block a user