V4.4 Complete

This commit is contained in:
Jim Lancaster
2026-08-15 14:30:33 -05:00
parent 63373bf24d
commit 7db4df1729
32 changed files with 1529 additions and 716 deletions
+13 -20
View File
@@ -5,15 +5,12 @@ from datetime import datetime
from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
@@ -132,11 +129,12 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
)
)
person = await people_service.create_person(Person(full_name="Linked Person"))
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=DocumentPersonRole.AUTHOR,
role_id=author_role.id,
)
)
@@ -144,7 +142,7 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
assert len(links_before_delete) == 1
assert links_before_delete[0].role_id is not None
assert links_before_delete[0].role_ref is not None
assert links_before_delete[0].role_ref.code == "author"
assert links_before_delete[0].role_ref.label == "Author"
document_dir = service.settings.upload_dir / "documents" / str(document.id)
document_dir.mkdir(parents=True, exist_ok=True)
@@ -196,11 +194,12 @@ async def test_read_person_detail_loads_document_links(default_session_factory):
)
)
person = await people_service.create_person(Person(full_name="Linked Person"))
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=DocumentPersonRole.AUTHOR,
role_id=author_role.id,
)
)
@@ -243,11 +242,12 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s
)
)
person = await service.create_person(Person(full_name="Blocked Person"))
author_role = await service.create_person_role(label="Author")
await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=author_role.id,
)
)
@@ -285,19 +285,19 @@ async def test_create_document_uses_existing_document_type_registry(default_sess
@pytest.mark.asyncio
async def test_update_document_person_sets_role_id_from_legacy_role(default_session_factory):
async def test_update_document_person_changes_role_id(default_session_factory):
documents_service = DocumentService(session_factory=default_session_factory)
service = PeopleService(session_factory=default_session_factory)
document = await documents_service.create_document(
Document(id=uuid4(), name="role-sync-doc", document_type="letter")
)
document = await documents_service.create_document(Document(id=uuid4(), name="role-sync-doc"))
person = await service.create_person(Person(full_name="Role Sync Person"))
author_role = await service.create_person_role(label="Author")
recipient_role = await service.create_person_role(label="Recipient")
link = await service.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.AUTHOR,
role_id=author_role.id,
)
)
@@ -306,15 +306,8 @@ async def test_update_document_person_sets_role_id_from_legacy_role(default_sess
id=link.id,
document_id=document.id,
person_id=person.id,
role=DocumentPersonRole.RECIPIENT,
role_id=None,
role_id=recipient_role.id,
)
)
assert updated.role == DocumentPersonRole.RECIPIENT
assert updated.role_id is not None
async with service._session_scope() as session:
recipient_role = (await session.exec(select(PersonRole).where(PersonRole.code == "recipient"))).first()
assert recipient_role is not None
assert updated.role_id == recipient_role.id
+24 -28
View File
@@ -6,8 +6,8 @@ import pytest
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.errors import ErrorCategory
from transcription.services.documents import DocumentService
from transcription.services.documents import DocumentTypeError
@@ -73,24 +73,27 @@ async def test_document_type_delete_allows_unreferenced_and_blocks_referenced(de
@pytest.mark.asyncio
async def test_person_role_maintenance_orders_by_label_then_code(default_session_factory):
async def test_person_role_maintenance_orders_by_normalized_label(default_session_factory):
service = PeopleService(session_factory=default_session_factory)
second = await service.create_person_role(code="witness", label="Witness")
first = await service.create_person_role(code="author", label="Author")
second = await service.create_person_role(label="Witness")
first = await service.create_person_role(label="Archivist")
updated = await service.update_person_role(second.id, label="Attestor", is_active=False)
assert updated.code == "witness"
assert [item.id for item in await service.list_person_roles(active_only=False)] == [second.id, first.id]
assert updated.semantic_key is None
assert [item.id for item in await service.list_person_roles(active_only=False)] == [first.id, second.id]
assert [item.id for item in await service.list_person_roles()] == [first.id]
summaries = {item.id: item for item in await service.list_person_role_summaries()}
assert summaries[second.id].link_count == 0
assert summaries[second.id].is_built_in is False
@pytest.mark.asyncio
async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=default_session_factory)
unused = await people.create_person_role(code="witness", label="Witness")
referenced = await people.create_person_role(code="author", label="Author")
unused = await people.create_person_role(label="Witness")
referenced = await people.create_person_role(label="Creator")
document = await documents.create_document(Document(name="Role document"))
person = await people.create_person(Person(full_name="Role Person"))
await people.create_document_person(
@@ -98,7 +101,6 @@ async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(defa
document_id=document.id,
person_id=person.id,
role_id=referenced.id,
role=DocumentPersonRole.AUTHOR,
)
)
@@ -113,12 +115,12 @@ async def test_person_role_delete_allows_unreferenced_and_blocks_referenced(defa
@pytest.mark.asyncio
async def test_person_role_duplicate_code_is_conflict(default_session_factory):
async def test_person_role_duplicate_normalized_label_is_conflict(default_session_factory):
service = PeopleService(session_factory=default_session_factory)
await service.create_person_role(code="author", label="Author")
await service.create_person_role(label="Witness")
with pytest.raises(PersonRoleError) as caught:
await service.create_person_role(code=" AUTHOR ", label="Duplicate")
await service.create_person_role(label=" witness ")
assert caught.value.category == ErrorCategory.CONFLICT
@@ -127,7 +129,7 @@ async def test_person_role_duplicate_code_is_conflict(default_session_factory):
async def test_custom_person_role_can_be_used_for_document_link(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=default_session_factory)
role = await people.create_person_role(code="witness", label="Witness")
role = await people.create_person_role(label="Witness")
document = await documents.create_document(Document(name="Witnessed document"))
person = await people.create_person(Person(full_name="Archive Witness"))
@@ -138,30 +140,24 @@ async def test_custom_person_role_can_be_used_for_document_link(default_session_
)
loaded = await people.list_document_people(document_id=document.id)
assert link.role == "witness"
assert link.role_id == role.id
assert loaded[0].role_ref is not None
assert loaded[0].role_ref.code == "witness"
assert loaded[0].role_ref.label == "Witness"
@pytest.mark.asyncio
async def test_custom_person_role_delete_blocks_legacy_only_reference(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
async def test_built_in_person_role_cannot_be_deleted(default_session_factory):
people = PeopleService(session_factory=default_session_factory)
role = await people.create_person_role(code="witness", label="Witness")
document = await documents.create_document(Document(name="Legacy role document"))
person = await people.create_person(Person(full_name="Legacy Witness"))
async with people._session_scope() as session:
session.add(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role="witness",
role_id=None,
)
role = PersonRole(
semantic_key="author",
label="Author",
normalized_label="author",
)
session.add(role)
await session.commit()
await session.refresh(role)
assert await people.is_person_role_referenced(role.id) is True
with pytest.raises(PersonRoleError) as caught:
await people.delete_person_role(role.id)
+12 -18
View File
@@ -4,7 +4,6 @@ import pytest
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
@@ -27,23 +26,23 @@ async def test_people_service_handles_person_and_document_person_crud(default_se
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=DocumentPersonRole.AUTHOR)
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 == DocumentPersonRole.AUTHOR
assert fetched.role_id is not None
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=DocumentPersonRole.RECIPIENT)
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role_id=recipient_role.id)
)
assert updated_link.role == DocumentPersonRole.RECIPIENT
assert updated_link.role_id is not None
assert updated_link.role_id == recipient_role.id
listed = await people_service.list_document_people(document_id=document.id)
assert len(listed) == 1
@@ -59,21 +58,15 @@ async def test_people_service_handles_person_and_document_person_crud(default_se
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 ")
)
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")
)
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")
)
await people_service.create_person(Person(full_name="Malformed", family_search_id="not-an-id"))
assert malformed.value.category == ErrorCategory.VALIDATION
@@ -202,11 +195,12 @@ async def test_document_detail_loads_linked_person_relationship(default_session_
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=DocumentPersonRole.AUTHOR,
role_id=author_role.id,
)
)
@@ -216,7 +210,7 @@ async def test_document_detail_loads_linked_person_relationship(default_session_
link = detail.document_people[0]
assert link.person is not None
assert link.person.full_name == "Grace Hopper"
assert link.role == DocumentPersonRole.AUTHOR
assert link.role_id == author_role.id
@pytest.mark.asyncio
+166
View File
@@ -0,0 +1,166 @@
from datetime import UTC
from datetime import datetime
from uuid import uuid4
import pytest
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import Job
from transcription.db.models import JobStatus
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.db.models import Source
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.people import DocumentPersonInput
from transcription.services.people import PeopleError
from transcription.services.people import PeopleService
from transcription.services.sources import SourceService
from transcription.services.workflows import create_document_with_people
from transcription.services.workflows import update_document_with_people
@pytest.mark.asyncio
async def test_create_document_with_people_rolls_back_on_invalid_person(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=default_session_factory)
role = await people.create_person_role(label="Witness")
with pytest.raises(PeopleError):
await create_document_with_people(
document=Document(name="Must roll back"),
links=[DocumentPersonInput(person_id=uuid4(), role_id=role.id)],
documents=documents,
people=people,
)
assert await documents.query_documents(name="Must roll back") == []
@pytest.mark.asyncio
async def test_update_document_with_people_rolls_back_document_and_links(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=default_session_factory)
role = await people.create_person_role(label="Witness")
inactive = await people.create_person_role(label="Former Witness", is_active=False)
person = await people.create_person(Person(full_name="Archive Witness"))
document = await create_document_with_people(
document=Document(name="Original name"),
links=[DocumentPersonInput(person_id=person.id, role_id=role.id)],
documents=documents,
people=people,
)
assert [item.name for item in await documents.list_documents()] == ["Original name"]
candidate = Document(
id=document.id,
name="Changed name",
created_at=document.created_at,
updated_at=document.updated_at,
)
with pytest.raises(PeopleError):
await update_document_with_people(
document=candidate,
links=[DocumentPersonInput(person_id=person.id, role_id=inactive.id)],
documents=documents,
people=people,
)
persisted_documents = await documents.list_documents()
links = await people.list_document_people(document_id=document.id)
assert [item.name for item in persisted_documents] == ["Original name"]
assert len(links) == 1
assert links[0].role_id == role.id
@pytest.mark.asyncio
async def test_direct_link_writes_reject_new_inactive_role_assignments(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=default_session_factory)
active = await people.create_person_role(label="Witness")
inactive = await people.create_person_role(label="Former Witness", is_active=False)
person = await people.create_person(Person(full_name="Archive Witness"))
document = await documents.create_document(Document(name="Role rules"))
link = await people.add_document_person_link(
document_id=document.id,
person_id=person.id,
role_id=active.id,
)
with pytest.raises(PeopleError, match="Inactive Person Role"):
await people.set_document_person_role(
document_person_id=link.id,
role_id=inactive.id,
)
unchanged = await people.set_document_person_role(
document_person_id=link.id,
role_id=active.id,
)
assert unchanged.role_id == active.id
@pytest.mark.asyncio
async def test_document_print_projection_uses_semantic_author_and_current_text(default_session_factory):
documents = DocumentService(session_factory=default_session_factory)
people = PeopleService(session_factory=default_session_factory)
sources = SourceService(session_factory=default_session_factory)
jobs = JobService(session_factory=default_session_factory)
document = await documents.create_document(Document(name="Print Me", notes="Archive note"))
person = await people.create_person(Person(full_name="Historic Author"))
async with people._session_scope() as session:
author = PersonRole(
semantic_key="author",
label="Creator",
normalized_label="creator",
)
session.add(author)
await session.flush()
session.add(DocumentPerson(document_id=document.id, person_id=person.id, role_id=author.id))
await session.commit()
await sources.create_source(
Source(
document_id=document.id,
page_number=2,
upload_name="page-2.png",
filename="page-2.png",
file_path="managed/page-2.png",
file_hash="2" * 64,
file_size_bytes=2,
raw_transcription="raw second",
revised_text="revised second",
)
)
await sources.create_source(
Source(
document_id=document.id,
page_number=1,
upload_name="page-1.png",
filename="page-1.png",
file_path="managed/page-1.png",
file_hash="1" * 64,
file_size_bytes=1,
raw_transcription="raw first",
)
)
await jobs.create_job(
Job(
document_id=document.id,
status=JobStatus.COMPLETED,
provider="openrouter",
model="model-a",
prompt_name="transcribe_document.md",
date_created=datetime(2026, 1, 1, tzinfo=UTC),
)
)
projection = await documents.read_document_print_projection(document.id)
assert projection.authors == ("Historic Author",)
assert [source.page_number for source in projection.sources] == [1, 2]
assert [source.current_text for source in projection.sources] == ["raw first", "revised second"]
assert [source.media_type for source in projection.sources] == ["image/png", "image/png"]
assert projection.jobs[0].status == "completed"