Phase 3: extract EvidenceService and rewrite the service ownership rule

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]>
This commit is contained in:
zoltan57
2026-08-18 15:54:27 -05:00
co-authored by Copilot App
parent 11097b9cfe
commit 7dd0d2c9bf
15 changed files with 381 additions and 242 deletions
+3 -1
View File
@@ -13,6 +13,7 @@ from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.services.documents import DocumentService
from transcription.services.evidence import EvidenceService
from transcription.services.jobs import JobCancelBlockedError
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobNotFoundError
@@ -274,6 +275,7 @@ class TestJobService:
document_service: DocumentService,
):
source_service = SourceService(session_factory=job_service.session_factory)
evidence_service = EvidenceService(session_factory=job_service.session_factory)
document = await document_service.create_document(Document(name="evidence-delete-doc"))
job = await job_service.create_job(Job(document_id=document.id, status=JobStatus.FAILED))
source = await source_service.create_source(
@@ -299,7 +301,7 @@ class TestJobService:
with pytest.raises(JobNotFoundError):
await job_service.read_job(job_id=job.id)
assert await source_service.list_execution_attempts(job_id=job.id) == []
assert await evidence_service.list_execution_attempts(job_id=job.id) == []
assert (await source_service.read_source(source.id)).id == source.id
@pytest.mark.asyncio
+2 -2
View File
@@ -13,10 +13,10 @@ from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.models import Source
from transcription.services.documents import DocumentService
from transcription.services.errors import SourceDeleteBlockedError
from transcription.services.errors import TranscriptionNotFoundError
from transcription.services.jobs import JobService
from transcription.services.sources import SourceDeleteBlockedError
from transcription.services.sources import SourceService
from transcription.services.sources import TranscriptionNotFoundError
@pytest.mark.integration
+4 -2
View File
@@ -13,10 +13,11 @@ from transcription.db.models import Source
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentService
from transcription.services.errors import SourceDeleteBlockedError
from transcription.services.evidence import EvidenceService
from transcription.services.jobs import JobService
from transcription.services.people import PeopleError
from transcription.services.people import PeopleService
from transcription.services.sources import SourceDeleteBlockedError
from transcription.services.sources import SourceService
@@ -324,7 +325,8 @@ async def test_update_job_source_transcription_persists_provider_json_payloads(d
assert len(stored_rows) == 1
assert stored_rows[0].status == JobSourceStatus.TRANSCRIBED
attempt = await transcriptions.read_latest_execution_attempt(job_source_id=stored_rows[0].id)
evidence = EvidenceService(session_factory=transcriptions.session_factory)
attempt = await evidence.read_latest_execution_attempt(job_source_id=stored_rows[0].id)
assert attempt is not None
assert attempt.attempt.raw_transcription == "provider transcript"
assert attempt.attempt.normalized_metadata == metadata
+5 -12
View File
@@ -11,19 +11,12 @@ from transcription.db.models import JobPurpose
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services import ServiceBundle
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.sources import CandidatePromotionError
from transcription.services.sources import SourceService
from transcription.services.errors import CandidatePromotionError
from transcription.services.workflows import create_source_retranscription_job
def _services(default_session_factory, settings: Settings) -> ServiceBundle:
return ServiceBundle(
documents=DocumentService(session_factory=default_session_factory, settings=settings),
jobs=JobService(session_factory=default_session_factory, settings=settings),
sources=SourceService(session_factory=default_session_factory, settings=settings),
)
return ServiceBundle.from_session_factory(default_session_factory, settings=settings)
async def _seed_source(services: ServiceBundle) -> Source:
@@ -71,14 +64,14 @@ async def test_first_success_is_preferred_and_later_success_remains_candidate(de
model="model-b",
)
unchanged = await services.sources.read_source(source.id)
attempts = await services.sources.list_execution_attempts(source_id=source.id)
attempts = await services.evidence.list_execution_attempts(source_id=source.id)
assert unchanged.raw_transcription == "first result"
assert unchanged.preferred_execution_attempt_id == first_attempt_id
assert {attempt.raw_transcription for attempt in attempts} == {"first result", "candidate result"}
candidate = next(attempt for attempt in attempts if attempt.raw_transcription == "candidate result")
promoted = await services.sources.promote_machine_attempt(
promoted = await services.evidence.promote_machine_attempt(
source_id=source.id,
execution_attempt_id=candidate.id,
)
@@ -95,7 +88,7 @@ async def test_promotion_rejects_unrelated_attempt(default_session_factory):
source = await _seed_source(services)
with pytest.raises(CandidatePromotionError):
await services.sources.promote_machine_attempt(
await services.evidence.promote_machine_attempt(
source_id=source.id,
execution_attempt_id=uuid4(),
)
+2 -7
View File
@@ -109,12 +109,7 @@ class TestWorkflowReliability:
default_session_factory,
monkeypatch,
):
services = ServiceBundle(
documents=ServiceBundle().documents.__class__(session_factory=default_session_factory),
jobs=ServiceBundle().jobs.__class__(session_factory=default_session_factory),
sources=ServiceBundle().sources.__class__(session_factory=default_session_factory),
people=ServiceBundle().people.__class__(session_factory=default_session_factory),
)
services = ServiceBundle.from_session_factory(default_session_factory)
async with services.jobs._session_scope() as session:
document = Document(id=uuid4(), name="durability-doc")
session.add(document)
@@ -155,7 +150,7 @@ class TestWorkflowReliability:
task = asyncio.create_task(process_queued_job(job=loaded, services=services))
await asyncio.wait_for(second_started.wait(), timeout=2)
attempts = await services.sources.list_execution_attempts(job_id=job.id)
attempts = await services.evidence.list_execution_attempts(job_id=job.id)
assert len(attempts) == 1
assert attempts[0].raw_transcription == "page 1"
+1 -1
View File
@@ -7,8 +7,8 @@ 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 PromptLoadError
from transcription.services.sources import build_prompt_execution
from transcription.services.sources import load_prompt_text
+6 -5
View File
@@ -1,9 +1,10 @@
"""Structural rules for the services package.
`.github/instructions/services.instructions.md:13` requires that service classes
stay independent of one another. Shared behavior belongs in a neutral module
(`base.py`, `registry.py`, `source_media.py`, `media_storage.py`), and any
operation spanning two services belongs in an orchestration module.
The "Structure" section of `.github/instructions/services.instructions.md` requires
that service modules stay independent of one another. Shared behavior belongs in a
neutral module that defines no service class (`base.py`, `errors.py`, `registry.py`,
`source_media.py`, `media_storage.py`), and any operation that writes models owned by
two services belongs in an orchestration module.
"""
from __future__ import annotations
@@ -13,7 +14,7 @@ from pathlib import Path
SERVICES_DIR = Path(__file__).resolve().parents[1] / "src" / "transcription" / "services"
# Modules that intentionally compose several services rather than owning one table.
# Modules that intentionally compose several services rather than owning one aggregate.
ORCHESTRATION_MODULES = frozenset({"store", "workflows", "__init__"})
+5 -3
View File
@@ -25,6 +25,7 @@ from transcription.providers.base import TranscriptionResult
from transcription.providers.evidence import SourceEvidenceReference
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
from transcription.services.documents import DocumentService
from transcription.services.evidence import EvidenceService
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobService
from transcription.services.sources import SourceService
@@ -203,6 +204,7 @@ async def test_attempts_are_append_only_and_exported_with_integrity(default_sess
documents = DocumentService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
sources = SourceService(session_factory=default_session_factory)
evidence = EvidenceService(session_factory=default_session_factory)
document = await documents.create_document(Document(name="Evidence"))
job = await jobs.create_job(Job(document_id=document.id))
source = await sources.create_source(
@@ -239,14 +241,14 @@ async def test_attempts_are_append_only_and_exported_with_integrity(default_sess
finished_at=now,
)
attempts = await sources.list_execution_attempts(source_id=source.id)
attempts = await evidence.list_execution_attempts(source_id=source.id)
assert [attempt.attempt_number for attempt in attempts] == [1, 2]
assert attempts[0].status == JobSourceStatus.FAILED
assert attempts[0].error_detail == "first failed"
assert attempts[1].status == JobSourceStatus.TRANSCRIBED
assert attempts[1].raw_transcription == "second succeeded"
export = await sources.build_evidence_export(source_id=source.id)
export = await evidence.build_evidence_export(source_id=source.id)
assert _json_object(export["source"])["digest_sha256"] == "a" * 64
assert [_json_object(item)["attempt_number"] for item in _json_array(export["attempts"])] == [1, 2]
assert "file_path" not in json.dumps(export)
@@ -257,7 +259,7 @@ async def test_attempts_are_append_only_and_exported_with_integrity(default_sess
latest_job_source = detail.latest_job_source
assert latest_job_source is not None
assert latest_job_source.execution_attempts == []
latest_attempt = await sources.read_latest_execution_attempt(job_source_id=latest_job_source.id)
latest_attempt = await evidence.read_latest_execution_attempt(job_source_id=latest_job_source.id)
assert latest_attempt is not None
assert latest_attempt.attempt.attempt_number == 2