Implement Meeting Context V1 and extraction improvements

Introduce Meeting Context V1 with YAML schema, validation and template.
Support optional --meeting-context during chunk extraction.
Inject authoritative Meeting Context into extraction prompts.
Record Meeting Context provenance in extraction output.
Activate todos.md in shared prompt assembly.
Strengthen responsibility attribution and decision/todo boundaries.
Add focused Gold scenarios and validation tests.
Update architecture and pipeline documentation.
This commit is contained in:
2026-08-01 16:49:48 +02:00
parent 63075eaca9
commit 06f0e7e651
25 changed files with 1453 additions and 22 deletions
@@ -0,0 +1,18 @@
# position_explicit_objection
Tests extraction of one explicit objection as a position.
Scope:
- Extract Tom's stated objection as exactly one position.
- Do not create a todo for Tom.
- Do not derive a decision from the objection.
Exclusions:
- This scenario does not test responsibility attribution for an open owner.
- Responsibility attribution is covered by `responsibility_attribution_negative`.
The transcript gives unique ground truth because Tom explicitly says "I object"
and "My position is", then explicitly refuses responsibility. The group also
states that no decision and no task for Tom were created.
@@ -0,0 +1,14 @@
{
"facts": [],
"decisions": [],
"todos": [],
"questions": [],
"positions": [
{
"speaker": "Tom",
"position": "Tom objects to using one generic intake checklist because generic criteria will not work for analytics pilots and hardware trials.",
"evidence": "Tom: I object to that. My position is that generic criteria will not work for these two types of work."
}
],
"technical": []
}
@@ -0,0 +1,11 @@
Iris: We could use one generic intake checklist for analytics pilots and hardware trials.
Tom: I object to that. My position is that generic criteria will not work for these two types of work.
Iris: Understood. Are you taking responsibility for rewriting the checklist?
Tom: No. I am not taking that on, and I am not proposing an owner. I am only stating my position.
Uma: Then we are not deciding the checklist today.
Iris: Correct. No decision and no task for Tom.
@@ -7,9 +7,14 @@ The scenario includes a Marketing participant who comments critically on
Business Development criteria. No one assigns that participant responsibility
for defining the criteria, and the participant does not accept such a task.
Expected behavior:
Scope:
- Do not assign Business Development criteria to the Marketing participant.
- Do not reclassify the Marketing participant as Business Development.
- Preserve the objection as a position.
- Keep responsibility open unless explicitly assigned.
- Preserve the agreed next step to ask Business Development for an owner.
Exclusions:
- This scenario does not test whether the objection is extracted as a position.
- Position extraction is covered by `position_explicit_objection`.
@@ -28,12 +28,6 @@
}
],
"questions": [],
"positions": [
{
"speaker": "Noah",
"position": "Noah says generic criteria will not work for different Marketing and product contexts.",
"evidence": "Noah: From Marketing, I can tell you that generic criteria will not work. A digital campaign and a physical product launch need different checks."
}
],
"positions": [],
"technical": []
}
+38
View File
@@ -5,12 +5,15 @@ from pathlib import Path
from src.meeting_lab.extraction.extract_chunks import (
EXTRACTION_CATEGORIES,
EXTRACTION_TASK_PROMPT_NAMES,
build_prompt,
extraction_path_for_chunk,
normalize_current_schema,
parse_json_response,
)
from src.meeting_lab.models.meeting_context import load_meeting_context
from src.meeting_lab.protocol.build_protocol import build_protocol
from scripts import run_gold_test
class ExtractionProtocolTests(unittest.TestCase):
@@ -102,6 +105,41 @@ Final answer:
self.assertIn("Extract each decision as one atomic commitment.", prompt)
self.assertIn("Anna: Agreed.", prompt)
def test_build_prompt_includes_todo_prompt_file(self) -> None:
prompt = build_prompt("transcript.txt", "Nina: I will update it.")
self.assertEqual(EXTRACTION_TASK_PROMPT_NAMES, ("decisions.md", "todos.md"))
self.assertIn("Action-item responsibility rule:", prompt)
self.assertIn(
"A named responsible person may be extracted only when the transcript contains",
prompt,
)
def test_gold_runner_uses_shared_production_prompt_assembly(self) -> None:
self.assertIs(run_gold_test.build_prompt, build_prompt)
def test_no_context_and_meeting_context_prompts_use_same_task_prompt_set(self) -> None:
context = load_meeting_context(
Path("samples/real_live/project_process_meeting/meeting_context.yaml")
)
no_context_prompt = build_prompt("chunk_01_normalized.txt", "Anna: Agreed.")
context_prompt = build_prompt(
"chunk_01_normalized.txt",
"Anna: Agreed.",
meeting_context=context,
)
for prompt in (no_context_prompt, context_prompt):
self.assertIn("You extract decisions from meeting transcript text.", prompt)
self.assertIn("Action-item responsibility rule:", prompt)
def test_prompt_assembly_is_deterministic(self) -> None:
first = build_prompt("transcript.txt", "Nina: I will update it.")
second = build_prompt("transcript.txt", "Nina: I will update it.")
self.assertEqual(first, second)
def test_build_protocol_groups_extraction_items(self) -> None:
with tempfile.TemporaryDirectory() as directory:
input_dir = Path(directory)
+197
View File
@@ -0,0 +1,197 @@
import copy
import json
import unittest
from pathlib import Path
from unittest.mock import patch
from src.meeting_lab.extraction.extract_chunks import (
EXTRACTION_CATEGORIES,
build_prompt,
extract_chunk,
)
from src.meeting_lab.models.meeting_context import (
MeetingContextValidationError,
load_meeting_context,
render_meeting_context_for_prompt,
validate_meeting_context,
)
CONTEXT_PATH = Path("samples/real_live/project_process_meeting/meeting_context.yaml")
SCRATCH_DIR = Path(".test-tmp")
class MeetingContextTests(unittest.TestCase):
def setUp(self) -> None:
self.context = load_meeting_context(CONTEXT_PATH)
def tearDown(self) -> None:
if not SCRATCH_DIR.exists():
return
for path in SCRATCH_DIR.glob("chunk_0*_*.txt"):
path.unlink()
for path in SCRATCH_DIR.glob("chunk_0*_*.json"):
path.unlink()
def test_valid_context_loading(self) -> None:
self.assertEqual(self.context.schema_version, "1")
self.assertEqual(self.context.meeting_id, "2026-07-27-projektprozess")
self.assertEqual(self.context.data["meeting"]["language"], "de")
def test_duplicate_participant_ids_are_invalid(self) -> None:
data = copy.deepcopy(self.context.data)
data["participants"][1]["participant_id"] = data["participants"][0][
"participant_id"
]
with self.assertRaisesRegex(MeetingContextValidationError, "Duplicate"):
validate_meeting_context(data)
def test_invalid_department_references_are_invalid(self) -> None:
data = copy.deepcopy(self.context.data)
data["participants"][0]["department_id"] = "unknown"
with self.assertRaisesRegex(MeetingContextValidationError, "unknown department"):
validate_meeting_context(data)
def test_participant_and_mentioned_person_id_collision_is_invalid(self) -> None:
data = copy.deepcopy(self.context.data)
data["mentioned_people"][0]["person_id"] = data["participants"][0][
"participant_id"
]
with self.assertRaisesRegex(MeetingContextValidationError, "collide"):
validate_meeting_context(data)
def test_invalid_attendance_status_is_invalid(self) -> None:
data = copy.deepcopy(self.context.data)
data["participants"][0]["attendance_status"] = "remote"
with self.assertRaisesRegex(MeetingContextValidationError, "invalid value"):
validate_meeting_context(data)
def test_prompt_representation_is_deterministic(self) -> None:
first = render_meeting_context_for_prompt(self.context)
second = render_meeting_context_for_prompt(self.context)
self.assertEqual(first, second)
self.assertIn("MEETING CONTEXT V1", first)
self.assertIn("- Language: de", first)
def test_authoritative_rules_appear_in_prompt(self) -> None:
prompt = build_prompt(
"chunk_01_normalized.txt",
"Martin: Wir besprechen Marketing.",
meeting_context=self.context,
)
self.assertIn("The participant list is authoritative.", prompt)
self.assertIn("Mentioned people did not attend this meeting.", prompt)
self.assertIn("Roles and departments must not be inferred or changed.", prompt)
self.assertIn("Discussion of a department does not establish responsibility.", prompt)
self.assertIn(
"An action item may name a responsible person only when assignment or acceptance is explicit",
prompt,
)
self.assertIn("Objections, suggestions and expertise do not establish ownership.", prompt)
def test_extraction_behavior_is_unchanged_without_context(self) -> None:
response = json.dumps(
{
"facts": [],
"decisions": [],
"todos": [],
"open_questions": [],
"positions": [],
"technical_details": [],
}
)
SCRATCH_DIR.mkdir(exist_ok=True)
chunk_path = SCRATCH_DIR / "chunk_01_normalized.txt"
output_path = SCRATCH_DIR / "chunk_01_extraction.json"
chunk_path.write_text("Anna: Keine Entscheidung.", encoding="utf-8")
captured_prompts = []
def fake_call_ollama(**kwargs):
captured_prompts.append(kwargs["prompt"])
return response, {}
with patch(
"src.meeting_lab.extraction.extract_chunks.call_ollama",
side_effect=fake_call_ollama,
):
extraction = extract_chunk(
chunk_path,
output_path,
model="test",
endpoint="http://example.invalid",
timeout=1,
temperature=0.0,
num_predict=None,
num_ctx=None,
)
self.assertEqual(set(extraction), set(EXTRACTION_CATEGORIES))
self.assertNotIn("context", extraction)
self.assertNotIn("MEETING CONTEXT V1", captured_prompts[0])
def test_extraction_result_records_meeting_context_provenance(self) -> None:
response = json.dumps(
{
"facts": [],
"decisions": [],
"todos": [],
"open_questions": [],
"positions": [],
"technical_details": [],
}
)
SCRATCH_DIR.mkdir(exist_ok=True)
chunk_path = SCRATCH_DIR / "chunk_02_normalized.txt"
output_path = SCRATCH_DIR / "chunk_02_extraction.json"
chunk_path.write_text("Martin: Hallo.", encoding="utf-8")
with patch(
"src.meeting_lab.extraction.extract_chunks.call_ollama",
return_value=(response, {}),
):
extraction = extract_chunk(
chunk_path,
output_path,
model="test",
endpoint="http://example.invalid",
timeout=1,
temperature=0.0,
num_predict=None,
num_ctx=None,
meeting_context=self.context,
)
written = json.loads(output_path.read_text(encoding="utf-8"))
self.assertEqual(
extraction["context"]["meeting_id"],
"2026-07-27-projektprozess",
)
self.assertEqual(extraction["context"]["schema_version"], "1")
self.assertEqual(written["context"], extraction["context"])
self.assertNotIn("participants", written["context"])
def test_real_context_keeps_metadata_separate_from_assignment(self) -> None:
prompt_context = render_meeting_context_for_prompt(self.context)
self.assertIn("Björn", prompt_context)
self.assertIn("department: Marketing", prompt_context)
self.assertIn("Mentioned but absent people:", prompt_context)
self.assertIn("Jovana", prompt_context)
self.assertIn("Discussion of a department does not establish responsibility.", prompt_context)
self.assertIn("assignment or acceptance is explicit", prompt_context)
self.assertNotIn("responsible: Björn", prompt_context)
self.assertNotIn("responsible: Jovana", prompt_context)
if __name__ == "__main__":
unittest.main()