170 lines
6.8 KiB
Python
170 lines
6.8 KiB
Python
"""Publication fault injection without inference or audio processing."""
|
|
|
|
import json
|
|
import multiprocessing
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from src.meeting_lab.models.meeting_context import create_meeting_context, load_meeting_context
|
|
from src.meeting_lab.protocol import history
|
|
from src.meeting_lab.orchestration import mvp
|
|
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
|
|
from tests import test_mvp_api as fixtures
|
|
|
|
|
|
def result(text):
|
|
return DirectProtocolResult(
|
|
protocol_text=text,
|
|
exact_prompt="prompt " + text,
|
|
transcript_input="Lumini original",
|
|
model_metadata={"name": "test"},
|
|
runtime_metadata={
|
|
"glossary_aliases_configured": {"Lumini": "Luminy"},
|
|
"glossary_replacements": [],
|
|
"num_thread": 10,
|
|
},
|
|
raw_response={"response": text},
|
|
)
|
|
|
|
|
|
def interrupt_publication(root):
|
|
original = history._replace_link
|
|
|
|
def interrupted(path, target):
|
|
if path.name == "current":
|
|
os._exit(77)
|
|
original(path, target)
|
|
|
|
history._replace_link = interrupted
|
|
history.persist_protocol_generation(
|
|
Path(root), result("new"), context=create_meeting_context(fixtures.context_data())
|
|
)
|
|
|
|
|
|
class HistoryTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.root = Path(self.temporary.name)
|
|
self.context = create_meeting_context(fixtures.context_data())
|
|
|
|
def persist(self, text):
|
|
return history.persist_protocol_generation(self.root, result(text), context=self.context)
|
|
|
|
def snapshot(self):
|
|
names = [
|
|
"protocol.md",
|
|
"context/meeting_context.yaml",
|
|
*["protocol/" + n for n in history._DIAGNOSTICS],
|
|
]
|
|
return {name: (self.root / name).read_bytes() for name in names}
|
|
|
|
def test_success_numbering_provenance_and_immutable_context(self):
|
|
self.persist("first")
|
|
first = self.snapshot()
|
|
self.persist("second")
|
|
self.assertEqual((self.root / "protocol.md").read_text(), "second")
|
|
old = self.root / "protocol/generations/001"
|
|
self.assertEqual((old / "protocol.md").read_bytes(), first["protocol.md"])
|
|
self.assertEqual(
|
|
(old / "meeting_context.yaml").read_bytes(), first["context/meeting_context.yaml"]
|
|
)
|
|
current = (self.root / "protocol/current").resolve()
|
|
self.assertEqual(current.name, "002")
|
|
metadata = json.loads((current / "runtime_metadata.json").read_text())
|
|
self.assertEqual(metadata["generation_index"], 2)
|
|
self.assertEqual(metadata["speaker_mapping"], self.context.speaker_mappings)
|
|
self.assertEqual(metadata["glossary_aliases_configured"], {"Lumini": "Luminy"})
|
|
self.assertEqual(metadata["glossary_replacements"], [])
|
|
self.assertEqual(metadata["num_thread"], 10)
|
|
self.assertEqual(metadata["source_run_id"], self.root.name)
|
|
self.assertEqual(
|
|
json.loads((current / "model_metadata.json").read_text()), {"name": "test"}
|
|
)
|
|
self.assertIn("sha", metadata["git"]["meeting_lab"])
|
|
self.assertEqual(
|
|
load_meeting_context(current / "meeting_context.yaml").data, self.context.data
|
|
)
|
|
for name in history._DIAGNOSTICS:
|
|
self.assertEqual((self.root / "protocol" / name).resolve(), current / name)
|
|
|
|
def test_failure_writing_record_preserves_previous(self):
|
|
self.persist("first")
|
|
before = self.snapshot()
|
|
with patch.object(history, "_write_json", side_effect=OSError("disk full")):
|
|
with self.assertRaises(OSError):
|
|
self.persist("second")
|
|
self.assertEqual(self.snapshot(), before)
|
|
self.persist("third")
|
|
self.assertEqual((self.root / "protocol/current").resolve().name, "002")
|
|
|
|
def test_failure_at_atomic_publication_preserves_all_stable_paths(self):
|
|
self.persist("first")
|
|
before = self.snapshot()
|
|
original = history.os.replace
|
|
|
|
def fail_current(source, destination):
|
|
if Path(destination).name == "current":
|
|
raise OSError("publication stopped")
|
|
original(source, destination)
|
|
|
|
with patch.object(history.os, "replace", side_effect=fail_current):
|
|
with self.assertRaises(OSError):
|
|
self.persist("second")
|
|
self.assertEqual(self.snapshot(), before)
|
|
self.assertFalse((self.root / "protocol/generations/002").exists())
|
|
|
|
def test_process_interruption_before_pointer_swap_preserves_current(self):
|
|
self.persist("first")
|
|
before = self.snapshot()
|
|
worker = multiprocessing.get_context("fork").Process(
|
|
target=interrupt_publication, args=(str(self.root),)
|
|
)
|
|
worker.start()
|
|
worker.join(timeout=10)
|
|
self.assertFalse(worker.is_alive())
|
|
self.assertEqual(worker.exitcode, 77)
|
|
self.assertEqual(self.snapshot(), before)
|
|
self.persist("third")
|
|
self.assertEqual((self.root / "protocol.md").read_text(), "third")
|
|
|
|
def test_legacy_migration_and_failure_during_link_conversion(self):
|
|
(self.root / "protocol").mkdir()
|
|
(self.root / "context").mkdir()
|
|
(self.root / "protocol.md").write_text("legacy")
|
|
for name in history._DIAGNOSTICS:
|
|
(self.root / "protocol" / name).write_text("legacy " + name)
|
|
(self.root / "context/meeting_context.yaml").write_text("legacy context")
|
|
before = self.snapshot()
|
|
original = history._replace_link
|
|
|
|
def fail_conversion(path, target):
|
|
if path.name == "raw_response.json":
|
|
raise OSError("link conversion stopped")
|
|
original(path, target)
|
|
|
|
with patch.object(history, "_replace_link", side_effect=fail_conversion):
|
|
with self.assertRaises(OSError):
|
|
self.persist("new")
|
|
self.assertEqual(self.snapshot(), before)
|
|
self.persist("new")
|
|
self.assertEqual((self.root / "protocol/generations/001/protocol.md").read_text(), "legacy")
|
|
self.assertEqual((self.root / "protocol/current").resolve().name, "002")
|
|
|
|
def test_failed_model_does_not_change_context_or_latest_generation(self):
|
|
self.persist("first")
|
|
before = self.snapshot()
|
|
(self.root / "transcript").mkdir()
|
|
(self.root / "transcript/transcript.json").write_text('{"text":"original"}')
|
|
different = fixtures.context_data()
|
|
different["speaker_mappings"] = {}
|
|
with patch.object(
|
|
mvp, "generate_direct_protocol", side_effect=RuntimeError("model stopped")
|
|
):
|
|
with self.assertRaises(RuntimeError):
|
|
mvp.regenerate_mvp_protocol(self.root, meeting_context=different)
|
|
self.assertEqual(self.snapshot(), before)
|