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]>
334 lines
13 KiB
Python
334 lines
13 KiB
Python
from uuid import uuid4
|
|
|
|
import pytest
|
|
from pydantic import JsonValue
|
|
|
|
from transcription.db.models import Document
|
|
from transcription.db.models import DocumentPerson
|
|
from transcription.db.models import Job
|
|
from transcription.db.models import JobSource
|
|
from transcription.db.models import JobSourceStatus
|
|
from transcription.db.models import Person
|
|
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 SourceService
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_people_service_handles_person_and_document_person_crud(default_session_factory):
|
|
documents = DocumentService(session_factory=default_session_factory)
|
|
people_service = PeopleService(session_factory=default_session_factory)
|
|
|
|
document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
|
|
person = await people_service.create_person(Person(full_name="Ada Lovelace"))
|
|
author_role = await people_service.create_person_role(label="Author")
|
|
recipient_role = await people_service.create_person_role(label="Recipient")
|
|
|
|
assert document.document_type_id is None
|
|
|
|
link = await people_service.create_document_person(
|
|
DocumentPerson(document_id=document.id, person_id=person.id, role_id=author_role.id)
|
|
)
|
|
|
|
fetched = await people_service.read_document_person(link.id)
|
|
assert fetched.id == link.id
|
|
assert fetched.role_id == author_role.id
|
|
|
|
updated_link = await people_service.update_document_person(
|
|
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role_id=recipient_role.id)
|
|
)
|
|
assert updated_link.role_id == recipient_role.id
|
|
|
|
listed = await people_service.list_document_people(document_id=document.id)
|
|
assert len(listed) == 1
|
|
|
|
people = await people_service.list_people()
|
|
assert len(people) == 1
|
|
|
|
await people_service.delete_document_person(updated_link)
|
|
assert len(await people_service.list_document_people(document_id=document.id)) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory):
|
|
people_service = PeopleService(session_factory=default_session_factory)
|
|
|
|
created = await people_service.create_person(Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq "))
|
|
assert created.family_search_id == "G8T4-MDQ"
|
|
|
|
with pytest.raises(PeopleError) as duplicate:
|
|
await people_service.create_person(Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ"))
|
|
assert duplicate.value.category == ErrorCategory.CONFLICT
|
|
|
|
with pytest.raises(PeopleError) as malformed:
|
|
await people_service.create_person(Person(full_name="Malformed", family_search_id="not-an-id"))
|
|
assert malformed.value.category == ErrorCategory.VALIDATION
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcription_service_manages_source_crud(default_session_factory):
|
|
documents = DocumentService(session_factory=default_session_factory)
|
|
transcriptions = SourceService(session_factory=default_session_factory)
|
|
|
|
document = await documents.create_document(Document(id=uuid4(), name="source-doc"))
|
|
source = await transcriptions.create_source(
|
|
Source(
|
|
document_id=document.id,
|
|
page_number=1,
|
|
upload_name="page-1.jpg",
|
|
filename="page-1.jpg",
|
|
file_path="uploads/page-1.jpg",
|
|
file_hash="7" * 64,
|
|
file_size_bytes=1,
|
|
)
|
|
)
|
|
|
|
fetched = await transcriptions.read_source(source.id)
|
|
assert fetched.id == source.id
|
|
|
|
source.page_number = 2
|
|
updated = await transcriptions.update_source(source)
|
|
assert updated.page_number == 2
|
|
|
|
listed = await transcriptions.list_sources(document_id=document.id)
|
|
assert len(listed) == 1
|
|
|
|
filtered = await transcriptions.query_sources(document_id=document.id, page_number=2)
|
|
assert len(filtered) == 1
|
|
|
|
await transcriptions.delete_source(updated)
|
|
assert len(await transcriptions.list_sources(document_id=document.id)) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_navigation_is_bounded_to_ordered_document(default_session_factory):
|
|
documents = DocumentService(session_factory=default_session_factory)
|
|
sources = SourceService(session_factory=default_session_factory)
|
|
document = await documents.create_document(Document(id=uuid4(), name="ordered"))
|
|
other = await documents.create_document(Document(id=uuid4(), name="other"))
|
|
|
|
first, second, third, _foreign = [
|
|
await sources.create_source(
|
|
Source(
|
|
document_id=document_id,
|
|
page_number=page_number,
|
|
upload_name=f"page-{page_number}.jpg",
|
|
filename=f"page-{page_number}.jpg",
|
|
file_path=f"uploads/page-{page_number}.jpg",
|
|
file_hash=str(page_number) * 64,
|
|
file_size_bytes=1,
|
|
)
|
|
)
|
|
for document_id, page_number in [
|
|
(document.id, 1),
|
|
(document.id, 2),
|
|
(document.id, 3),
|
|
(other.id, 2),
|
|
]
|
|
]
|
|
|
|
first_navigation = await sources.read_source_navigation(first.id)
|
|
middle_navigation = await sources.read_source_navigation(second.id)
|
|
last_navigation = await sources.read_source_navigation(third.id)
|
|
|
|
assert (first_navigation.previous_id, first_navigation.next_id) == (None, second.id)
|
|
assert (middle_navigation.previous_id, middle_navigation.next_id) == (first.id, third.id)
|
|
assert (last_navigation.previous_id, last_navigation.next_id) == (second.id, None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transcription_service_job_source_crud_uses_caller_session(default_session_factory):
|
|
transcriptions = SourceService(session_factory=default_session_factory)
|
|
|
|
async with transcriptions._session_scope() as session:
|
|
document = Document(id=uuid4(), name="job-source-doc")
|
|
session.add(document)
|
|
await session.flush()
|
|
|
|
job = Job(document_id=document.id)
|
|
session.add(job)
|
|
await session.flush()
|
|
|
|
source = Source(
|
|
document_id=document.id,
|
|
page_number=1,
|
|
upload_name="job-source.jpg",
|
|
filename="job-source.jpg",
|
|
file_path="uploads/job-source.jpg",
|
|
file_hash="8" * 64,
|
|
file_size_bytes=1,
|
|
)
|
|
session.add(source)
|
|
await session.flush()
|
|
|
|
job_source = await transcriptions.create_job_source(
|
|
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING),
|
|
session=session,
|
|
)
|
|
assert job_source.status == JobSourceStatus.PENDING
|
|
|
|
job_source.status = JobSourceStatus.TRANSCRIBED
|
|
updated = await transcriptions.update_job_source(job_source, session=session)
|
|
assert updated.status == JobSourceStatus.TRANSCRIBED
|
|
|
|
fetched = await transcriptions.read_job_source(job_source.id, session=session)
|
|
assert fetched.id == job_source.id
|
|
|
|
listed = await transcriptions.list_job_sources(job_id=job.id, session=session)
|
|
assert len(listed) == 1
|
|
|
|
await transcriptions.delete_job_source(updated, session=session)
|
|
await session.commit()
|
|
|
|
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_document_detail_loads_linked_person_relationship(default_session_factory):
|
|
documents = DocumentService(session_factory=default_session_factory)
|
|
people_service = PeopleService(session_factory=default_session_factory)
|
|
|
|
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
|
|
person = await people_service.create_person(Person(full_name="Grace Hopper"))
|
|
author_role = await people_service.create_person_role(label="Author")
|
|
await people_service.create_document_person(
|
|
DocumentPerson(
|
|
document_id=document.id,
|
|
person_id=person.id,
|
|
role_id=author_role.id,
|
|
)
|
|
)
|
|
|
|
detail = await documents.read_document_detail(document.id)
|
|
|
|
assert len(detail.document_people) == 1
|
|
link = detail.document_people[0]
|
|
assert link.person is not None
|
|
assert link.person.full_name == "Grace Hopper"
|
|
assert link.role_id == author_role.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_document_delete_is_blocked_with_source_and_job_dependencies(default_session_factory):
|
|
documents = DocumentService(session_factory=default_session_factory)
|
|
jobs = JobService(session_factory=default_session_factory)
|
|
transcriptions = SourceService(session_factory=default_session_factory)
|
|
|
|
document = await documents.create_document(Document(id=uuid4(), name="blocked-by-deps"))
|
|
job = await jobs.create_job(Job(document_id=document.id))
|
|
source = await transcriptions.create_source(
|
|
Source(
|
|
document_id=document.id,
|
|
page_number=1,
|
|
upload_name="blocked.jpg",
|
|
filename="blocked.jpg",
|
|
file_path="uploads/blocked.jpg",
|
|
file_hash="9" * 64,
|
|
file_size_bytes=1,
|
|
)
|
|
)
|
|
await transcriptions.create_job_source(
|
|
JobSource(
|
|
job_id=job.id,
|
|
source_id=source.id,
|
|
status=JobSourceStatus.PENDING,
|
|
)
|
|
)
|
|
|
|
with pytest.raises(DocumentDeleteBlockedError) as exc_info:
|
|
await documents.delete_document(document)
|
|
|
|
message = exc_info.value.message
|
|
assert "Sources" in message
|
|
assert "Jobs" in message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_delete_blocks_when_linked_to_multiple_jobs(default_session_factory):
|
|
documents = DocumentService(session_factory=default_session_factory)
|
|
jobs = JobService(session_factory=default_session_factory)
|
|
transcriptions = SourceService(session_factory=default_session_factory)
|
|
|
|
document = await documents.create_document(Document(id=uuid4(), name="multi-job-source-doc"))
|
|
job_one = await jobs.create_job(Job(document_id=document.id))
|
|
job_two = await jobs.create_job(Job(document_id=document.id))
|
|
|
|
source = await transcriptions.create_source(
|
|
Source(
|
|
document_id=document.id,
|
|
page_number=1,
|
|
upload_name="shared-page.jpg",
|
|
filename="shared-page.jpg",
|
|
file_path="uploads/shared-page.jpg",
|
|
file_hash="a" * 64,
|
|
file_size_bytes=1,
|
|
)
|
|
)
|
|
await transcriptions.create_job_source(
|
|
JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING)
|
|
)
|
|
await transcriptions.create_job_source(
|
|
JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING)
|
|
)
|
|
|
|
with pytest.raises(SourceDeleteBlockedError):
|
|
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_job_source_transcription_persists_provider_json_payloads(default_session_factory):
|
|
documents = DocumentService(session_factory=default_session_factory)
|
|
jobs = JobService(session_factory=default_session_factory)
|
|
transcriptions = SourceService(session_factory=default_session_factory)
|
|
|
|
document = await documents.create_document(Document(id=uuid4(), name="provider-payloads-doc"))
|
|
job = await jobs.create_job(Job(document_id=document.id))
|
|
source = await transcriptions.create_source(
|
|
Source(
|
|
document_id=document.id,
|
|
page_number=1,
|
|
upload_name="provider.jpg",
|
|
filename="provider.jpg",
|
|
file_path="uploads/provider.jpg",
|
|
file_hash="b" * 64,
|
|
file_size_bytes=1,
|
|
)
|
|
)
|
|
await transcriptions.create_job_source(
|
|
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING)
|
|
)
|
|
|
|
metadata: dict[str, JsonValue] = {
|
|
"finish_reason": "stop",
|
|
"usage": {"input_tokens": 11, "output_tokens": 22, "total_tokens": 33},
|
|
}
|
|
raw_payload: dict[str, JsonValue] = {"id": "resp_xyz", "choices": [{"message": {"content": "provider transcript"}}]}
|
|
|
|
await transcriptions.update_job_source_transcription(
|
|
job_id=job.id,
|
|
source_id=source.id,
|
|
text="provider transcript",
|
|
ai_metadata=metadata,
|
|
raw_api_response=raw_payload,
|
|
provider="openrouter",
|
|
model="test-model",
|
|
)
|
|
|
|
stored_rows = await transcriptions.list_job_sources(job_id=job.id)
|
|
assert len(stored_rows) == 1
|
|
assert stored_rows[0].status == JobSourceStatus.TRANSCRIBED
|
|
|
|
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
|
|
assert attempt.attempt.sdk_response_snapshot == raw_payload
|