From 6fc07690d9e2fcfc926dbd33b0e1770c9cf81c13 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 11 Sep 2026 10:25:45 +0200 Subject: [PATCH] Make protocol language follow meeting language --- PROJECT_KNOWLEDGE.md | 13 ++ README.md | 7 + src/meeting_lab/models/meeting_context.py | 4 +- .../protocol/direct_protocol_prompt.py | 8 +- .../protocol/generate_direct_protocol.py | 12 +- tests/test_meeting_language.py | 164 ++++++++++++++++++ 6 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 tests/test_meeting_language.py diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index 203ee20..d809f8f 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -1,5 +1,18 @@ # Project Knowledge +## Meeting language in direct protocols + +Direct protocol prompts derive their explicit output language from persisted +Meeting Context `meeting.language`, including regeneration and diarized fallback. +Missing context/language explicitly defaults to `de`; omitted language is accepted +without modifying context data. Protocol runtime `output_language` is derived +provenance, not a separate setting. No transcript/context translation is performed. + +Prompt evolution (2026-09-11): replaced unconditional German output with the +meeting-language instruction in the shared prompt builder. Names remain verbatim. +Validation uses mocked generation for German/English, plain/diarized inputs, +regeneration and legacy contexts; no live model or extraction gold run is involved. + This is a compact operational summary of the current Meeting Lab state. ## Objective diff --git a/README.md b/README.md index 7b5327c..ffe12bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Meeting Lab +The direct protocol uses saved Meeting Context `meeting.language`: `de` requests +German output and `en` requests English output, for plain and diarized transcripts +and protocol-only regeneration. Meeting Assistant supplies the same selection to +Whisper. Missing context/language retains German output for older runs. Effective +output language is recorded as `output_language` in protocol runtime metadata. +Transcript text, authored context, names and speaker mappings are not translated. + Experimentierumgebung zur Entwicklung eines lokalen Diskussionsanalyzers für Meetingtranskripte. ## Ziel diff --git a/src/meeting_lab/models/meeting_context.py b/src/meeting_lab/models/meeting_context.py index a33b474..9684539 100644 --- a/src/meeting_lab/models/meeting_context.py +++ b/src/meeting_lab/models/meeting_context.py @@ -108,7 +108,9 @@ def validate_meeting_context(data: dict[str, Any]) -> None: meeting = _require_mapping(data, "meeting") _require_non_empty_string(meeting, "meeting.meeting_id") _require_non_empty_string(meeting, "meeting.title") - _require_non_empty_string(meeting, "meeting.language") + # Legacy contexts may omit language; protocol generation defaults to German. + if "language" in meeting: + _require_non_empty_string(meeting, "meeting.language") organization = _optional_mapping(data.get("organization"), "organization") departments = _optional_list(organization.get("departments"), "organization.departments") diff --git a/src/meeting_lab/protocol/direct_protocol_prompt.py b/src/meeting_lab/protocol/direct_protocol_prompt.py index b3061dc..8097b08 100644 --- a/src/meeting_lab/protocol/direct_protocol_prompt.py +++ b/src/meeting_lab/protocol/direct_protocol_prompt.py @@ -3,13 +3,13 @@ from __future__ import annotations -DIRECT_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und dem Meeting-Kontext ein vollständiges, strukturiertes und professionelles internes Besprechungsprotokoll in deutscher Sprache. +DIRECT_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und dem Meeting-Kontext ein vollständiges, strukturiertes und professionelles internes Besprechungsprotokoll. Das Protokoll muss themenorientiert sein, nicht chronologisch und nicht nach technischen Kategorien gegliedert. Beginne mit # Meeting Protocol. Verwende für jedes kohärente Thema eine Überschrift ## und darunter eine strukturierte Synthese der Diskussion. Bewahre relevante Diskussionsverläufe, unterschiedliche Positionen, offene Punkte und Entscheidungsgrundlagen. Dokumentiere die wesentlichen Inhalte nachvollziehbar und fasse Themenblöcke so zusammen, dass auch Personen, die nicht am Meeting teilgenommen haben, den Kontext und die Entwicklung der Diskussion verstehen können. Nenne Entscheidungen oder abgestimmte Positionen nur, wenn sie tatsächlich belegt sind. Führe Maßnahmen nur auf, wenn eine konkrete zukünftige Handlung gestützt ist; nenne verantwortliche Personen und Fristen ausschließlich bei expliziter Zuweisung, Annahme oder Bestätigung im Transkript. Vorschläge, Einwände, Möglichkeiten und vorläufige Ideen sind keine Entscheidungen oder Verpflichtungen. Bewahre relevante Einschränkungen und ungelöste Meinungsverschiedenheiten. Nenne offene Punkte nur, wenn sie wirklich offen bleiben. Nicht jedes Thema benötigt Entscheidungen, Maßnahmen oder offene Punkte. Erzeuge keine reine Wiedergabe des Transkripts und verlängere das Protokoll nicht unnötig durch Wiederholungen. Synthetisiere zusammengehörige Aussagen, entferne Füllwörter und Gesprächsrauschen und erfinde keine Fakten, Entscheidungen, Zustimmungen, Verantwortlichen oder Fristen. Gib kein JSON, keine internen Labels und keine Analyse oder Denkprotokolle aus. Das Ergebnis soll als Markdown-Protokoll nach geringfügiger menschlicher Redaktion intern versendbar sein. Eine kompakte themenübergreifende Maßnahmenliste am Ende ist optional, wenn sie nützlich und vollständig belegt ist.""" -COMPACT_DIARIZED_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und Meeting-Kontext ein vollständiges, professionelles internes Besprechungsprotokoll auf Deutsch. Das Transkript ist in aufeinanderfolgende anonyme Sprecherblöcke gegliedert. +COMPACT_DIARIZED_PROTOCOL_INSTRUCTION = """Erstelle aus dem vollständigen Transkript und Meeting-Kontext ein vollständiges, professionelles internes Besprechungsprotokoll. Das Transkript ist in aufeinanderfolgende anonyme Sprecherblöcke gegliedert. Beginne mit # Meeting Protocol. Gliedere themenorientiert mit ## und synthetisiere je Thema den relevanten Diskussionsverlauf, Kontext, unterschiedliche Positionen, Entscheidungsgrundlagen, Einschränkungen und ungelöste Meinungsverschiedenheiten so, dass Dritte ihn nachvollziehen können. Nenne Entscheidungen nur bei Beleg. Nenne Maßnahmen, Verantwortliche und Fristen nur bei expliziter Zuweisung, Annahme oder Bestätigung; Vorschläge sind keine Verpflichtungen. @@ -23,10 +23,14 @@ def build_direct_protocol_prompt( meeting_context: str | None = None, *, instruction: str = DIRECT_PROTOCOL_INSTRUCTION, + meeting_language: str = "de", ) -> str: context = meeting_context.strip() if meeting_context else "Kein Meeting-Kontext bereitgestellt." + language = {"de": "German", "en": "English"}.get(meeting_language, meeting_language) return ( f"{instruction}\n\n" + f"Write the meeting protocol in {language}. " + "Preserve speaker and person names exactly as supplied.\n\n" f"MEETING-KONTEXT:\n{context}\n\n" f"VOLLSTAENDIGES TRANSKRIPT:\n{transcript.strip()}\n" ) diff --git a/src/meeting_lab/protocol/generate_direct_protocol.py b/src/meeting_lab/protocol/generate_direct_protocol.py index 63e96c6..7095fb2 100644 --- a/src/meeting_lab/protocol/generate_direct_protocol.py +++ b/src/meeting_lab/protocol/generate_direct_protocol.py @@ -96,6 +96,7 @@ def select_transcript_input( rendered_context: str | None, *, safe_input_token_budget: int = DEFAULT_SAFE_INPUT_TOKEN_BUDGET, + meeting_language: str = "de", ) -> SelectedTranscriptInput: """Select complete prompt input without allowing silent tail truncation.""" if safe_input_token_budget <= 0: @@ -117,6 +118,7 @@ def select_transcript_input( compact.text, rendered_context, instruction=instruction, + meeting_language=meeting_language, ) compact_estimate = estimate_input_tokens(compact_prompt) if compact_estimate <= safe_input_token_budget: @@ -138,7 +140,9 @@ def select_transcript_input( representation = "plain_transcript" fallback_used = False - plain_prompt = build_direct_protocol_prompt(plain_text, rendered_context) + plain_prompt = build_direct_protocol_prompt( + plain_text, rendered_context, meeting_language=meeting_language + ) plain_estimate = estimate_input_tokens(plain_prompt) if plain_estimate > safe_input_token_budget: raise DirectProtocolError( @@ -175,10 +179,15 @@ def generate_direct_protocol( load_meeting_context(context_path) if context_path is not None else None ) rendered_context = render_meeting_context_for_prompt(context) if context else None + # Older runs without a meeting language retain the historical German output. + meeting_language = ( + str(context.data["meeting"].get("language") or "de") if context else "de" + ) selected = select_transcript_input( transcript, rendered_context, safe_input_token_budget=safe_input_token_budget, + meeting_language=meeting_language, ) model_metadata = model_check(endpoint, model, 10) @@ -192,6 +201,7 @@ def generate_direct_protocol( ) data = generation.raw_response runtime_metadata = { + "output_language": meeting_language, "model": model, "prompt_token_count": data.get("prompt_eval_count"), "output_token_count": data.get("eval_count"), diff --git a/tests/test_meeting_language.py b/tests/test_meeting_language.py new file mode 100644 index 0000000..2ff9d1b --- /dev/null +++ b/tests/test_meeting_language.py @@ -0,0 +1,164 @@ +"""Meeting-language regression tests without media or model execution.""" + +import json +import tempfile +import unittest +from functools import partial +from pathlib import Path +from unittest.mock import Mock, patch + +from src.meeting_lab.llm.ollama import OllamaGeneration +from src.meeting_lab.models.meeting_context import load_meeting_context +from src.meeting_lab.orchestration.mvp import regenerate_mvp_protocol +from src.meeting_lab.protocol.direct_protocol_prompt import build_direct_protocol_prompt +from src.meeting_lab.protocol.generate_direct_protocol import ( + estimate_input_tokens, + generate_direct_protocol, + select_transcript_input, +) + + +class MeetingLanguageTests(unittest.TestCase): + def test_diarized_plain_fallback_retains_english_instruction(self) -> None: + segments = [ + { + "start": index, + "end": index + 1, + "text": "Word.", + "speaker_id": f"SPEAKER_{index % 2:02d}", + } + for index in range(200) + ] + plain = " ".join(segment["text"] for segment in segments) + budget = estimate_input_tokens( + build_direct_protocol_prompt(plain, meeting_language="en") + ) + selected = select_transcript_input( + {"text": plain, "segments": segments, "speaker_labels_anonymous": True}, + None, + meeting_language="en", + safe_input_token_budget=budget, + ) + self.assertEqual(selected.representation, "plain_transcript_fallback") + self.assertIn("Write the meeting protocol in English.", selected.prompt) + self.assertEqual(selected.text, plain) + + def test_generation_and_regeneration_preserve_language_and_inputs(self) -> None: + for language, expected in ( + ("de", "German"), + ("en", "English"), + (None, "German"), + ): + for diarized in (False, True): + with ( + self.subTest(language=language, diarized=diarized), + tempfile.TemporaryDirectory() as directory, + ): + run = Path(directory) + context_path = run / "context" / "meeting_context.yaml" + context_path.parent.mkdir() + context_text = ( + 'schema_version: "1"\nmeeting:\n' + " meeting_id: test\n title: Mixed terminology\n" + + (f" language: {language}\n" if language else "") + + ' notes: "Freigabe für Product X"\n' + "participants:\n - participant_id: person\n" + ' display_name: "Jörg Müller"\n' + "speaker_mappings:\n SPEAKER_00: person\n" + ) + context_path.write_text(context_text, encoding="utf-8") + transcript = run / ("diarization" if diarized else "transcript") + transcript.mkdir() + transcript /= ( + "transcript_diarized.json" if diarized else "transcript.json" + ) + transcript.write_text( + json.dumps( + { + "text": "I will check Product X.", + "speaker_labels_anonymous": diarized, + "segments": [ + { + "id": 0, + "start": 0, + "end": 1, + "speaker_id": "SPEAKER_00", + "text": "I will check Product X.", + } + ], + } + ), + encoding="utf-8", + ) + original = transcript.read_bytes() + call = Mock( + return_value=OllamaGeneration( + text="# Meeting Protocol", + raw_response={}, + client_wall_time_seconds=0.1, + ) + ) + generate = partial( + generate_direct_protocol, + model_check=Mock(return_value={}), + generation_call=call, + ) + result = generate(transcript, context_path) + self.assertIn( + f"Write the meeting protocol in {expected}.", + result.exact_prompt, + ) + self.assertNotIn("in deutscher Sprache", result.exact_prompt) + self.assertNotIn("auf Deutsch", result.exact_prompt) + self.assertIn("Jörg Müller", result.exact_prompt) + self.assertIn("Mixed terminology", result.exact_prompt) + self.assertIn("I will check Product X.", result.transcript_input) + self.assertEqual( + result.runtime_metadata["output_language"], language or "de" + ) + self.assertEqual( + context_path.read_text(encoding="utf-8"), context_text + ) + context = load_meeting_context(context_path) + with ( + patch( + "src.meeting_lab.orchestration.mvp.generate_direct_protocol", + side_effect=generate, + ), + patch( + "src.meeting_lab.orchestration.mvp.transcribe_audio", + side_effect=AssertionError("Retranscription is forbidden"), + ), + ): + regenerate_mvp_protocol(run, meeting_context=context) + metadata = json.loads( + (run / "protocol" / "runtime_metadata.json").read_text() + ) + self.assertEqual(metadata["output_language"], language or "de") + self.assertIn( + f"Write the meeting protocol in {expected}.", + call.call_args.args[2], + ) + self.assertEqual(transcript.read_bytes(), original) + self.assertEqual( + load_meeting_context(context_path).data, context.data + ) + self.assertEqual(context.speaker_mappings, {"SPEAKER_00": "person"}) + + def test_no_context_defaults_to_german(self) -> None: + with tempfile.TemporaryDirectory() as directory: + transcript = Path(directory) / "transcript.json" + transcript.write_text('{"text": "English source text."}') + result = generate_direct_protocol( + transcript, + model_check=Mock(return_value={}), + generation_call=Mock( + return_value=OllamaGeneration( + text="Protocol", + raw_response={}, + client_wall_time_seconds=0.1, + ) + ), + ) + self.assertIn("Write the meeting protocol in German.", result.exact_prompt) + self.assertEqual(result.runtime_metadata["output_language"], "de")