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:
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user