generated from john/python-template
Decompose SourceService along the aggregate boundary and then correct the
instruction file that caused it to grow, in that order. The refactor is the
empirical test of the rule.
services/evidence.py (new)
EvidenceService owns ExecutionAttempt: read_latest_execution_attempt,
list_execution_attempts, promote_machine_attempt, build_evidence_export,
plus the LatestExecutionAttempt projection. Moved verbatim from sources.py.
services/errors.py (new)
The five-class error hierarchy (PromptLoadError, TranscriptionError,
TranscriptionNotFoundError, SourceDeleteBlockedError,
CandidatePromotionError) moved out of sources.py. evidence.py needs
TranscriptionNotFoundError, and test_service_boundaries.py correctly
rejected the sibling import. errors.py defines no *Service class, so it is
a legal shared home. This was the boundary test doing its job, not an
obstacle to route around.
sources.py 1,389 -> 885 lines (1,063 after Phase 2).
services/__init__.py
ServiceBundle and from_session_factory register evidence. Note that
field-by-field ServiceBundle construction silently binds services to the
process-global session factory via default_factory; from_session_factory is
the only safe constructor. Two test bundles were fixed for this.
.github/instructions/services.instructions.md
Rewritten to describe the boundaries the decomposition actually produced,
per plan Phase 3 task 7 and review log [59].
- "1 service class per data model" -> one service class per aggregate.
The table-shaped rule is the measured cause of sources.py reaching
1,389 lines; DocumentType has no lifecycle without Document.
- New Model Ownership section. Junctions are owned by their lifecycle
owner, the service that creates and deletes the rows: document_person
to PeopleService (sole writer, measured), job_source to SourceService.
Two carve-outs are stated rather than left as silent violations:
cascade deletion when a service deletes its own aggregate root, and
status transitions that create and delete nothing (cancel_job,
resubmit_failed_sources), which are Job lifecycle events on the work
queue. EvidenceService.promote_machine_attempt's two-field write to
Source is named and scoped.
- Mandatory CRUD softened to intent. It was already false: five modules
define no service class, EvidenceService has no create/delete because
ExecutionAttempt is append-only, RegistryService uses <op>_entry.
- Separated reading across models via eager loads from the owning root,
which is allowed, from importing another service, which is not. The old
line 13 and lines 75-77 read as contradictory.
- Typo: picutre.
No code was moved to satisfy the rule.
tests/test_service_boundaries.py
Docstring no longer cites the instruction file by line number; that anchor
would desynchronise silently. errors.py added to the neutral-module list.
Verified: 292 passed, 4 skipped, 0 ruff, 0 ty. All 25 /ui/* routes walked
against the live app; 24x 200. /ui/documents/{id}/sources 404s via a 307 that
drops the /ui prefix, confirmed pre-existing (last touched in 6a3ee26) and
left alone as out of scope.
Co-authored-by: Copilot App <[email protected]>
111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
"""Tests for prompt artifacts in prompts/."""
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from transcription.config import Settings
|
|
from transcription.services.errors import PromptLoadError
|
|
from transcription.services.sources import PromptExecution
|
|
from transcription.services.sources import build_prompt_execution
|
|
from transcription.services.sources import load_prompt_text
|
|
|
|
PROMPT_PATH = Path("prompts/transcribe_document.md")
|
|
|
|
|
|
def _prompt_text() -> str:
|
|
"""Read prompt text from the canonical prompt file."""
|
|
return PROMPT_PATH.read_text(encoding="utf-8")
|
|
|
|
|
|
class TestPromptArtifact:
|
|
"""Verify prompt artifact presence and baseline semantics."""
|
|
|
|
def test_prompt_file_exists(self):
|
|
"""Canonical transcription prompt file exists."""
|
|
assert PROMPT_PATH.exists()
|
|
|
|
def test_prompt_file_is_not_empty(self):
|
|
"""Canonical prompt file has non-whitespace content."""
|
|
text = _prompt_text()
|
|
assert text.strip()
|
|
|
|
def test_prompt_mentions_verbatim_behavior(self):
|
|
"""Prompt explicitly enforces verbatim transcription behavior."""
|
|
text = _prompt_text().lower()
|
|
assert "verbatim" in text
|
|
assert "do not summarize" in text
|
|
|
|
def test_prompt_includes_uncertainty_and_illegible_markers(self):
|
|
"""Prompt contains conventions for uncertainty and illegible text."""
|
|
text = _prompt_text().lower()
|
|
assert "[boston?]" in text
|
|
assert "[illegible]" in text
|
|
|
|
def test_prompt_includes_deleted_and_inserted_conventions(self):
|
|
"""Prompt contains conventions for deleted and inserted text."""
|
|
text = _prompt_text().lower()
|
|
assert "[deleted:" in text
|
|
assert "[inserted:" in text
|
|
|
|
def test_prompt_defines_exactly_one_body_medium_marker(self):
|
|
text = _prompt_text().lower()
|
|
for marker in (
|
|
"[document body handwritten]",
|
|
"[document body typewritten]",
|
|
"[document body typeset]",
|
|
"[document body mixed]",
|
|
):
|
|
assert marker in text
|
|
assert "exactly one" in text
|
|
assert "typewriter defects are not handwriting" in text
|
|
|
|
def test_prompt_preserves_structured_layout_associations(self):
|
|
text = _prompt_text().lower()
|
|
for phrase in ("tables of contents", "dotted-leader", "page-reference", "tables and forms", "columns"):
|
|
assert phrase in text
|
|
|
|
|
|
class TestPromptConfiguration:
|
|
def test_builds_validated_immutable_prompt_provenance(self, tmp_path):
|
|
prompt_text = "Transcribe this document verbatim."
|
|
(tmp_path / "custom.md").write_text(prompt_text, encoding="utf-8")
|
|
settings = Settings(
|
|
openrouter_api_key="test-key",
|
|
prompt_dir=tmp_path,
|
|
default_prompt_name="custom.md",
|
|
transcription_temperature=0.2,
|
|
transcription_top_p=0.9,
|
|
)
|
|
|
|
execution = build_prompt_execution(settings=settings)
|
|
|
|
assert execution.prompt_hash == hashlib.sha256(prompt_text.encode()).hexdigest()
|
|
assert execution.temperature == 0.2
|
|
assert execution.top_p == 0.9
|
|
with pytest.raises(ValidationError):
|
|
execution.prompt_name = "changed.md" # ty: ignore[invalid-assignment]
|
|
|
|
def test_rejects_prompt_path_traversal_even_with_direct_loader_call(self, tmp_path):
|
|
outside_prompt = tmp_path / "outside.md"
|
|
prompt_dir = tmp_path / "prompts"
|
|
prompt_dir.mkdir()
|
|
outside_prompt.write_text("secret", encoding="utf-8")
|
|
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
|
|
|
with pytest.raises(PromptLoadError):
|
|
load_prompt_text(prompt_name="../outside.md", settings=settings)
|
|
|
|
def test_prompt_execution_rejects_invalid_provenance_hash(self):
|
|
with pytest.raises(ValidationError):
|
|
PromptExecution(
|
|
prompt_name="prompt.md",
|
|
prompt_hash="not-a-sha256",
|
|
system_prompt=None,
|
|
user_prompt="text",
|
|
temperature=None,
|
|
top_p=None,
|
|
)
|