Make protocol language follow meeting language

This commit is contained in:
2026-09-11 10:25:45 +02:00
parent 8a0f38fce4
commit 6fc07690d9
6 changed files with 204 additions and 4 deletions
+13
View File
@@ -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
+7
View File
@@ -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
@@ -108,6 +108,8 @@ 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")
# 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")
@@ -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 ## <Thema> 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 ## <Thema> 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"
)
@@ -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"),
+164
View File
@@ -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")