Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
874ab5b68d | ||
|
|
d2e3636b94 | ||
|
|
92247ef43e | ||
|
|
9177a2660b |
@@ -295,3 +295,23 @@ The next recommended evaluation step is to use the consolidated V0 result as
|
|||||||
input for the unchanged Working Protocol renderer and compare that output
|
input for the unchanged Working Protocol renderer and compare that output
|
||||||
against the Working Protocol Synthesizer V0 baseline and the human reference
|
against the Working Protocol Synthesizer V0 baseline and the human reference
|
||||||
protocol.
|
protocol.
|
||||||
|
|
||||||
|
Protocol calls accept an optional positive `protocol_num_thread` setting through
|
||||||
|
both initial processing and regeneration. With `None`, Ollama receives no
|
||||||
|
`num_thread` override and selects its own thread configuration.
|
||||||
|
|
||||||
|
Configured glossary aliases are diagnostic metadata only. Meeting Context supplies
|
||||||
|
terminology guidance; direct-protocol transcript text is never alias-substituted.
|
||||||
|
See `docs/protocol-generation-regression.md` for the frozen-input regression.
|
||||||
|
|
||||||
|
Successful protocols are retained in `protocol/generations/NNN/` with their exact
|
||||||
|
prompt, transcript input, response, model/runtime metadata and Meeting Context.
|
||||||
|
Relative compatibility symlinks resolve through `protocol/current`, which is
|
||||||
|
replaced atomically only after a complete record is written. Failed regeneration
|
||||||
|
keeps the previous protocol, diagnostics and context. Legacy regular files are
|
||||||
|
snapshotted before conversion. Publication is serialized with a local file lock.
|
||||||
|
Read `current` once when inspecting a consistent multi-file snapshot. Copy whole
|
||||||
|
run directories preserving relative symlinks. Process interruption may leave an
|
||||||
|
unreferenced record or temporary directory, never a partial current generation.
|
||||||
|
This local POSIX-filesystem contract does not promise power-loss durability or
|
||||||
|
network-filesystem transaction semantics.
|
||||||
|
|||||||
@@ -278,3 +278,15 @@ Artefakten, Windows-Beispiel und Vergleich mit dem AI-PC stehen in
|
|||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
Noch nicht festgelegt.
|
Noch nicht festgelegt.
|
||||||
|
|
||||||
|
Successful protocols are retained in `protocol/generations/NNN/` with their exact
|
||||||
|
prompt, transcript input, response, model/runtime metadata and Meeting Context.
|
||||||
|
Relative compatibility symlinks resolve through `protocol/current`, which is
|
||||||
|
replaced atomically only after a complete record is written. Failed regeneration
|
||||||
|
keeps the previous protocol, diagnostics and context. Legacy regular files are
|
||||||
|
snapshotted before conversion. Publication is serialized with a local file lock.
|
||||||
|
Read `current` once when inspecting a consistent multi-file snapshot. Copy whole
|
||||||
|
run directories preserving relative symlinks. Process interruption may leave an
|
||||||
|
unreferenced record or temporary directory, never a partial current generation.
|
||||||
|
This local POSIX-filesystem contract does not promise power-loss durability or
|
||||||
|
network-filesystem transaction semantics.
|
||||||
|
|||||||
@@ -60,3 +60,8 @@ also exceeds the budget, generation fails before model lookup or generation;
|
|||||||
it never truncates, chunks, summarizes, retries, or makes multiple protocol
|
it never truncates, chunks, summarizes, retries, or makes multiple protocol
|
||||||
calls implicitly. Full diarization artifacts are never overwritten by this
|
calls implicitly. Full diarization artifacts are never overwritten by this
|
||||||
selection.
|
selection.
|
||||||
|
|
||||||
|
Glossary aliases are recorded as configuration provenance, never applied as
|
||||||
|
deterministic replacements to the compact/plain protocol input. Canonical
|
||||||
|
terminology guides generation through Meeting Context. Raw Whisper and
|
||||||
|
diarization artifacts remain unchanged. `glossary_replacements` is always empty.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Direct-protocol regression reproduction
|
||||||
|
|
||||||
|
The August 2026 glossary regression was isolated with frozen inputs. Condition
|
||||||
|
A used the original derived transcript; condition B differed only by these
|
||||||
|
seven deterministic substitutions:
|
||||||
|
|
||||||
|
- `Carbofool` to `Carbofol` (two occurrences)
|
||||||
|
- `Bento Fix` to `Bentofix` (two occurrences)
|
||||||
|
- `Sikirgut-Heistlöse` to `Secugrid HS` (one occurrence)
|
||||||
|
- `Lumini` to `Luminy` (two occurrences)
|
||||||
|
|
||||||
|
To repeat the manual comparison, copy the investigated run to a new temporary
|
||||||
|
directory, retain its Meeting Context and speaker mapping, and invoke
|
||||||
|
`regenerate_mvp_protocol` through the same parameters used by Meeting
|
||||||
|
Assistant. Never run the comparison in the historical run directory. Preserve
|
||||||
|
the model, `num_ctx`, `num_predict`, temperature, think setting, thread setting,
|
||||||
|
and Meeting Context. Compare the new generation's `exact_prompt.txt` and
|
||||||
|
`transcript_input.txt` with the frozen A and B artifacts before comparing model
|
||||||
|
output.
|
||||||
|
|
||||||
|
The required fixed condition is A: configured glossary aliases remain visible
|
||||||
|
in Meeting Context terminology guidance, while `transcript_input.txt` retains
|
||||||
|
the original seven source spellings. Automated tests mock the model and enforce
|
||||||
|
that invariant; this full historical experiment remains an explicit manual LLM
|
||||||
|
validation so normal tests do not depend on a local model.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Meeting Assistant — First functional alpha
|
||||||
|
|
||||||
|
Version: `0.1.0a1`
|
||||||
|
Tag: `v0.1.0-alpha.1`
|
||||||
|
|
||||||
|
This paired Meeting Lab revision supports the first functional end-to-end
|
||||||
|
Meeting Assistant alpha: reusable transcription and optional diarization,
|
||||||
|
explicit speaker mapping and regeneration, German and English protocol
|
||||||
|
generation, glossary provenance without transcript mutation, configurable
|
||||||
|
protocol thread profiles, generation history with atomic publication, and the
|
||||||
|
GTM-Hub qualitative regression case.
|
||||||
|
|
||||||
|
Validated candidate revisions:
|
||||||
|
|
||||||
|
- Assistant: `b50b87c32b3941310c719f459fd45faa87658b68`
|
||||||
|
- Meeting Lab: `d2e3636b949280402728308022c99bdee6ea1066`
|
||||||
|
|
||||||
|
The immutable release revisions are the commits targeted by the paired
|
||||||
|
`v0.1.0-alpha.1` tags in the two repositories.
|
||||||
|
|
||||||
|
Accepted limitations are documented in the Assistant release notes, including
|
||||||
|
the requirement for local runtime/model/container setup, human review of
|
||||||
|
generated protocols, imperfect diarization, context limits, local POSIX
|
||||||
|
publication assumptions, and the historical ignored fixtures needed by some
|
||||||
|
old experiment tests.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "meeting-lab"
|
name = "meeting-lab"
|
||||||
version = "0.1.0"
|
version = "0.1.0a1"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||||||
if str(REPO_ROOT) not in sys.path:
|
if str(REPO_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(REPO_ROOT))
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from src.meeting_lab.models.meeting_context import load_meeting_context # noqa: E402
|
||||||
|
from src.meeting_lab.protocol.history import persist_protocol_generation # noqa: E402
|
||||||
from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT # noqa: E402
|
from src.meeting_lab.llm.ollama import DEFAULT_ENDPOINT # noqa: E402
|
||||||
from src.meeting_lab.protocol.generate_direct_protocol import ( # noqa: E402
|
from src.meeting_lab.protocol.generate_direct_protocol import ( # noqa: E402
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
||||||
DirectProtocolResult,
|
|
||||||
generate_direct_protocol,
|
generate_direct_protocol,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,22 +65,6 @@ def write_json(path: Path, data: Any) -> None:
|
|||||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def persist_result(run_dir: Path, result: DirectProtocolResult) -> Path:
|
|
||||||
protocol_dir = run_dir / "protocol"
|
|
||||||
protocol_dir.mkdir()
|
|
||||||
(protocol_dir / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
|
|
||||||
write_json(protocol_dir / "raw_response.json", result.raw_response)
|
|
||||||
write_json(protocol_dir / "runtime_metadata.json", result.runtime_metadata)
|
|
||||||
transcript_input = getattr(result, "transcript_input", None)
|
|
||||||
if transcript_input is not None:
|
|
||||||
(protocol_dir / "transcript_input.txt").write_text(
|
|
||||||
transcript_input, encoding="utf-8"
|
|
||||||
)
|
|
||||||
protocol_path = run_dir / "protocol.md"
|
|
||||||
protocol_path.write_text(result.protocol_text, encoding="utf-8")
|
|
||||||
return protocol_path
|
|
||||||
|
|
||||||
|
|
||||||
def run(args: argparse.Namespace) -> tuple[int, Path, Path | None]:
|
def run(args: argparse.Namespace) -> tuple[int, Path, Path | None]:
|
||||||
run_dir = create_unique_run_dir(args.output_root, args.transcript.stem)
|
run_dir = create_unique_run_dir(args.output_root, args.transcript.stem)
|
||||||
timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
|
timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||||
@@ -129,7 +114,11 @@ def run(args: argparse.Namespace) -> tuple[int, Path, Path | None]:
|
|||||||
endpoint=args.ollama_endpoint,
|
endpoint=args.ollama_endpoint,
|
||||||
safe_input_token_budget=args.safe_input_token_budget,
|
safe_input_token_budget=args.safe_input_token_budget,
|
||||||
)
|
)
|
||||||
protocol_path = persist_result(run_dir, result)
|
protocol_path = persist_protocol_generation(
|
||||||
|
run_dir,
|
||||||
|
result,
|
||||||
|
context=load_meeting_context(preserved_context) if preserved_context else None,
|
||||||
|
)
|
||||||
metadata["status"] = "completed"
|
metadata["status"] = "completed"
|
||||||
metadata["final_protocol_path"] = str(protocol_path.resolve())
|
metadata["final_protocol_path"] = str(protocol_path.resolve())
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ def generate_once(
|
|||||||
timeout: int,
|
timeout: int,
|
||||||
num_ctx: int,
|
num_ctx: int,
|
||||||
num_predict: int,
|
num_predict: int,
|
||||||
|
num_thread: int | None = None,
|
||||||
) -> OllamaGeneration:
|
) -> OllamaGeneration:
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
@@ -74,6 +75,10 @@ def generate_once(
|
|||||||
"num_predict": num_predict,
|
"num_predict": num_predict,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if num_thread is not None:
|
||||||
|
if type(num_thread) is not int or num_thread <= 0:
|
||||||
|
raise ValueError("Protocol thread count must be a positive integer.")
|
||||||
|
payload["options"]["num_thread"] = num_thread
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
response = requests.post(generate_url(endpoint), json=payload, timeout=timeout)
|
response = requests.post(generate_url(endpoint), json=payload, timeout=timeout)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -28,11 +29,11 @@ from src.meeting_lab.models.meeting_context import (
|
|||||||
write_meeting_context,
|
write_meeting_context,
|
||||||
)
|
)
|
||||||
from src.meeting_lab.progress import ProgressEvent, ProgressSink, ProgressStatus
|
from src.meeting_lab.progress import ProgressEvent, ProgressSink, ProgressStatus
|
||||||
|
from src.meeting_lab.protocol.history import persist_protocol_generation
|
||||||
from src.meeting_lab.protocol.generate_direct_protocol import (
|
from src.meeting_lab.protocol.generate_direct_protocol import (
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
DEFAULT_NUM_CTX,
|
DEFAULT_NUM_CTX,
|
||||||
DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
||||||
DirectProtocolResult,
|
|
||||||
generate_direct_protocol,
|
generate_direct_protocol,
|
||||||
load_compact_transcript,
|
load_compact_transcript,
|
||||||
)
|
)
|
||||||
@@ -56,6 +57,8 @@ class MvpMeetingConfig:
|
|||||||
threads: str | int = "auto"
|
threads: str | int = "auto"
|
||||||
model: str = DEFAULT_MODEL
|
model: str = DEFAULT_MODEL
|
||||||
ollama_endpoint: str = DEFAULT_ENDPOINT
|
ollama_endpoint: str = DEFAULT_ENDPOINT
|
||||||
|
glossary_aliases: Mapping[str, str] | None = None
|
||||||
|
protocol_num_thread: int | None = None
|
||||||
protocol_num_ctx: int = DEFAULT_NUM_CTX
|
protocol_num_ctx: int = DEFAULT_NUM_CTX
|
||||||
protocol_safe_input_token_budget: int = DEFAULT_SAFE_INPUT_TOKEN_BUDGET
|
protocol_safe_input_token_budget: int = DEFAULT_SAFE_INPUT_TOKEN_BUDGET
|
||||||
diarization: str = "off"
|
diarization: str = "off"
|
||||||
@@ -77,6 +80,8 @@ def regenerate_mvp_protocol(
|
|||||||
meeting_context: ContextInput,
|
meeting_context: ContextInput,
|
||||||
model: str = DEFAULT_MODEL,
|
model: str = DEFAULT_MODEL,
|
||||||
ollama_endpoint: str = DEFAULT_ENDPOINT,
|
ollama_endpoint: str = DEFAULT_ENDPOINT,
|
||||||
|
glossary_aliases: Mapping[str, str] | None = None,
|
||||||
|
protocol_num_thread: int | None = None,
|
||||||
protocol_num_ctx: int = DEFAULT_NUM_CTX,
|
protocol_num_ctx: int = DEFAULT_NUM_CTX,
|
||||||
protocol_safe_input_token_budget: int = DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
protocol_safe_input_token_budget: int = DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
||||||
progress_sink: ProgressSink | None = None,
|
progress_sink: ProgressSink | None = None,
|
||||||
@@ -87,6 +92,10 @@ def regenerate_mvp_protocol(
|
|||||||
context = _effective_context(meeting_context)
|
context = _effective_context(meeting_context)
|
||||||
if context is None:
|
if context is None:
|
||||||
raise ValueError("Meeting Context is required for protocol regeneration.")
|
raise ValueError("Meeting Context is required for protocol regeneration.")
|
||||||
|
if protocol_num_thread is not None and (
|
||||||
|
type(protocol_num_thread) is not int or protocol_num_thread <= 0
|
||||||
|
):
|
||||||
|
raise ValueError("Protocol Ollama thread count must be a positive integer.")
|
||||||
if protocol_num_ctx <= 0:
|
if protocol_num_ctx <= 0:
|
||||||
raise ValueError("Protocol Ollama context size must be positive.")
|
raise ValueError("Protocol Ollama context size must be positive.")
|
||||||
if protocol_safe_input_token_budget <= 0:
|
if protocol_safe_input_token_budget <= 0:
|
||||||
@@ -102,19 +111,24 @@ def regenerate_mvp_protocol(
|
|||||||
f"Existing run has no protocol transcript artifact: {run_dir}"
|
f"Existing run has no protocol transcript artifact: {run_dir}"
|
||||||
)
|
)
|
||||||
|
|
||||||
context_path = run_dir / "context" / "meeting_context.yaml"
|
|
||||||
write_meeting_context(context, context_path)
|
|
||||||
_emit(progress_sink, "protocol_generation", "started", started)
|
_emit(progress_sink, "protocol_generation", "started", started)
|
||||||
try:
|
try:
|
||||||
result = generate_direct_protocol(
|
with tempfile.TemporaryDirectory(
|
||||||
transcript_path,
|
prefix=".protocol-context-", dir=run_dir, ignore_cleanup_errors=True
|
||||||
context_path,
|
) as temporary:
|
||||||
model=model,
|
context_path = Path(temporary) / "meeting_context.yaml"
|
||||||
endpoint=ollama_endpoint,
|
write_meeting_context(context, context_path)
|
||||||
num_ctx=protocol_num_ctx,
|
result = generate_direct_protocol(
|
||||||
safe_input_token_budget=protocol_safe_input_token_budget,
|
transcript_path,
|
||||||
)
|
context_path,
|
||||||
protocol_path = _persist_protocol(run_dir, result)
|
model=model,
|
||||||
|
endpoint=ollama_endpoint,
|
||||||
|
num_ctx=protocol_num_ctx,
|
||||||
|
num_thread=protocol_num_thread,
|
||||||
|
glossary_aliases=glossary_aliases,
|
||||||
|
safe_input_token_budget=protocol_safe_input_token_budget,
|
||||||
|
)
|
||||||
|
protocol_path = persist_protocol_generation(run_dir, result, context=context)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_emit(
|
_emit(
|
||||||
progress_sink,
|
progress_sink,
|
||||||
@@ -191,6 +205,10 @@ def _validate_inputs(
|
|||||||
raise ValueError("A diarization container image is required.")
|
raise ValueError("A diarization container image is required.")
|
||||||
if config.protocol_safe_input_token_budget <= 0:
|
if config.protocol_safe_input_token_budget <= 0:
|
||||||
raise ValueError("Protocol safe input token budget must be positive.")
|
raise ValueError("Protocol safe input token budget must be positive.")
|
||||||
|
if config.protocol_num_thread is not None and (
|
||||||
|
type(config.protocol_num_thread) is not int or config.protocol_num_thread <= 0
|
||||||
|
):
|
||||||
|
raise ValueError("Protocol Ollama thread count must be a positive integer.")
|
||||||
if config.protocol_num_ctx <= 0:
|
if config.protocol_num_ctx <= 0:
|
||||||
raise ValueError("Protocol Ollama context size must be positive.")
|
raise ValueError("Protocol Ollama context size must be positive.")
|
||||||
|
|
||||||
@@ -214,22 +232,6 @@ def _emit(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _persist_protocol(run_dir: Path, result: DirectProtocolResult) -> Path:
|
|
||||||
protocol_dir = run_dir / "protocol"
|
|
||||||
protocol_dir.mkdir(exist_ok=True)
|
|
||||||
(protocol_dir / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
|
|
||||||
_write_json(protocol_dir / "raw_response.json", result.raw_response)
|
|
||||||
_write_json(protocol_dir / "runtime_metadata.json", result.runtime_metadata)
|
|
||||||
transcript_input = getattr(result, "transcript_input", None)
|
|
||||||
if transcript_input is not None:
|
|
||||||
(protocol_dir / "transcript_input.txt").write_text(
|
|
||||||
transcript_input, encoding="utf-8"
|
|
||||||
)
|
|
||||||
protocol_path = run_dir / "protocol.md"
|
|
||||||
protocol_path.write_text(result.protocol_text, encoding="utf-8")
|
|
||||||
return protocol_path
|
|
||||||
|
|
||||||
|
|
||||||
def run_mvp_meeting(
|
def run_mvp_meeting(
|
||||||
config: MvpMeetingConfig,
|
config: MvpMeetingConfig,
|
||||||
*,
|
*,
|
||||||
@@ -426,10 +428,12 @@ def run_mvp_meeting(
|
|||||||
model=config.model,
|
model=config.model,
|
||||||
endpoint=config.ollama_endpoint,
|
endpoint=config.ollama_endpoint,
|
||||||
num_ctx=config.protocol_num_ctx,
|
num_ctx=config.protocol_num_ctx,
|
||||||
|
num_thread=config.protocol_num_thread,
|
||||||
|
glossary_aliases=config.glossary_aliases,
|
||||||
safe_input_token_budget=config.protocol_safe_input_token_budget,
|
safe_input_token_budget=config.protocol_safe_input_token_budget,
|
||||||
)
|
)
|
||||||
stage_runtimes["protocol"] = round(time.perf_counter() - stage_started, 3)
|
stage_runtimes["protocol"] = round(time.perf_counter() - stage_started, 3)
|
||||||
protocol_path = _persist_protocol(run_dir, result)
|
protocol_path = persist_protocol_generation(run_dir, result, context=effective_context)
|
||||||
_emit(progress_sink, "protocol_generation", "completed", overall_started)
|
_emit(progress_sink, "protocol_generation", "completed", overall_started)
|
||||||
metadata["status"] = "completed"
|
metadata["status"] = "completed"
|
||||||
_emit(progress_sink, "completed", "completed", overall_started)
|
_emit(progress_sink, "completed", "completed", overall_started)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
@@ -170,9 +171,11 @@ def generate_direct_protocol(
|
|||||||
timeout: int = DEFAULT_TIMEOUT,
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
num_ctx: int = DEFAULT_NUM_CTX,
|
num_ctx: int = DEFAULT_NUM_CTX,
|
||||||
num_predict: int = DEFAULT_NUM_PREDICT,
|
num_predict: int = DEFAULT_NUM_PREDICT,
|
||||||
|
num_thread: int | None = None,
|
||||||
safe_input_token_budget: int = DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
safe_input_token_budget: int = DEFAULT_SAFE_INPUT_TOKEN_BUDGET,
|
||||||
model_check: Callable[[str, str, int], dict[str, Any]] = require_model,
|
model_check: Callable[[str, str, int], dict[str, Any]] = require_model,
|
||||||
generation_call: Callable[..., OllamaGeneration] = generate_once,
|
generation_call: Callable[..., OllamaGeneration] = generate_once,
|
||||||
|
glossary_aliases: Mapping[str, str] | None = None,
|
||||||
) -> DirectProtocolResult:
|
) -> DirectProtocolResult:
|
||||||
transcript = _load_transcript_document(transcript_path)
|
transcript = _load_transcript_document(transcript_path)
|
||||||
context: MeetingContext | None = (
|
context: MeetingContext | None = (
|
||||||
@@ -198,6 +201,7 @@ def generate_direct_protocol(
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
num_ctx=num_ctx,
|
num_ctx=num_ctx,
|
||||||
num_predict=num_predict,
|
num_predict=num_predict,
|
||||||
|
num_thread=num_thread,
|
||||||
)
|
)
|
||||||
data = generation.raw_response
|
data = generation.raw_response
|
||||||
runtime_metadata = {
|
runtime_metadata = {
|
||||||
@@ -216,6 +220,7 @@ def generate_direct_protocol(
|
|||||||
"think": False,
|
"think": False,
|
||||||
"num_ctx": num_ctx,
|
"num_ctx": num_ctx,
|
||||||
"num_predict": num_predict,
|
"num_predict": num_predict,
|
||||||
|
"num_thread": num_thread,
|
||||||
"selected_transcript_representation": selected.representation,
|
"selected_transcript_representation": selected.representation,
|
||||||
"estimated_input_tokens": selected.estimated_input_tokens,
|
"estimated_input_tokens": selected.estimated_input_tokens,
|
||||||
"safe_input_token_budget": selected.safe_input_token_budget,
|
"safe_input_token_budget": selected.safe_input_token_budget,
|
||||||
@@ -235,6 +240,8 @@ def generate_direct_protocol(
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"speaker_mapping_count": len(context.speaker_mappings) if context else 0,
|
"speaker_mapping_count": len(context.speaker_mappings) if context else 0,
|
||||||
|
"glossary_aliases_configured": dict(sorted((glossary_aliases or {}).items())),
|
||||||
|
"glossary_replacements": [],
|
||||||
}
|
}
|
||||||
return DirectProtocolResult(
|
return DirectProtocolResult(
|
||||||
protocol_text=generation.text,
|
protocol_text=generation.text,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Immutable protocol records with one atomic latest-generation publication."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from src.meeting_lab.models.meeting_context import MeetingContext, write_meeting_context
|
||||||
|
from src.meeting_lab.protocol.generate_direct_protocol import DirectProtocolResult
|
||||||
|
|
||||||
|
_DIAGNOSTICS = (
|
||||||
|
"exact_prompt.txt",
|
||||||
|
"transcript_input.txt",
|
||||||
|
"raw_response.json",
|
||||||
|
"runtime_metadata.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_provenance(repository: Path) -> dict[str, Any]:
|
||||||
|
provenance: dict[str, Any] = {"repository": str(repository), "sha": None, "dirty": None}
|
||||||
|
try:
|
||||||
|
provenance["sha"] = subprocess.check_output(
|
||||||
|
["git", "rev-parse", "HEAD"], cwd=repository, text=True, stderr=subprocess.DEVNULL
|
||||||
|
).strip()
|
||||||
|
provenance["dirty"] = bool(
|
||||||
|
subprocess.check_output(
|
||||||
|
["git", "status", "--porcelain"],
|
||||||
|
cwd=repository,
|
||||||
|
text=True,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
return provenance
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, value: Any) -> None:
|
||||||
|
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_link(path: Path, target: str) -> None:
|
||||||
|
"""Replace a link/file atomically; never write through an existing symlink."""
|
||||||
|
temporary = path.with_name(f".{path.name}-{uuid4().hex}")
|
||||||
|
try:
|
||||||
|
temporary.symlink_to(target)
|
||||||
|
os.replace(temporary, path)
|
||||||
|
finally:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_record(directory: Path) -> None:
|
||||||
|
for path in directory.iterdir():
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
descriptor = os.open(directory, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_generation(run_dir: Path, protocol_dir: Path, generations: Path) -> None:
|
||||||
|
"""Bootstrap old regular files before switching any compatibility paths."""
|
||||||
|
if (protocol_dir / "current").is_symlink() or not (run_dir / "protocol.md").is_file():
|
||||||
|
return
|
||||||
|
# Preserve the latest regular-file state even for runs created by an older
|
||||||
|
# history implementation. Never reuse or overwrite an existing record.
|
||||||
|
index = max(
|
||||||
|
(int(p.name) for p in generations.iterdir() if p.is_dir() and p.name.isdigit()),
|
||||||
|
default=0,
|
||||||
|
) + 1
|
||||||
|
legacy = generations / f"{index:03d}"
|
||||||
|
pending = Path(tempfile.mkdtemp(prefix=".legacy-", dir=generations))
|
||||||
|
try:
|
||||||
|
shutil.copyfile(run_dir / "protocol.md", pending / "protocol.md")
|
||||||
|
for name in _DIAGNOSTICS:
|
||||||
|
source = protocol_dir / name
|
||||||
|
if source.is_file():
|
||||||
|
shutil.copyfile(source, pending / name)
|
||||||
|
context = run_dir / "context/meeting_context.yaml"
|
||||||
|
if context.is_file():
|
||||||
|
shutil.copyfile(context, pending / "meeting_context.yaml")
|
||||||
|
_sync_record(pending)
|
||||||
|
pending.rename(legacy)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(pending, ignore_errors=True)
|
||||||
|
_replace_link(protocol_dir / "current", f"generations/{legacy.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _compatibility_links(run_dir: Path, protocol_dir: Path, pending: Path) -> None:
|
||||||
|
"""Install indirections while they still resolve to the previous generation."""
|
||||||
|
links = [(run_dir / "protocol.md", "protocol/current/protocol.md")]
|
||||||
|
links.extend((protocol_dir / name, f"current/{name}") for name in _DIAGNOSTICS)
|
||||||
|
if (pending / "meeting_context.yaml").is_file():
|
||||||
|
context_dir = run_dir / "context"
|
||||||
|
context_dir.mkdir(exist_ok=True)
|
||||||
|
links.append(
|
||||||
|
(context_dir / "meeting_context.yaml", "../protocol/current/meeting_context.yaml")
|
||||||
|
)
|
||||||
|
for path, target in links:
|
||||||
|
if not path.is_symlink() or os.readlink(path) != target:
|
||||||
|
_replace_link(path, target)
|
||||||
|
|
||||||
|
|
||||||
|
def persist_protocol_generation(
|
||||||
|
run_dir: Path,
|
||||||
|
result: DirectProtocolResult,
|
||||||
|
*,
|
||||||
|
context: MeetingContext | None,
|
||||||
|
) -> Path:
|
||||||
|
"""Publish a complete record; current is the sole successful-generation pointer.
|
||||||
|
|
||||||
|
Readers needing a multi-file snapshot should resolve current once. Relative
|
||||||
|
symlinks keep complete run directories movable. Writes are serialized locally.
|
||||||
|
"""
|
||||||
|
protocol_dir = run_dir / "protocol"
|
||||||
|
protocol_dir.mkdir(exist_ok=True)
|
||||||
|
generations = protocol_dir / "generations"
|
||||||
|
generations.mkdir(exist_ok=True)
|
||||||
|
with (protocol_dir / ".publication.lock").open("a") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
_legacy_generation(run_dir, protocol_dir, generations)
|
||||||
|
index = (
|
||||||
|
max(
|
||||||
|
(int(p.name) for p in generations.iterdir() if p.is_dir() and p.name.isdigit()),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
+ 1
|
||||||
|
)
|
||||||
|
destination = generations / f"{index:03d}"
|
||||||
|
pending = Path(tempfile.mkdtemp(prefix=".pending-", dir=generations))
|
||||||
|
published = False
|
||||||
|
try:
|
||||||
|
metadata = dict(result.runtime_metadata)
|
||||||
|
metadata.update(
|
||||||
|
{
|
||||||
|
"generation_index": index,
|
||||||
|
"generation_timestamp": datetime.now(UTC).isoformat(timespec="seconds"),
|
||||||
|
"source_run_id": run_dir.name,
|
||||||
|
"speaker_mapping": dict(sorted(context.speaker_mappings.items()))
|
||||||
|
if context
|
||||||
|
else {},
|
||||||
|
"speaker_mapping_names": {
|
||||||
|
label: context.participant_for_speaker(label).get("display_name", "")
|
||||||
|
for label in sorted(context.speaker_mappings)
|
||||||
|
if context.participant_for_speaker(label) is not None
|
||||||
|
}
|
||||||
|
if context
|
||||||
|
else {},
|
||||||
|
"git": {"meeting_lab": _git_provenance(Path(__file__).resolve().parents[3])},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
(pending / "protocol.md").write_text(result.protocol_text, encoding="utf-8")
|
||||||
|
(pending / "exact_prompt.txt").write_text(result.exact_prompt, encoding="utf-8")
|
||||||
|
if result.transcript_input is not None:
|
||||||
|
(pending / "transcript_input.txt").write_text(
|
||||||
|
result.transcript_input, encoding="utf-8"
|
||||||
|
)
|
||||||
|
_write_json(pending / "raw_response.json", result.raw_response)
|
||||||
|
_write_json(pending / "runtime_metadata.json", metadata)
|
||||||
|
_write_json(pending / "model_metadata.json", result.model_metadata)
|
||||||
|
if context is not None:
|
||||||
|
write_meeting_context(context, pending / "meeting_context.yaml")
|
||||||
|
_sync_record(pending)
|
||||||
|
_compatibility_links(run_dir, protocol_dir, pending)
|
||||||
|
pending.rename(destination)
|
||||||
|
_replace_link(protocol_dir / "current", f"generations/{destination.name}")
|
||||||
|
published = True
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(pending, ignore_errors=True)
|
||||||
|
# An interrupted process may leave an unreferenced complete record;
|
||||||
|
# never delete the record selected by current, even after interruption.
|
||||||
|
if (
|
||||||
|
not published
|
||||||
|
and destination.exists()
|
||||||
|
and (protocol_dir / "current").resolve() != destination
|
||||||
|
):
|
||||||
|
shutil.rmtree(destination, ignore_errors=True)
|
||||||
|
return run_dir / "protocol.md"
|
||||||
@@ -198,6 +198,23 @@ class OllamaTests(unittest.TestCase):
|
|||||||
self.assertFalse(payload["stream"])
|
self.assertFalse(payload["stream"])
|
||||||
self.assertEqual(result.raw_response, raw)
|
self.assertEqual(result.raw_response, raw)
|
||||||
|
|
||||||
|
def test_request_omits_num_thread_when_backend_selection_is_requested(self) -> None:
|
||||||
|
response = Mock()
|
||||||
|
response.raise_for_status.return_value = None
|
||||||
|
response.json.return_value = {"response": "# Meeting Protocol"}
|
||||||
|
with patch.object(ollama.requests, "post", return_value=response) as post:
|
||||||
|
ollama.generate_once(
|
||||||
|
"http://localhost:11434",
|
||||||
|
"qwen3.8:27b",
|
||||||
|
"prompt",
|
||||||
|
timeout=30,
|
||||||
|
num_ctx=32768,
|
||||||
|
num_predict=8192,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = post.call_args.kwargs["json"]
|
||||||
|
self.assertNotIn("num_thread", payload["options"])
|
||||||
|
|
||||||
def test_malformed_response_failure_without_retry(self) -> None:
|
def test_malformed_response_failure_without_retry(self) -> None:
|
||||||
response = Mock()
|
response = Mock()
|
||||||
response.raise_for_status.return_value = None
|
response.raise_for_status.return_value = None
|
||||||
@@ -237,6 +254,7 @@ class DirectProtocolCliTests(unittest.TestCase):
|
|||||||
"transcript_input": "selected transcript\n",
|
"transcript_input": "selected transcript\n",
|
||||||
"raw_response": {"response": protocol_text},
|
"raw_response": {"response": protocol_text},
|
||||||
"runtime_metadata": {"request_count": 1},
|
"runtime_metadata": {"request_count": 1},
|
||||||
|
"model_metadata": {},
|
||||||
})(),
|
})(),
|
||||||
) as generator:
|
) as generator:
|
||||||
code, run_dir, protocol_path = run_direct_protocol.run(args)
|
code, run_dir, protocol_path = run_direct_protocol.run(args)
|
||||||
@@ -304,6 +322,8 @@ class DirectProtocolCliTests(unittest.TestCase):
|
|||||||
"exact_prompt": "prompt",
|
"exact_prompt": "prompt",
|
||||||
"raw_response": {"response": "# Meeting Protocol"},
|
"raw_response": {"response": "# Meeting Protocol"},
|
||||||
"runtime_metadata": {},
|
"runtime_metadata": {},
|
||||||
|
"model_metadata": {},
|
||||||
|
"transcript_input": None,
|
||||||
})()
|
})()
|
||||||
with (
|
with (
|
||||||
patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction,
|
patch("src.meeting_lab.extraction.extract_chunks.extract_input") as extraction,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from scripts import run_mvp_meeting as cli
|
from scripts import run_mvp_meeting as cli
|
||||||
from src.meeting_lab.audio import PreparedAudio
|
from src.meeting_lab.audio import PreparedAudio
|
||||||
|
from src.meeting_lab.llm.ollama import OllamaGeneration
|
||||||
|
from src.meeting_lab.protocol.generate_direct_protocol import generate_direct_protocol
|
||||||
from src.meeting_lab.models.meeting_context import load_meeting_context
|
from src.meeting_lab.models.meeting_context import load_meeting_context
|
||||||
from src.meeting_lab.orchestration import mvp as mvp_api
|
from src.meeting_lab.orchestration import mvp as mvp_api
|
||||||
from src.meeting_lab.orchestration.mvp import MvpMeetingConfig, MvpRunResult
|
from src.meeting_lab.orchestration.mvp import MvpMeetingConfig, MvpRunResult
|
||||||
@@ -116,6 +118,7 @@ class MvpApiTests(unittest.TestCase):
|
|||||||
patch.object(
|
patch.object(
|
||||||
mvp_api, "generate_direct_protocol", side_effect=fake_protocol
|
mvp_api, "generate_direct_protocol", side_effect=fake_protocol
|
||||||
) as protocol_generator,
|
) as protocol_generator,
|
||||||
|
patch("src.meeting_lab.protocol.history._git_provenance", return_value={}),
|
||||||
patch.object(subprocess, "run") as subprocess_run,
|
patch.object(subprocess, "run") as subprocess_run,
|
||||||
):
|
):
|
||||||
result = mvp_api.run_mvp_meeting(
|
result = mvp_api.run_mvp_meeting(
|
||||||
@@ -202,6 +205,85 @@ class MvpApiTests(unittest.TestCase):
|
|||||||
persisted = load_meeting_context(run_dir / "context/meeting_context.yaml")
|
persisted = load_meeting_context(run_dir / "context/meeting_context.yaml")
|
||||||
self.assertEqual(persisted.speaker_mappings, {"SPEAKER_00": "person-1"})
|
self.assertEqual(persisted.speaker_mappings, {"SPEAKER_00": "person-1"})
|
||||||
|
|
||||||
|
def test_regeneration_keeps_glossary_out_of_transcript_and_in_context(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
run_dir = Path(directory)
|
||||||
|
diarization_dir = run_dir / "diarization"
|
||||||
|
diarization_dir.mkdir()
|
||||||
|
source = diarization_dir / "transcript_diarized.json"
|
||||||
|
source.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"text": "SPEAKER_00: Lumini.",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 1.0,
|
||||||
|
"speaker_id": "SPEAKER_00",
|
||||||
|
"text": "Lumini.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"speaker_labels_anonymous": True,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
source_before = source.read_bytes()
|
||||||
|
transcript_dir = run_dir / "transcript"
|
||||||
|
transcript_dir.mkdir()
|
||||||
|
whisper_source = transcript_dir / "transcript.json"
|
||||||
|
whisper_source.write_text(
|
||||||
|
json.dumps({"text": "Lumini.", "segments": []}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
whisper_source_before = whisper_source.read_bytes()
|
||||||
|
context = context_data()
|
||||||
|
context["known_entities"] = {
|
||||||
|
"Authoritative terminology": ["Luminy (aliases: Lumini)"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_without_network(transcript, context_path, **kwargs):
|
||||||
|
return generate_direct_protocol(
|
||||||
|
transcript,
|
||||||
|
context_path,
|
||||||
|
model=kwargs["model"],
|
||||||
|
num_ctx=kwargs["num_ctx"],
|
||||||
|
num_thread=kwargs["num_thread"],
|
||||||
|
safe_input_token_budget=kwargs["safe_input_token_budget"],
|
||||||
|
glossary_aliases=kwargs["glossary_aliases"],
|
||||||
|
model_check=lambda *_: {},
|
||||||
|
generation_call=lambda *_args, **_kwargs: OllamaGeneration(
|
||||||
|
raw_response={"response": "# Protocol", "done": True},
|
||||||
|
text="# Protocol",
|
||||||
|
client_wall_time_seconds=0.1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
mvp_api,
|
||||||
|
"generate_direct_protocol",
|
||||||
|
side_effect=generate_without_network,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mvp_api.regenerate_mvp_protocol(
|
||||||
|
run_dir,
|
||||||
|
meeting_context=context,
|
||||||
|
glossary_aliases={"Lumini": "Luminy"},
|
||||||
|
)
|
||||||
|
|
||||||
|
generation = run_dir / "protocol"
|
||||||
|
transcript_input = (generation / "transcript_input.txt").read_text()
|
||||||
|
exact_prompt = (generation / "exact_prompt.txt").read_text()
|
||||||
|
metadata = json.loads((generation / "runtime_metadata.json").read_text())
|
||||||
|
self.assertIn("SPEAKER_00: Lumini.", transcript_input)
|
||||||
|
self.assertNotIn("SPEAKER_00: Luminy.", transcript_input)
|
||||||
|
self.assertIn("Luminy (aliases: Lumini)", exact_prompt)
|
||||||
|
self.assertIn("SPEAKER_00", exact_prompt)
|
||||||
|
self.assertIn("Test Person", exact_prompt)
|
||||||
|
self.assertEqual(metadata["glossary_replacements"], [])
|
||||||
|
self.assertEqual(source.read_bytes(), source_before)
|
||||||
|
self.assertEqual(whisper_source.read_bytes(), whisper_source_before)
|
||||||
|
|
||||||
def test_failure_emits_terminal_failure_event(self):
|
def test_failure_emits_terminal_failure_event(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Protocol thread configuration through the public pipeline boundaries."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from src.meeting_lab.llm import ollama
|
||||||
|
from src.meeting_lab.orchestration import mvp
|
||||||
|
from tests import test_mvp_api as fixtures
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolThreadTests(unittest.TestCase):
|
||||||
|
def test_initial_and_regeneration_forward_default_and_explicit_threads(self):
|
||||||
|
for threads in (None, 4, 10, 16):
|
||||||
|
with self.subTest(threads=threads), tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = replace(
|
||||||
|
fixtures.MvpApiTests().config(Path(directory)), protocol_num_thread=threads
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(mvp, "prepare_audio", side_effect=fixtures.fake_prepare),
|
||||||
|
patch.object(mvp, "transcribe_audio", side_effect=fixtures.fake_transcribe),
|
||||||
|
patch.object(
|
||||||
|
mvp, "generate_direct_protocol", side_effect=fixtures.fake_protocol
|
||||||
|
) as generate,
|
||||||
|
):
|
||||||
|
result = mvp.run_mvp_meeting(config, meeting_context=fixtures.context_data())
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
self.assertEqual(generate.call_args.kwargs["num_thread"], threads)
|
||||||
|
mvp.regenerate_mvp_protocol(
|
||||||
|
result.run_dir,
|
||||||
|
meeting_context=fixtures.context_data(),
|
||||||
|
protocol_num_thread=threads,
|
||||||
|
)
|
||||||
|
self.assertEqual(generate.call_args.kwargs["num_thread"], threads)
|
||||||
|
|
||||||
|
def test_explicit_payload_and_invalid_values(self):
|
||||||
|
response = Mock()
|
||||||
|
response.json.return_value = {"response": "protocol"}
|
||||||
|
with patch.object(ollama.requests, "post", return_value=response) as post:
|
||||||
|
ollama.generate_once(
|
||||||
|
"url", "model", "prompt", timeout=1, num_ctx=32, num_predict=8, num_thread=10
|
||||||
|
)
|
||||||
|
self.assertEqual(post.call_args.kwargs["json"]["options"]["num_thread"], 10)
|
||||||
|
post.reset_mock()
|
||||||
|
for value in (0, -1, True, 1.5):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
ollama.generate_once(
|
||||||
|
"url",
|
||||||
|
"model",
|
||||||
|
"prompt",
|
||||||
|
timeout=1,
|
||||||
|
num_ctx=32,
|
||||||
|
num_predict=8,
|
||||||
|
num_thread=value,
|
||||||
|
)
|
||||||
|
post.assert_not_called()
|
||||||
@@ -137,6 +137,43 @@ class ProtocolInputBudgetTests(unittest.TestCase):
|
|||||||
self.assertEqual(result.transcript_input, compact)
|
self.assertEqual(result.transcript_input, compact)
|
||||||
self.assertEqual(call.call_count, 1)
|
self.assertEqual(call.call_count, 1)
|
||||||
|
|
||||||
|
def test_glossary_aliases_do_not_mutate_protocol_input_or_source_artifact(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
document = {
|
||||||
|
"text": "Lumini und Carbofool.",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 1.0,
|
||||||
|
"speaker_id": "SPEAKER_00",
|
||||||
|
"text": "Lumini und Carbofool.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"speaker_labels_anonymous": True,
|
||||||
|
}
|
||||||
|
transcript = self._write(root, document)
|
||||||
|
source_before = transcript.read_bytes()
|
||||||
|
|
||||||
|
result = generate_direct_protocol(
|
||||||
|
transcript,
|
||||||
|
glossary_aliases={"Lumini": "Luminy", "Carbofool": "Carbofol"},
|
||||||
|
model_check=Mock(return_value={}),
|
||||||
|
generation_call=Mock(return_value=completed_generation()),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(transcript.read_bytes(), source_before)
|
||||||
|
|
||||||
|
self.assertIn("SPEAKER_00: Lumini und Carbofool.", result.transcript_input)
|
||||||
|
self.assertIn("SPEAKER_00: Lumini und Carbofool.", result.exact_prompt)
|
||||||
|
self.assertEqual(result.runtime_metadata["glossary_replacements"], [])
|
||||||
|
self.assertEqual(
|
||||||
|
result.runtime_metadata["glossary_aliases_configured"],
|
||||||
|
{"Carbofool": "Carbofol", "Lumini": "Luminy"},
|
||||||
|
)
|
||||||
|
|
||||||
def test_mapped_speakers_and_statements_reach_final_ollama_payload(self) -> None:
|
def test_mapped_speakers_and_statements_reach_final_ollama_payload(self) -> None:
|
||||||
diarized = {
|
diarized = {
|
||||||
"text": "",
|
"text": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user