V4.3 revision to Document Types

This commit is contained in:
Jim Lancaster
2026-08-15 13:29:53 -05:00
parent aed827babe
commit a78b58ff40
30 changed files with 1481 additions and 291 deletions
+6 -6
View File
@@ -27,7 +27,7 @@ Deliver constrained, installation-local application settings while preserving th
### 1. Define Service Contracts
- Define Document Type maintenance commands for create, relabel, sort, activate, deactivate, and delete-if-unreferenced.
- Define Document Type maintenance commands for create, relabel, activate, deactivate, and delete-if-unreferenced.
- Define Person Role maintenance commands for create, relabel, activate, deactivate, and delete-if-unreferenced.
- Define a Prompt Store interface for constrained list, read, write, backup-status, and explicit recovery behavior.
- Map validation, conflict, not-found, dependency, and filesystem failures to existing `AppError` categories.
@@ -36,13 +36,13 @@ Deliver constrained, installation-local application settings while preserving th
- Reuse existing Document and People service ownership.
- Add explicit write methods rather than passing UI-mutated ORM objects directly where practical.
- Normalize and validate new stable codes.
- Reject duplicate codes deterministically.
- Normalize Document Type labels and reject case-insensitive duplicates deterministically.
- Keep Person Role stable-code validation and duplicate rejection.
- Permit deletion only after a service-owned reference check proves the entry is unreferenced.
- Reject deletion of referenced entries deterministically without partial mutation.
- Permit label changes whether or not an entry is referenced.
- Preserve inactive entries for historical reads.
- Maintain existing Document Type sort order.
- Order Document Types alphabetically by normalized label.
- Order Person Roles deterministically by label and then code without adding a schema field.
- Add service tests for create, relabel, activation, deactivation, duplicates, immutable codes, ordering, allowed deletion, and blocked referenced deletion.
@@ -65,7 +65,7 @@ Deliver constrained, installation-local application settings while preserving th
- Add separate pages or panels for Document Types, Person Roles, and Prompts.
- Keep pages responsible for orchestration and notifications only.
- Use service callbacks for all mutations.
- Explain stable codes, inactive historical entries, and future-only prompt effects in the UI.
- Explain inactive historical entries and future-only prompt effects in the UI.
- Present deletion only for unreferenced registry entries and preserve clear conflict feedback if references appear before submission.
- Present prompt backup availability and recovery as an explicit operator action.
- Do not render raw environment values or secrets.
@@ -102,7 +102,7 @@ Deliver constrained, installation-local application settings while preserving th
- All V4.3 acceptance criteria are testable and satisfied.
- Settings mutations cross explicit service or adapter boundaries.
- Registry codes cannot be accidentally changed.
- Document Types use UUID-only identity and unique labels; Person Role codes cannot be accidentally changed.
- Referenced registry entries can be relabeled or deactivated but cannot be deleted.
- Unreferenced registry entries can be deleted without cascade behavior.
- Prompt writes cannot escape the configured directory or rewrite historical provenance.
+9 -8
View File
@@ -18,12 +18,12 @@ This document defines the frozen boundary for the constrained-settings revision
### 2. Document Type Maintenance
- List active and inactive Document Types.
- Add new types with a stable unique code and user-facing label.
- Edit mutable labels and sort order.
- Add new types with a unique user-facing label.
- Edit labels and active state.
- Activate or deactivate types without invalidating historical Documents.
- Do not allow changing a stable code after creation.
- Allow deletion only when no Document references the type.
- Allow label changes regardless of whether the type is referenced.
- Display types alphabetically by label.
### 3. Person Role Maintenance
@@ -74,9 +74,10 @@ This document defines the frozen boundary for the constrained-settings revision
## Locked Design Decisions
### A. Registry Codes Are Immutable
### A. Registry Identity and Lifecycle
- Document Type and Person Role codes are stable identifiers.
- Document Types use UUID identity and case-insensitively unique labels; no separate code is exposed or stored.
- Person Role codes remain stable identifiers.
- Labels and active state remain mutable.
- Historical references remain valid when a registry entry is inactive.
- Labels may be updated for referenced and unreferenced entries.
@@ -104,7 +105,7 @@ This document defines the frozen boundary for the constrained-settings revision
- Person Roles are ordered by label and then stable code.
- V4.3 does not add a `sort_order` field to Person Roles.
- Document Type sort-order maintenance remains in scope because `DocumentType.sort_order` is already part of the V4 schema.
- Document Types use alphabetical label ordering and have no persisted sort order.
### F. Settings Are Installation-Local
@@ -122,11 +123,11 @@ This document defines the frozen boundary for the constrained-settings revision
## Acceptance Criteria
1. Document Type and Person Role maintenance preserves stable codes and historical references.
1. Document Type UUID identity and Person Role stable codes preserve historical references.
2. Inactive registry entries remain visible on historical records but are excluded from default create selectors.
3. Labels can be changed for referenced or unreferenced registry entries.
4. An unreferenced Document Type or Person Role can be deleted, while deletion of a referenced entry fails without partial mutation.
5. Document Type sort order is maintainable; Person Roles use deterministic label/code ordering without a schema addition.
5. Document Types use alphabetical label ordering; Person Roles use deterministic label/code ordering.
6. Prompt edits are restricted to existing Markdown files directly beneath the configured prompt directory.
7. Prompt saves use atomic replacement, retain exactly one previous-version backup, and support explicit recovery.
8. A prompt edit affects future Jobs only and leaves stored Job provenance unchanged.
+4 -4
View File
@@ -21,7 +21,7 @@ This document describes the production architecture of the document transcriptio
- Freeze prompt text, prompt hash, model, and explicitly configured sampling parameters on each `Job`.
- Preserve page-level machine output, normalized metadata, and an SDK-serialized OpenRouter response snapshot on `JobSource`.
- Organize historical `Person` records through many-to-many Document relationships and extensible roles.
- Classify Documents through a registry with stable type codes.
- Classify Documents through a UUID-identified registry with unique labels.
- Maintain human revision separately from machine-generated text.
- Isolate page failures so multi-page jobs can complete with partial success.
- Operate across supported platforms through Python-based application and maintenance tooling.
@@ -111,7 +111,7 @@ Responsibilities:
- People own person records, relationship roles, document-person links, and portrait media.
- Apply deterministic conflict handling for relationship-role writes.
- Use set-based synchronization for many-to-many relationship updates.
- Resolve and validate registry-backed document types.
- Resolve and validate registry-backed document types by UUID.
### Source Media Policy
@@ -155,7 +155,7 @@ Responsibilities:
### 3. Document Type Management
1. User selects a registry-backed document type for a document.
2. Service resolves the stable type code or id.
2. Service resolves the Document Type UUID.
3. Persistence stores the `document_type_id` reference.
4. Inactive types remain valid for historical rows but are excluded from default selectors.
@@ -171,7 +171,7 @@ Responsibilities:
- Generic `ProcessingArtifact` records use versioned schemas, digests, and one inline or external content location.
- `DocumentPerson` links are unique for `(document_id, person_id, role_id)`.
- Relationship mutations are deterministic and set-based.
- `DocumentType.code` is stable; `DocumentType.label` may evolve.
- `DocumentType.id` is canonical identity; its unique label may evolve.
## Data Model Summary
+2 -2
View File
@@ -42,13 +42,13 @@ Implement the Version 4 project definition from the current repository state whi
- Implement set-based synchronization for document-person updates.
- Implement deterministic uniqueness and relationship-write conflict checks.
- Remove suggestion-related service behavior.
- Add document-type resolution and validation by stable code or id.
- Add document-type resolution and validation by UUID.
### 4. Update API Contracts
- Keep API evolution additive.
- Add role-aware relationship retrieval and write behavior.
- Add document-type catalog retrieval and code-based selection for document writes.
- Add document-type catalog retrieval and UUID-based selection for document writes.
- Remove suggestion-related API surfaces from the V4 target state.
### 5. Update UI Workflows
+4 -4
View File
@@ -16,11 +16,11 @@ This document defines the baseline requirements for the document transcription s
| REQ-7 | Policy Constraint | Enforce deterministic relationship-role writes with uniqueness on `(document_id, person_id, role_id)` and explicit conflict responses for invalid duplicate link attempts. | test |
| REQ-8 | Functional | Use set-based synchronization for document-person mutations so updates add and remove only the intended links. | test |
| REQ-9 | Functional | Maintain immutable machine output on `Source.raw_transcription` while permitting inline human edits on `Source.revised_text`. | test |
| REQ-10 | Functional | Support a registry-driven `DocumentType` taxonomy with stable codes, mutable labels, and active/inactive lifecycle control. | test |
| REQ-10 | Functional | Support a UUID-identified `DocumentType` taxonomy with unique user-facing labels and active/inactive lifecycle control. | test |
| REQ-11 | Data Constraint | Store `Document` type as a controlled reference to `DocumentType`. | test |
| REQ-12 | Interface | Render multi-page transcriptions sequentially by `page_number` with document, people, and document-type metadata. | demonstration |
| REQ-13 | Interface | Document create/edit UI must support selecting multiple people per role and selecting an active document type from the registry. | demonstration |
| REQ-14 | API Constraint | Expose additive, role-aware retrieval and write behavior for document-person links and code-based selection for document types. | test |
| REQ-14 | API Constraint | Expose additive, role-aware retrieval and write behavior for document-person links and UUID-based selection for document types. | test |
| REQ-15 | Data Constraint | Calculate and store cryptographic file hashes (SHA-256) and file sizes for uploaded source images. | test |
| REQ-16 | Data Constraint | Preserve a portable relational model across supported backends using SQLModel, SQLAlchemy, SQLite, and PostgreSQL. | inspection |
| REQ-17 | Reliability | Ensure delete and update flows for documents, people, and relationship links remain deterministic and safe. | test |
@@ -29,8 +29,8 @@ This document defines the baseline requirements for the document transcription s
## Clarifying Constraints
1. `DocumentType.code` and `PersonRole.code` are stable machine identifiers.
2. `DocumentType.label` and `PersonRole.label` may evolve without changing canonical identity.
1. `DocumentType.id` is its sole identity; labels are unique ignoring case and surrounding whitespace.
2. `PersonRole.code` is a stable machine identifier; `PersonRole.label` may evolve.
3. Relationship-write policy and conflict handling must be consistent across UI, API, services, and persistence.
4. Many-per-role behavior is required for document-person links.
5. Relationship conflicts must fail deterministically without partial mutation.
+5 -5
View File
@@ -8,10 +8,9 @@ This document defines the relational schema for the document transcription syste
erDiagram
DOCUMENT_TYPE {
UUID id PK
TEXT code
TEXT label
TEXT normalized_label
BOOLEAN is_active
INTEGER sort_order
TIMESTAMPTZ created_at
TIMESTAMPTZ updated_at
}
@@ -207,13 +206,14 @@ EXECUTION_ATTEMPT ||--o{ PROCESSING_ARTIFACT : produces
### Document Type Governance
- Every document type is defined by `DOCUMENT_TYPE`.
- `DOCUMENT_TYPE.code` is a stable machine identifier.
- `DOCUMENT_TYPE.label` is mutable display text.
- `DOCUMENT_TYPE.id` is the sole machine identity.
- `DOCUMENT_TYPE.label` is mutable display text and is unique after trimming and case normalization.
- `DOCUMENT_TYPE.normalized_label` stores the normalized uniqueness key.
- Inactive types remain valid for historical rows but should be excluded from default selection UIs.
## Constraint Summary
- `DOCUMENT_TYPE.code` is unique.
- `DOCUMENT_TYPE.normalized_label` is unique.
- `PERSON_ROLE.code` is unique.
- `DOCUMENT_PERSON(document_id, person_id, role_id)` is unique.
+1 -1
View File
@@ -16,7 +16,7 @@ Define what this revision includes, what it intentionally excludes, and what mig
### 2. Document Type Governance
- Registry-driven `DocumentType` model with stable codes and controlled selection.
- Registry-driven `DocumentType` model with UUID identity, unique labels, and controlled selection.
- Minimal rollout for the current corpus with no alias helper table.
### 3. UI and API Behavior
+6 -24
View File
@@ -51,10 +51,8 @@ class SelectorRequest(ApiModel):
class DocumentTypeRead(ApiModel):
id: UUID
code: str
label: str
is_active: bool
sort_order: int
class PersonRoleRead(ApiModel):
@@ -64,27 +62,13 @@ class PersonRoleRead(ApiModel):
is_active: bool
class DocumentTypeWriteRequest(SelectorRequest):
document_type_id: UUID | None = None
document_type_code: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9_]+$")
@property
def selector_id(self) -> UUID | None:
return self.document_type_id
@property
def selector_code(self) -> str | None:
return self.document_type_code
@property
def selector_names(self) -> tuple[str, str]:
return "document_type_id", "document_type_code"
class DocumentTypeWriteRequest(ApiModel):
document_type_id: UUID
class DocumentTypeWriteResponse(ApiModel):
document_id: UUID
document_type_id: UUID | None
document_type_code: str | None
document_type_id: UUID
class DocumentPersonWriteRequest(ApiModel):
@@ -133,10 +117,8 @@ class DocumentPeopleResponse(ApiModel):
def _document_type_to_read(item: DocumentType) -> DocumentTypeRead:
return DocumentTypeRead(
id=item.id,
code=item.code,
label=item.label,
is_active=item.is_active,
sort_order=item.sort_order,
)
@@ -150,7 +132,7 @@ def _person_role_to_read(item: PersonRole) -> PersonRoleRead:
def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead:
role_code = item.role_ref.code if item.role_ref is not None else item.role.value
role_code = item.role_ref.code if item.role_ref is not None else str(item.role)
person_name = item.person.full_name if item.person is not None else None
return DocumentPersonRead(
@@ -164,10 +146,11 @@ def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead:
def _document_to_type_response(item: Document) -> DocumentTypeWriteResponse:
if item.document_type_id is None:
raise ValueError("Document Type assignment did not persist")
return DocumentTypeWriteResponse(
document_id=item.id,
document_type_id=item.document_type_id,
document_type_code=item.document_type,
)
@@ -218,7 +201,6 @@ async def set_document_type(
document = await service.set_document_type(
document_id=document_id,
document_type_id=payload.document_type_id,
document_type_code=payload.document_type_code,
)
return _document_to_type_response(document)
+2 -14
View File
@@ -62,10 +62,9 @@ class DocumentType(SQLModel, table=True):
__tablename__ = "document_type"
id: UUID = Field(default_factory=uuid4, primary_key=True)
code: str = Field(index=True, unique=True)
label: str
normalized_label: str = Field(index=True, unique=True)
is_active: bool = True
sort_order: int = 0
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@@ -97,7 +96,6 @@ class Document(SQLModel, table=True):
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str
document_type_id: UUID | None = Field(default=None, foreign_key="document_type.id")
document_type: str | None = None
document_date: date | None = None
document_date_raw: str | None = None
location_created: str | None = None
@@ -153,17 +151,7 @@ class DocumentPerson(SQLModel, table=True):
document_id: UUID = Field(foreign_key="document.id")
person_id: UUID = Field(foreign_key="person.id")
role_id: UUID | None = Field(default=None, foreign_key="person_role.id")
role: DocumentPersonRole = Field(
default=DocumentPersonRole.AUTHOR,
sa_column=Column(
SAEnum(
DocumentPersonRole,
values_callable=lambda enum_cls: [item.value for item in enum_cls],
native_enum=False,
),
nullable=False,
),
)
role: str = Field(default=DocumentPersonRole.AUTHOR.value, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
+81 -11
View File
@@ -26,13 +26,13 @@ DEFAULT_PERSON_ROLES: tuple[tuple[str, str], ...] = (
("mentioned", "Mentioned"),
)
DEFAULT_DOCUMENT_TYPES: tuple[tuple[str, str], ...] = (
("letter", "Letter"),
("record", "Record"),
("memo", "Memo"),
("postcard", "Postcard"),
("journal", "Journal"),
("note", "Note"),
DEFAULT_DOCUMENT_TYPES: tuple[str, ...] = (
"Letter",
"Record",
"Memo",
"Postcard",
"Journal",
"Note",
)
@@ -44,6 +44,7 @@ async def create_all(*, engine: AsyncEngine | None = None) -> None:
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
await _upgrade_document_type_uuid_identity(connection)
await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(connection)
await seed_registry_defaults(engine=active_engine)
@@ -54,6 +55,7 @@ async def upgrade_schema(*, engine: AsyncEngine | None = None) -> None:
"""Apply non-destructive additive upgrades to an existing schema."""
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await _upgrade_document_type_uuid_identity(connection)
await _upgrade_person_family_search_id(connection)
await _upgrade_v42_evidence_tables(connection)
@@ -68,6 +70,73 @@ async def _upgrade_v42_evidence_tables(connection: AsyncConnection) -> None:
await connection.run_sync(create_tables)
async def _upgrade_document_type_uuid_identity(connection: AsyncConnection) -> None:
"""Backfill UUID references and retire legacy Document Type code/order columns."""
def inspect_schema(sync_connection) -> tuple[set[str], set[str]]:
database = inspect(sync_connection)
tables = set(database.get_table_names())
type_columns = (
{column["name"] for column in database.get_columns("document_type")} if "document_type" in tables else set()
)
document_columns = (
{column["name"] for column in database.get_columns("document")} if "document" in tables else set()
)
return type_columns, document_columns
type_columns, document_columns = await connection.run_sync(inspect_schema)
if not type_columns:
return
if "normalized_label" not in type_columns:
await connection.execute(text("ALTER TABLE document_type ADD COLUMN normalized_label VARCHAR"))
await connection.execute(
text("UPDATE document_type SET normalized_label = lower(trim(label)) WHERE normalized_label IS NULL")
)
duplicates = (
await connection.execute(
text("SELECT normalized_label FROM document_type GROUP BY normalized_label HAVING count(*) > 1")
)
).first()
if duplicates is not None:
raise RuntimeError(
"Document Type migration requires unique labels ignoring case and whitespace; "
f"duplicate normalized label: {duplicates[0]!r}"
)
if "code" in type_columns and {"document_type", "document_type_id"}.issubset(document_columns):
await connection.execute(
text(
"UPDATE document SET document_type_id = ("
"SELECT id FROM document_type WHERE "
"lower(trim(document_type.code)) = lower(trim(document.document_type))"
") WHERE document_type_id IS NULL AND document_type IS NOT NULL"
)
)
if connection.dialect.name == "postgresql":
await connection.execute(text("ALTER TABLE document_type ALTER COLUMN normalized_label SET NOT NULL"))
if "code" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN code CASCADE"))
if "sort_order" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order"))
if "document_type" in document_columns:
await connection.execute(text("ALTER TABLE document DROP COLUMN document_type"))
elif connection.dialect.name == "sqlite":
await connection.execute(text("DROP INDEX IF EXISTS ix_document_type_code"))
if "code" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN code"))
if "sort_order" in type_columns:
await connection.execute(text("ALTER TABLE document_type DROP COLUMN sort_order"))
if "document_type" in document_columns:
await connection.execute(text("ALTER TABLE document DROP COLUMN document_type"))
await connection.execute(
text("CREATE UNIQUE INDEX IF NOT EXISTS ix_document_type_normalized_label ON document_type (normalized_label)")
)
async def _upgrade_person_family_search_id(connection: AsyncConnection) -> None:
"""Add the nullable V4.1 FamilySearch field to an existing database."""
@@ -105,10 +174,11 @@ async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
if code not in role_codes:
session.add(PersonRole(code=code, label=label))
type_codes = set((await session.exec(select(DocumentType.code))).all())
for sort_order, (code, label) in enumerate(DEFAULT_DOCUMENT_TYPES):
if code not in type_codes:
session.add(DocumentType(code=code, label=label, sort_order=sort_order))
type_labels = set((await session.exec(select(DocumentType.normalized_label))).all())
for label in DEFAULT_DOCUMENT_TYPES:
normalized_label = label.casefold()
if normalized_label not in type_labels:
session.add(DocumentType(label=label, normalized_label=normalized_label))
await session.commit()
+2 -1
View File
@@ -6,9 +6,10 @@ from dataclasses import field
from .documents import DocumentService
from .jobs import JobService
from .people import PeopleService
from .prompts import PromptStore
from .sources import SourceService
__all__ = ["DocumentService", "JobService", "PeopleService", "ServiceBundle", "SourceService"]
__all__ = ["DocumentService", "JobService", "PeopleService", "PromptStore", "ServiceBundle", "SourceService"]
@dataclass(frozen=True, slots=True)
+200 -70
View File
@@ -1,12 +1,15 @@
import logging
import shutil
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import col
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -36,57 +39,48 @@ class DocumentDeleteBlockedError(DocumentError):
"""Raised when a document delete is blocked by dependent records."""
class DocumentTypeError(DocumentError):
"""Raised when Document Type maintenance fails."""
def _normalize_registry_label(label: str) -> str:
normalized = label.strip()
if not normalized:
raise DocumentTypeError(
"Document Type label is required",
category=ErrorCategory.VALIDATION,
suggestion="Enter a user-facing label and retry.",
)
return normalized
def _document_type_label_key(label: str) -> str:
return _normalize_registry_label(label).casefold()
@dataclass(frozen=True, slots=True)
class DocumentTypeSummary:
"""Settings read model for a Document Type and its usage count."""
id: UUID
label: str
is_active: bool
document_count: int
class DocumentService(ServiceBase):
"""Thin service class for managing documents in the database."""
async def _resolve_or_create_document_type(
self,
*,
session: AsyncSession,
document_type_id: UUID | None,
document_type_code: str | None,
) -> DocumentType | None:
"""Resolve canonical document type by id/code with compatibility fallback creation."""
if document_type_id is not None:
found = await session.get(DocumentType, document_type_id)
if found is None:
raise DocumentError(
f"Document type with id {document_type_id} not found",
category=ErrorCategory.VALIDATION,
suggestion="Select a valid document type and retry.",
)
return found
if document_type_code is None:
return None
normalized_code = document_type_code.strip().lower()
if not normalized_code:
return None
existing = (await session.exec(select(DocumentType).where(DocumentType.code == normalized_code))).first()
if existing is not None:
return existing
created = DocumentType(code=normalized_code, label=normalized_code.replace("_", " ").title())
session.add(created)
await session.flush()
return created
async def _sync_document_type_fields(self, *, session: AsyncSession, document: Document) -> None:
"""Synchronize legacy and canonical document type fields."""
resolved = await self._resolve_or_create_document_type(
session=session,
document_type_id=document.document_type_id,
document_type_code=document.document_type,
)
if resolved is None:
document.document_type_id = None
document.document_type = None
async def _validate_document_type(self, *, session: AsyncSession, document: Document) -> None:
"""Validate the UUID-backed Document Type reference."""
if document.document_type_id is None:
return
document.document_type_id = resolved.id
document.document_type = resolved.code
if await session.get(DocumentType, document.document_type_id) is None:
raise DocumentError(
f"Document type with id {document.document_type_id} not found",
category=ErrorCategory.VALIDATION,
suggestion="Select a valid document type and retry.",
)
async def _get_document_or_raise(self, *, session: AsyncSession, document_id: UUID) -> Document:
"""Get a document by id or raise a not-found service error."""
@@ -111,7 +105,7 @@ class DocumentService(ServiceBase):
) -> Document:
"""Create a new document in the database."""
async with self._session_scope(session) as _session:
await self._sync_document_type_fields(session=_session, document=document)
await self._validate_document_type(session=_session, document=document)
_session.add(document)
try:
await self._finalize(session=_session, caller_session=session, refresh=(document,))
@@ -154,7 +148,7 @@ class DocumentService(ServiceBase):
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
"""Update an existing document in the database."""
async with self._session_scope(session) as _session:
await self._sync_document_type_fields(session=_session, document=document)
await self._validate_document_type(session=_session, document=document)
document.updated_at = datetime.now(UTC)
merged = await _session.merge(document)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
@@ -272,39 +266,175 @@ class DocumentService(ServiceBase):
async with self._session_scope(session) as _session:
query = select(DocumentType)
if active_only:
query = query.where(DocumentType.is_active.is_(True))
query = query.order_by(DocumentType.sort_order, DocumentType.code)
query = query.where(col(DocumentType.is_active).is_(True))
query = query.order_by(col(DocumentType.normalized_label), col(DocumentType.id))
result = await _session.exec(query)
return result.all()
async def list_document_type_summaries(
self,
*,
session: AsyncSession | None = None,
) -> Sequence[DocumentTypeSummary]:
"""List Document Types alphabetically with current usage counts."""
async with self._session_scope(session) as _session:
query = (
select(DocumentType, func.count(col(Document.id)))
.outerjoin(Document, col(Document.document_type_id) == col(DocumentType.id))
.group_by(col(DocumentType.id))
.order_by(col(DocumentType.normalized_label), col(DocumentType.id))
)
rows = (await _session.exec(query)).all()
return [
DocumentTypeSummary(
id=document_type.id,
label=document_type.label,
is_active=document_type.is_active,
document_count=int(document_count),
)
for document_type, document_count in rows
]
async def create_document_type(
self,
*,
label: str,
is_active: bool = True,
session: AsyncSession | None = None,
) -> DocumentType:
"""Create a UUID-identified Document Type with a unique label."""
document_type = DocumentType(
label=_normalize_registry_label(label),
normalized_label=_document_type_label_key(label),
is_active=is_active,
)
async with self._session_scope(session) as _session:
_session.add(document_type)
try:
await self._finalize(session=_session, caller_session=session, refresh=(document_type,))
except IntegrityError as exc:
raise DocumentTypeError(
f"Document Type label {document_type.label!r} already exists",
category=ErrorCategory.CONFLICT,
suggestion="Choose a different label or edit the existing type.",
) from exc
return document_type
async def read_document_type(
self,
document_type_id: UUID,
*,
session: AsyncSession | None = None,
) -> DocumentType:
"""Read a Document Type by id."""
async with self._session_scope(session) as _session:
document_type = await _session.get(DocumentType, document_type_id)
if document_type is None:
raise DocumentTypeError(
f"Document Type with id {document_type_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Document Type.",
)
return document_type
async def update_document_type(
self,
document_type_id: UUID,
*,
label: str,
is_active: bool,
session: AsyncSession | None = None,
) -> DocumentType:
"""Update a Document Type label and active state."""
async with self._session_scope(session) as _session:
document_type = await _session.get(DocumentType, document_type_id)
if document_type is None:
raise DocumentTypeError(
f"Document Type with id {document_type_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Document Type.",
)
document_type.label = _normalize_registry_label(label)
document_type.normalized_label = _document_type_label_key(label)
document_type.is_active = is_active
document_type.updated_at = datetime.now(UTC)
try:
await self._finalize(session=_session, caller_session=session, refresh=(document_type,))
except IntegrityError as exc:
raise DocumentTypeError(
f"Document Type label {document_type.label!r} already exists",
category=ErrorCategory.CONFLICT,
suggestion="Choose a different label or edit the existing type.",
) from exc
return document_type
async def delete_document_type(
self,
document_type_id: UUID,
*,
session: AsyncSession | None = None,
) -> None:
"""Delete an unreferenced Document Type without cascade behavior."""
async with self._session_scope(session) as _session:
document_type = await _session.get(DocumentType, document_type_id)
if document_type is None:
raise DocumentTypeError(
f"Document Type with id {document_type_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Document Type.",
)
if await self._document_type_is_referenced(session=_session, document_type=document_type):
raise DocumentTypeError(
f"Document Type {document_type.label!r} is referenced and cannot be deleted",
category=ErrorCategory.CONFLICT,
suggestion="Deactivate the type instead; historical Documents will retain it.",
)
await _session.delete(document_type)
await self._finalize(session=_session, caller_session=session)
async def is_document_type_referenced(
self,
document_type_id: UUID,
*,
session: AsyncSession | None = None,
) -> bool:
"""Return whether a Document references a Document Type."""
async with self._session_scope(session) as _session:
document_type = await _session.get(DocumentType, document_type_id)
if document_type is None:
raise DocumentTypeError(
f"Document Type with id {document_type_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Document Type.",
)
return await self._document_type_is_referenced(
session=_session,
document_type=document_type,
)
@staticmethod
async def _document_type_is_referenced(
*,
session: AsyncSession,
document_type: DocumentType,
) -> bool:
reference = (
await session.exec(select(Document.id).where(Document.document_type_id == document_type.id))
).first()
return reference is not None
async def set_document_type(
self,
*,
document_id: UUID,
document_type_id: UUID | None = None,
document_type_code: str | None = None,
document_type_id: UUID,
session: AsyncSession | None = None,
) -> Document:
"""Set a document type by canonical id or code."""
"""Set a Document Type by UUID."""
async with self._session_scope(session) as _session:
document = await self._get_document_or_raise(session=_session, document_id=document_id)
if document_type_id is None and (document_type_code is None or not document_type_code.strip()):
raise DocumentError(
"Either document_type_id or document_type_code is required",
category=ErrorCategory.VALIDATION,
suggestion="Provide a valid document type id or code and retry.",
)
if document_type_id is not None and document_type_code and document_type_code.strip():
raise DocumentError(
"Provide document_type_id or document_type_code, not both",
category=ErrorCategory.VALIDATION,
suggestion="Send only one document type selector and retry.",
)
document.document_type_id = document_type_id
document.document_type = document_type_code
await self._sync_document_type_fields(session=_session, document=document)
await self._validate_document_type(session=_session, document=document)
document.updated_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(document,))
return document
+154 -10
View File
@@ -41,6 +41,35 @@ class PersonMediaError(PeopleError):
"""Raised when Person portrait media cannot be validated or persisted."""
class PersonRoleError(PeopleError):
"""Raised when Person Role maintenance fails."""
REGISTRY_CODE_PATTERN = re.compile(r"^[a-z0-9_]+$")
def _normalize_role_code(code: str) -> str:
normalized = code.strip().lower()
if not normalized or not REGISTRY_CODE_PATTERN.fullmatch(normalized):
raise PersonRoleError(
"Person Role code must contain only lowercase letters, numbers, and underscores",
category=ErrorCategory.VALIDATION,
suggestion="Enter a stable code such as witness or record_keeper.",
)
return normalized
def _normalize_role_label(label: str) -> str:
normalized = label.strip()
if not normalized:
raise PersonRoleError(
"Person Role label is required",
category=ErrorCategory.VALIDATION,
suggestion="Enter a user-facing label and retry.",
)
return normalized
def normalize_family_search_id(value: str | None) -> str | None:
"""Normalize and validate a FamilySearch tree person identifier."""
normalized = (value or "").strip().upper()
@@ -175,7 +204,129 @@ class PeopleService(ServiceBase):
query = select(PersonRole)
if active_only:
query = query.where(PersonRole.is_active.is_(True))
return (await _session.exec(query.order_by(PersonRole.code))).all()
return (await _session.exec(query.order_by(PersonRole.label, PersonRole.code))).all()
async def create_person_role(
self,
*,
code: str,
label: str,
is_active: bool = True,
session: AsyncSession | None = None,
) -> PersonRole:
"""Create a Person Role with an immutable normalized code."""
role = PersonRole(
code=_normalize_role_code(code),
label=_normalize_role_label(label),
is_active=is_active,
)
async with self._session_scope(session) as _session:
_session.add(role)
try:
await self._finalize(session=_session, caller_session=session, refresh=(role,))
except IntegrityError as exc:
raise PersonRoleError(
f"Person Role code {role.code!r} already exists",
category=ErrorCategory.CONFLICT,
suggestion="Choose a different stable code or edit the existing role.",
) from exc
return role
async def read_person_role(
self,
person_role_id: UUID,
*,
session: AsyncSession | None = None,
) -> PersonRole:
"""Read a Person Role by id."""
async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id)
if role is None:
raise PersonRoleError(
f"Person Role with id {person_role_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Person Role.",
)
return role
async def update_person_role(
self,
person_role_id: UUID,
*,
label: str,
is_active: bool,
session: AsyncSession | None = None,
) -> PersonRole:
"""Update mutable Person Role fields without changing its code."""
async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id)
if role is None:
raise PersonRoleError(
f"Person Role with id {person_role_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Person Role.",
)
role.label = _normalize_role_label(label)
role.is_active = is_active
role.updated_at = datetime.now(UTC)
await self._finalize(session=_session, caller_session=session, refresh=(role,))
return role
async def delete_person_role(
self,
person_role_id: UUID,
*,
session: AsyncSession | None = None,
) -> None:
"""Delete an unreferenced Person Role without cascade behavior."""
async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id)
if role is None:
raise PersonRoleError(
f"Person Role with id {person_role_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Person Role.",
)
if await self._person_role_is_referenced(session=_session, role=role):
raise PersonRoleError(
f"Person Role {role.label!r} is referenced and cannot be deleted",
category=ErrorCategory.CONFLICT,
suggestion="Deactivate the role instead; historical relationships will retain it.",
)
await _session.delete(role)
await self._finalize(session=_session, caller_session=session)
async def is_person_role_referenced(
self,
person_role_id: UUID,
*,
session: AsyncSession | None = None,
) -> bool:
"""Return whether canonical or compatibility data references a Person Role."""
async with self._session_scope(session) as _session:
role = await _session.get(PersonRole, person_role_id)
if role is None:
raise PersonRoleError(
f"Person Role with id {person_role_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an available Person Role.",
)
return await self._person_role_is_referenced(session=_session, role=role)
@staticmethod
async def _person_role_is_referenced(
*,
session: AsyncSession,
role: PersonRole,
) -> bool:
reference = (
await session.exec(
select(DocumentPerson.id).where(
(DocumentPerson.role_id == role.id) | (DocumentPerson.role == role.code)
)
)
).first()
return reference is not None
async def list_document_people(
self,
@@ -328,15 +479,8 @@ class PeopleService(ServiceBase):
)
@staticmethod
def _legacy_role(role_code: str) -> DocumentPersonRole:
try:
return DocumentPersonRole(role_code.strip().lower())
except ValueError as exc:
raise PeopleError(
f"Unsupported role code {role_code!r}",
category=ErrorCategory.VALIDATION,
suggestion="Use author, recipient, or mentioned.",
) from exc
def _legacy_role(role_code: str) -> str:
return _normalize_role_code(role_code)
@staticmethod
def _not_found(message: str) -> PeopleError:
+188
View File
@@ -0,0 +1,188 @@
"""Constrained storage for mutable prompt Markdown artifacts."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from uuid import uuid4
from ..config import Settings
from ..config import get_settings
from ..errors import AppError
from ..errors import ErrorCategory
PROMPT_EXTENSION = ".md"
BACKUP_SUFFIX = ".bak"
class PromptStoreError(AppError):
"""Raised when prompt storage validation or persistence fails."""
@dataclass(frozen=True, slots=True)
class PromptSummary:
"""Read model for one editable prompt artifact."""
name: str
is_default: bool
has_backup: bool
class PromptStore:
"""List, read, atomically update, and recover existing prompt files."""
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
def list_prompts(self) -> tuple[PromptSummary, ...]:
"""List editable direct-child Markdown prompts by filename."""
root = self._prompt_root()
try:
candidates = tuple(root.iterdir())
except OSError as exc:
raise self._filesystem_error("Prompt directory could not be read", exc) from exc
summaries: list[PromptSummary] = []
for candidate in candidates:
if candidate.suffix.lower() != PROMPT_EXTENSION or not candidate.is_file():
continue
resolved = candidate.resolve()
if resolved.parent != root:
continue
summaries.append(
PromptSummary(
name=candidate.name,
is_default=candidate.name == self.settings.default_prompt_name,
has_backup=self._backup_path(candidate).is_file(),
)
)
return tuple(sorted(summaries, key=lambda item: item.name.casefold()))
def read_prompt(self, name: str) -> str:
"""Read one existing UTF-8 prompt."""
path = self._resolve_existing_prompt(name)
return self._read_nonempty_text(path, description="Prompt")
def write_prompt(self, name: str, content: str) -> None:
"""Atomically replace an existing prompt and retain one prior version."""
path = self._resolve_existing_prompt(name)
normalized_content = content.strip()
if not normalized_content:
raise PromptStoreError(
"Prompt content cannot be empty",
category=ErrorCategory.VALIDATION,
suggestion="Enter prompt text before saving.",
)
self._atomic_write(path=path, content=f"{normalized_content}\n", preserve_current=True)
def recover_prompt(self, name: str) -> None:
"""Restore the sole previous-version backup as an explicit operation."""
path = self._resolve_existing_prompt(name)
backup_path = self._backup_path(path)
if not backup_path.is_file():
raise PromptStoreError(
f"No previous version is available for {path.name}",
category=ErrorCategory.NOT_FOUND,
suggestion="Save a prompt edit before attempting recovery.",
)
backup_content = self._read_nonempty_text(backup_path, description="Prompt backup")
self._atomic_write(path=path, content=backup_content, preserve_current=True)
def _prompt_root(self) -> Path:
try:
root = self.settings.prompt_dir.resolve()
except OSError as exc:
raise self._filesystem_error("Prompt directory could not be resolved", exc) from exc
if not root.is_dir():
raise PromptStoreError(
f"Prompt directory is unavailable: {root}",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Restore the configured prompt directory and its permissions.",
)
return root
def _resolve_existing_prompt(self, name: str) -> Path:
normalized_name = name.strip()
if (
not normalized_name
or Path(normalized_name).name != normalized_name
or Path(normalized_name).suffix.lower() != PROMPT_EXTENSION
):
raise PromptStoreError(
"Prompt name must be a direct-child Markdown filename",
category=ErrorCategory.VALIDATION,
suggestion="Select an existing .md prompt from Settings.",
)
root = self._prompt_root()
try:
path = (root / normalized_name).resolve()
except OSError as exc:
raise self._filesystem_error("Prompt path could not be resolved", exc) from exc
if path.parent != root:
raise PromptStoreError(
"Prompt path must remain inside the configured prompt directory",
category=ErrorCategory.VALIDATION,
suggestion="Select an existing prompt from Settings.",
)
if not path.is_file():
raise PromptStoreError(
f"Prompt file not found: {normalized_name}",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh Settings and select an existing prompt.",
)
return path
def _read_nonempty_text(self, path: Path, *, description: str) -> str:
try:
content = path.read_text(encoding="utf-8")
except UnicodeError as exc:
raise PromptStoreError(
f"{description} is not valid UTF-8: {path.name}",
category=ErrorCategory.VALIDATION,
suggestion="Restore a valid UTF-8 Markdown prompt.",
) from exc
except OSError as exc:
raise self._filesystem_error(f"{description} could not be read", exc) from exc
if not content.strip():
raise PromptStoreError(
f"{description} is empty: {path.name}",
category=ErrorCategory.VALIDATION,
suggestion="Restore non-empty prompt content.",
)
return content
def _atomic_write(self, *, path: Path, content: str, preserve_current: bool) -> None:
temporary_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
backup_path = self._backup_path(path)
backup_temporary_path = backup_path.with_name(f".{backup_path.name}.{uuid4().hex}.tmp")
try:
self._write_synced(temporary_path, content.encode("utf-8"))
if preserve_current:
self._write_synced(backup_temporary_path, path.read_bytes())
backup_temporary_path.replace(backup_path)
temporary_path.replace(path)
except (OSError, UnicodeError) as exc:
raise self._filesystem_error(f"Prompt {path.name} could not be saved", exc) from exc
finally:
temporary_path.unlink(missing_ok=True)
backup_temporary_path.unlink(missing_ok=True)
@staticmethod
def _write_synced(path: Path, content: bytes) -> None:
with path.open("wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
@staticmethod
def _backup_path(path: Path) -> Path:
return path.with_name(f"{path.name}{BACKUP_SUFFIX}")
@staticmethod
def _filesystem_error(message: str, exc: Exception) -> PromptStoreError:
return PromptStoreError(
f"{message}: {exc}",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check prompt directory permissions and available disk space, then retry.",
)
+7 -4
View File
@@ -1,12 +1,16 @@
"""UI page registration exports."""
from contextlib import suppress
from fastapi import FastAPI
from nicegui import ui
from transcription.ui.pages.home_page import register_page as register_home_page
from transcription.config import get_settings
from transcription.ui.pages.documents_page import register_page as register_documents_page
from transcription.ui.pages.home_page import register_page as register_home_page
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.people_page import register_page as register_people_page
from transcription.ui.pages.settings_page import register_page as register_settings_page
from transcription.ui.pages.sources_page import register_page as register_sources_page
from transcription.ui.resources import read_css
from transcription.ui.theme import apply_archival_theme
@@ -19,12 +23,10 @@ def _register_global_styles(app: FastAPI) -> None:
return
apply_archival_theme()
try:
with suppress(RuntimeError):
ui.add_css(read_css("theme.css"), shared=True)
except RuntimeError:
# NiceGUI shared style registration can raise when the app is constructed
# outside an active client context, which happens in unit tests.
pass
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
@@ -37,4 +39,5 @@ def register_pages(app: FastAPI) -> None:
register_people_page()
register_sources_page()
register_jobs_page()
register_settings_page(settings=getattr(app.state, "settings", None) or get_settings())
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
@@ -11,6 +11,7 @@ NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("People", "/people", "group"),
("Sources", "/sources", "folder"),
("Jobs", "/jobs", "work_history"),
("Settings", "/settings", "settings"),
)
@@ -23,6 +24,8 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool:
return current_path == "/people" or current_path.startswith("/people/")
if item_path == "/sources":
return current_path == "/sources" or current_path.startswith("/sources/")
if item_path == "/settings":
return current_path == "/settings" or current_path.startswith("/settings/")
return current_path == item_path
+21 -32
View File
@@ -59,7 +59,7 @@ def register_page() -> None: # noqa: PLR0915
people=people,
role_codes=[role.code for role in role_catalog],
role_labels={role.code: role.label for role in role_catalog},
type_options={doc_type.code: doc_type.label for doc_type in type_catalog},
type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog},
selected_people_by_role=selected_people_by_role,
)
@@ -67,11 +67,11 @@ def register_page() -> None: # noqa: PLR0915
async def submit_create() -> None:
candidate_name = (form["name"].value or "").strip()
candidate_type = _resolve_selected_document_type_code(form["type"].value, form["type_options"])
candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
if not candidate_type:
if candidate_type_id is None:
ui.notify("Document type is required.", type="warning")
return
@@ -82,7 +82,7 @@ def register_page() -> None: # noqa: PLR0915
candidate = Document(
name=candidate_name,
document_type=candidate_type,
document_type_id=candidate_type_id,
document_date=parsed_date,
document_date_raw=(form["date_raw"].value or "").strip() or None,
location_created=(form["location"].value or "").strip() or None,
@@ -148,9 +148,7 @@ def register_page() -> None: # noqa: PLR0915
DocumentTableRow(
id=doc.id,
name=doc.name,
document_type=(
doc.document_type_ref.label if doc.document_type_ref is not None else (doc.document_type or "")
),
document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else ""),
authors=", ".join(_group_people_labels_by_role(doc).get("author", [])),
document_date=compact_date(doc.document_date, doc.document_date_raw),
archive_identifier=doc.archive_identifier or "",
@@ -179,11 +177,7 @@ def register_page() -> None: # noqa: PLR0915
return
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
type_display = (
document.document_type_ref.label
if document.document_type_ref is not None
else (document.document_type or "Unspecified")
)
type_display = document.document_type_ref.label if document.document_type_ref is not None else "Unspecified"
with section_header_row():
page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}")
@@ -245,9 +239,7 @@ def register_page() -> None: # noqa: PLR0915
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with archival_card(extra_classes="p-3"), ui.row().classes(
"w-full items-center justify-between"
):
with archival_card(extra_classes="p-3"), ui.row().classes("w-full items-center justify-between"):
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
@@ -294,17 +286,17 @@ def register_page() -> None: # noqa: PLR0915
people=people,
role_codes=[role.code for role in role_catalog],
role_labels={role.code: role.label for role in role_catalog},
type_options={doc_type.code: doc_type.label for doc_type in type_catalog},
type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog},
selected_people_by_role=existing_by_role,
)
async def submit_edit() -> None:
candidate_name = (form["name"].value or "").strip()
candidate_type = _resolve_selected_document_type_code(form["type"].value, form["type_options"])
candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
if not candidate_type:
if candidate_type_id is None:
ui.notify("Document type is required.", type="warning")
return
@@ -316,7 +308,7 @@ def register_page() -> None: # noqa: PLR0915
candidate = Document(
id=document.id,
name=candidate_name,
document_type=candidate_type,
document_type_id=candidate_type_id,
document_date=parsed_date,
document_date_raw=(form["date_raw"].value or "").strip() or None,
location_created=(form["location"].value or "").strip() or None,
@@ -465,11 +457,11 @@ def _render_document_form_fields(
.classes("w-full ui-form-surface")
)
ordered_types = sorted(type_options.items(), key=lambda item: item[1].casefold())
type_display_to_code = {f"{label} ({code})": code for code, label in ordered_types}
type_display_options = list(type_display_to_code.keys())
type_display_to_id = {label: type_id for type_id, label in ordered_types}
type_display_options = list(type_display_to_id.keys())
selected_type = (
f"{type_options[document.document_type]} ({document.document_type})"
if document and document.document_type in type_options
type_options[str(document.document_type_id)]
if document and document.document_type_id is not None and str(document.document_type_id) in type_options
else (type_display_options[0] if type_display_options else "")
)
type_input = (
@@ -543,7 +535,7 @@ def _render_document_form_fields(
return {
"name": name_input,
"type": type_input,
"type_options": type_display_to_code,
"type_options": type_display_to_id,
"date": date_input,
"date_raw": date_raw_input,
"location": location_input,
@@ -599,13 +591,9 @@ def _render_related_people_card(document: Document) -> None:
for person in grouped[role_code]:
ui.button(
person.full_name,
on_click=lambda _=None, person_id=person.id: ui.navigate.to(
f"/people/{person_id}"
),
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
icon="person",
).props("flat dense no-caps").classes(
"self-start text-xs font-semibold ui-link-primary"
)
).props("flat dense no-caps").classes("self-start text-xs font-semibold ui-link-primary")
def _render_document_processing_card(document: Document) -> None:
@@ -645,11 +633,12 @@ def _parse_iso_date(value: str | None) -> date | None:
return None
def _resolve_selected_document_type_code(selected_value: Any, type_options: dict[str, str]) -> str | None:
def _resolve_selected_document_type_id(selected_value: Any, type_options: dict[str, str]) -> UUID | None:
candidate = str(selected_value).strip() if selected_value is not None else ""
if not candidate:
return None
return type_options.get(candidate)
selected_id = type_options.get(candidate)
return _parse_uuid(selected_id)
def _resolve_link_role_code(link: Any) -> str | None:
+1 -1
View File
@@ -473,7 +473,7 @@ def _render_linked_documents(person: Person) -> None:
doc = link.document
if doc is None:
continue
role_code = link.role_ref.code if link.role_ref is not None else link.role.value
role_code = link.role_ref.code if link.role_ref is not None else str(link.role)
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
+353
View File
@@ -0,0 +1,353 @@
"""Constrained installation-local Settings page."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from uuid import UUID
from nicegui import ui
from transcription.config import Settings
from transcription.services.documents import DocumentService
from transcription.services.people import PeopleService
from transcription.services.prompts import PromptStore
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def register_page(*, settings: Settings) -> None: # noqa: PLR0915
"""Register the constrained Settings route."""
@ui.page("/settings")
async def settings_page(session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
documents = DocumentService(session_factory=session_factory)
people = PeopleService(session_factory=session_factory)
prompts = PromptStore(settings=settings)
render_navigation_header(current_path="/settings")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): # noqa: PLR1702
page_header(
"Settings",
subtitle="Installation-local registries and future Job prompt defaults.",
)
@ui.refreshable
async def render_document_types() -> None: # noqa: PLR0915
with archival_card("Document Types"):
ui.label("Types are listed alphabetically. Select one row to edit or delete it.").classes(
"text-xs ui-text-muted mb-3"
)
try:
document_types = await documents.list_document_type_summaries()
except Exception as exc: # noqa: BLE001
show_error(exc, title="Document Types unavailable", operation="settings.types.list")
return
if not document_types:
render_empty_state("No Document Types are configured.", extra_classes="mt-3")
rows = [
{
"id": str(item.id),
"label": item.label,
"document_count": item.document_count,
"is_active": item.is_active,
}
for item in document_types
]
table = ui.table(
columns=[
{
"name": "label",
"label": "Label",
"field": "label",
"align": "left",
"sortable": True,
},
{
"name": "document_count",
"label": "Documents",
"field": "document_count",
"align": "right",
"sortable": True,
},
{
"name": "is_active",
"label": "Active",
"field": "is_active",
"align": "center",
},
],
rows=rows,
row_key="id",
selection="single",
pagination={"rowsPerPage": 0, "sortBy": "label"},
).classes("w-full ui-table")
table.add_slot(
"body-cell-is_active",
"""
<q-td :props="props" class="text-center">
<q-checkbox :model-value="props.value" disable dense />
</q-td>
""",
)
async def save_type(
*,
item_id: UUID | None,
label: str,
is_active: bool,
) -> bool:
try:
if item_id is None:
await documents.create_document_type(label=label, is_active=is_active)
else:
await documents.update_document_type(
item_id,
label=label,
is_active=is_active,
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Document Type save failed", operation="settings.types.save")
return False
ui.notify("Document Type saved", type="positive")
render_document_types.refresh()
return True
def open_type_editor(*, creating: bool) -> None:
selected = _selected_table_row(table)
if not creating and selected is None:
ui.notify("Select one Document Type to edit.", type="warning")
return
if creating:
item_id = None
current_label = ""
current_active = True
else:
assert selected is not None
item_id = UUID(str(selected["id"]))
current_label = str(selected["label"])
current_active = bool(selected["is_active"])
with ui.dialog() as dialog, ui.card().classes("w-full max-w-lg ui-card-surface"):
ui.label("Add Document Type" if creating else "Edit Document Type").classes(
"text-lg font-semibold"
)
label_input = ui.input("Label", value=current_label).props("outlined").classes("w-full")
active_input = ui.checkbox("Active", value=current_active)
async def submit() -> None:
saved = await save_type(
item_id=item_id,
label=str(label_input.value or ""),
is_active=bool(active_input.value),
)
if saved:
dialog.close()
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=dialog.close).props("flat")
ui.button("Save", icon="save", on_click=submit).classes("ui-btn-primary")
dialog.open()
async def delete_selected_type() -> None:
selected = _selected_table_row(table)
if selected is None:
ui.notify("Select one Document Type to delete.", type="warning")
return
try:
await documents.delete_document_type(UUID(str(selected["id"])))
except Exception as exc: # noqa: BLE001
show_error(exc, title="Document Type deletion failed", operation="settings.types.delete")
return
ui.notify("Document Type deleted", type="positive")
render_document_types.refresh()
with ui.row().classes("w-full items-center gap-2 mt-3"):
ui.button("Add", icon="add", on_click=lambda: open_type_editor(creating=True)).classes(
"ui-btn-primary"
)
ui.button("Edit", icon="edit", on_click=lambda: open_type_editor(creating=False)).props("flat")
destructive_button("Delete", icon="delete", on_click=delete_selected_type)
@ui.refreshable
async def render_person_roles() -> None:
with archival_card("Person Roles"):
ui.label(
"Codes are permanent. Roles are ordered by label then code; referenced roles cannot be deleted."
).classes("text-xs ui-text-muted mb-3")
try:
roles = await people.list_person_roles(active_only=False)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Roles unavailable", operation="settings.roles.list")
return
with ui.row().classes("w-full items-end gap-2"):
code = ui.input("Stable code").props("dense")
label = ui.input("Label").props("dense")
async def create_role() -> None:
try:
await people.create_person_role(
code=str(code.value or ""),
label=str(label.value or ""),
)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role creation failed", operation="settings.roles.create")
return
ui.notify("Person Role created", type="positive")
render_person_roles.refresh()
ui.button("Add role", icon="add", on_click=create_role).classes("ui-btn-primary")
if not roles:
render_empty_state("No Person Roles are configured.", extra_classes="mt-3")
for role in roles:
is_referenced = await people.is_person_role_referenced(role.id)
_person_role_row(
role,
is_referenced=is_referenced,
on_save=_save_role(people, role.id, render_person_roles.refresh),
on_delete=_delete_role(people, role.id, render_person_roles.refresh),
)
@ui.refreshable
def render_prompts() -> None:
with archival_card("Prompts"):
ui.label("Only existing Markdown prompts can be edited. Changes affect future Jobs only.").classes(
"text-xs ui-text-muted mb-3"
)
try:
summaries = prompts.list_prompts()
except Exception as exc: # noqa: BLE001
show_error(exc, title="Prompts unavailable", operation="settings.prompts.list")
return
if not summaries:
render_empty_state("No editable Markdown prompts were found.")
for summary in summaries:
try:
content = prompts.read_prompt(summary.name)
except Exception as exc: # noqa: BLE001
show_error(exc, title=f"{summary.name} unavailable", operation="settings.prompts.read")
continue
with ui.column().classes("w-full gap-2 py-3 ui-header-divider"):
with section_header_row():
title = f"{summary.name} (default)" if summary.is_default else summary.name
ui.label(title).classes("font-medium")
ui.label(
"Previous version available" if summary.has_backup else "No previous version"
).classes("text-xs ui-text-muted")
editor = (
ui.textarea("Markdown prompt", value=content)
.props("outlined autogrow")
.classes("w-full")
)
def save_prompt(
*,
name: str = summary.name,
field: Any = editor,
) -> None:
try:
prompts.write_prompt(name, str(field.value or ""))
except Exception as exc: # noqa: BLE001
show_error(exc, title="Prompt save failed", operation="settings.prompts.write")
return
ui.notify(f"{name} saved for future Jobs", type="positive")
render_prompts.refresh()
def recover_prompt(*, name: str = summary.name) -> None:
try:
prompts.recover_prompt(name)
except Exception as exc: # noqa: BLE001
show_error(
exc,
title="Prompt recovery failed",
operation="settings.prompts.recover",
)
return
ui.notify(f"{name} restored from its previous version", type="positive")
render_prompts.refresh()
with ui.row().classes("items-center gap-2"):
ui.button("Save prompt", icon="save", on_click=save_prompt).classes("ui-btn-primary")
recovery = ui.button(
"Restore previous version",
icon="restore",
on_click=recover_prompt,
).props("flat")
if not summary.has_backup:
recovery.props("disable")
await render_document_types()
await render_person_roles()
render_prompts()
def _selected_table_row(table: Any) -> dict[str, Any] | None:
selected = table.selected or []
if len(selected) != 1:
return None
return selected[0]
def _person_role_row(
role: Any,
*,
is_referenced: bool,
on_save: Callable[..., Any],
on_delete: Callable[..., Any],
) -> None:
with ui.row().classes("w-full items-end gap-2 py-2 ui-header-divider"):
ui.input("Code", value=role.code).props("dense readonly").classes("min-w-44")
label = ui.input("Label", value=role.label).props("dense").classes("grow")
is_active = ui.switch("Active", value=role.is_active)
ui.button(
"Save",
icon="save",
on_click=lambda: on_save(label=str(label.value or ""), is_active=bool(is_active.value)),
).props("flat").classes("ui-link-primary")
if not is_referenced:
destructive_button("Delete", icon="delete", on_click=on_delete, extra_classes="text-xs")
def _save_role(
service: PeopleService,
item_id: UUID,
refresh: Callable[[], Any],
) -> Callable[..., Any]:
async def save(*, label: str, is_active: bool) -> None:
try:
await service.update_person_role(item_id, label=label, is_active=is_active)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role update failed", operation="settings.roles.update")
return
ui.notify("Person Role updated", type="positive")
refresh()
return save
def _delete_role(
service: PeopleService,
item_id: UUID,
refresh: Callable[[], Any],
) -> Callable[[], Any]:
async def delete() -> None:
try:
await service.delete_person_role(item_id)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Person Role deletion failed", operation="settings.roles.delete")
return
ui.notify("Person Role deleted", type="positive")
refresh()
return delete
+19 -11
View File
@@ -10,6 +10,7 @@ from uuid import UUID
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlmodel import select
from transcription.api.errors import register_error_handlers
from transcription.api.v4_documents import get_document_service
@@ -21,6 +22,7 @@ from transcription.db import create_all
from transcription.db.engine import get_database_url
from transcription.db.engine import get_engine
from transcription.db.models import Document
from transcription.db.models import DocumentType
from transcription.db.models import Person
from transcription.db.session import dispose_session_factory
from transcription.db.session import session_scope
@@ -45,6 +47,12 @@ def _seed_document_and_person(
return asyncio.run(_seed())
async def _document_type_id(*, db_url: str, label: str) -> UUID:
async with session_scope(database_url=db_url) as session:
document_type = (await session.exec(select(DocumentType).where(DocumentType.label == label))).one()
return document_type.id
@contextmanager
def _v4_api_client(tmp_path: Path, *, db_filename: str) -> Generator[tuple[TestClient, str]]:
settings = Settings(
@@ -88,8 +96,8 @@ def test_list_document_types_returns_seeded_registry(tmp_path):
assert response.status_code == 200
payload = response.json()
codes = {item["code"] for item in payload}
assert {"letter", "record", "memo"}.issubset(codes)
labels = {item["label"] for item in payload}
assert {"Letter", "Record", "Memo"}.issubset(labels)
def test_list_person_roles_returns_seeded_registry(tmp_path):
@@ -102,37 +110,37 @@ def test_list_person_roles_returns_seeded_registry(tmp_path):
assert {"author", "recipient", "mentioned"}.issubset(codes)
def test_set_document_type_by_code_updates_canonical_fields(tmp_path):
def test_set_document_type_by_id_updates_canonical_field(tmp_path):
with _v4_api_client(tmp_path, db_filename="api-doc-type.db") as (client, db_url):
document_id, _ = _seed_document_and_person(db_url=db_url)
type_id = asyncio.run(_document_type_id(db_url=db_url, label="Record"))
response = client.put(
f"/api/v4/documents/{document_id}/type",
json={"document_type_code": "record"},
json={"document_type_id": str(type_id)},
)
assert response.status_code == 200
payload = response.json()
assert payload["document_id"] == str(document_id)
assert payload["document_type_id"] is not None
assert payload["document_type_code"] == "record"
assert payload["document_type_id"] == str(type_id)
def test_document_type_payload_requires_exactly_one_selector(tmp_path):
def test_document_type_payload_requires_uuid_only(tmp_path):
with _v4_api_client(tmp_path, db_filename="api-doc-type-validation.db") as (client, db_url):
document_id, _ = _seed_document_and_person(db_url=db_url)
missing = client.put(f"/api/v4/documents/{document_id}/type", json={})
conflicting = client.put(
invalid = client.put(
f"/api/v4/documents/{document_id}/type",
json={"document_type_id": str(UUID(int=1)), "document_type_code": "record"},
json={"document_type_id": "record"},
)
unexpected = client.put(
f"/api/v4/documents/{document_id}/type",
json={"document_type_code": "record", "ignored": True},
json={"document_type_id": str(UUID(int=1)), "ignored": True},
)
assert missing.status_code == 422
assert conflicting.status_code == 422
assert invalid.status_code == 422
assert unexpected.status_code == 422
+6 -17
View File
@@ -11,7 +11,6 @@ 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 DocumentType
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import PersonRole
@@ -26,12 +25,13 @@ from transcription.services.people import PeopleService
@pytest.mark.asyncio
async def test_read_document_detail_allows_missing_sources(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
document_type = await service.create_document_type(label="Letter")
created = await service.create_document(
Document(
id=uuid4(),
name="detail-doc",
document_type="letter",
document_type_id=document_type.id,
)
)
@@ -41,7 +41,7 @@ async def test_read_document_detail_allows_missing_sources(default_session_facto
assert detail.sources == []
assert detail.document_type_id is not None
assert detail.document_type_ref is not None
assert detail.document_type_ref.code == "letter"
assert detail.document_type_ref.label == "Letter"
@pytest.mark.asyncio
@@ -52,7 +52,6 @@ async def test_update_document_refreshes_updated_timestamp(default_session_facto
Document(
id=uuid4(),
name="timestamp-doc",
document_type="letter",
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
)
)
@@ -74,7 +73,6 @@ async def test_delete_document_blocks_when_dependencies_exist(default_session_fa
Document(
id=uuid4(),
name="blocked-delete",
document_type="record",
)
)
@@ -106,7 +104,6 @@ async def test_delete_document_succeeds_when_unlinked(default_session_factory, t
Document(
id=uuid4(),
name="free-delete",
document_type="memo",
)
)
@@ -132,7 +129,6 @@ async def test_delete_document_removes_person_links(default_session_factory, tmp
Document(
id=uuid4(),
name="person-linked-delete",
document_type="memo",
)
)
person = await people_service.create_person(Person(full_name="Linked Person"))
@@ -171,7 +167,6 @@ async def test_delete_document_removes_populated_storage_tree(default_session_fa
Document(
id=uuid4(),
name="tree-delete",
document_type="memo",
)
)
@@ -198,7 +193,6 @@ async def test_read_person_detail_loads_document_links(default_session_factory):
Document(
id=uuid4(),
name="linked-doc",
document_type="letter",
)
)
person = await people_service.create_person(Person(full_name="Linked Person"))
@@ -246,7 +240,6 @@ async def test_delete_person_removes_links_when_linked_documents_exist(default_s
Document(
id=uuid4(),
name="block-person-delete-doc",
document_type="record",
)
)
person = await service.create_person(Person(full_name="Blocked Person"))
@@ -280,16 +273,12 @@ async def test_delete_person_succeeds_when_unlinked(default_session_factory):
@pytest.mark.asyncio
async def test_create_document_reuses_existing_document_type_registry(default_session_factory):
async def test_create_document_uses_existing_document_type_registry(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
async with service._session_scope() as session:
existing = DocumentType(code="record", label="Record")
session.add(existing)
await session.commit()
await session.refresh(existing)
existing = await service.create_document_type(label="Record")
created = await service.create_document(Document(id=uuid4(), name="typed-doc", document_type="record"))
created = await service.create_document(Document(id=uuid4(), name="typed-doc", document_type_id=existing.id))
assert created.document_type_id is not None
assert created.document_type_id == existing.id
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import pytest
from transcription.config import Settings
from transcription.errors import ErrorCategory
from transcription.services.prompts import PromptStore
from transcription.services.prompts import PromptStoreError
@pytest.fixture
def prompt_store(tmp_path):
prompt_dir = tmp_path / "prompts"
prompt_dir.mkdir()
(prompt_dir / "transcribe_document.md").write_text("Original prompt\n", encoding="utf-8")
(prompt_dir / "notes.txt").write_text("Not a prompt\n", encoding="utf-8")
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
return PromptStore(settings=settings), prompt_dir
def test_list_and_read_existing_markdown_prompts(prompt_store):
store, _ = prompt_store
prompts = store.list_prompts()
assert [item.name for item in prompts] == ["transcribe_document.md"]
assert prompts[0].is_default is True
assert prompts[0].has_backup is False
assert store.read_prompt("transcribe_document.md") == "Original prompt\n"
def test_write_rotates_single_backup_and_recovery_swaps_versions(prompt_store):
store, prompt_dir = prompt_store
prompt_path = prompt_dir / "transcribe_document.md"
backup_path = prompt_dir / "transcribe_document.md.bak"
store.write_prompt(prompt_path.name, "Second prompt")
assert prompt_path.read_text(encoding="utf-8") == "Second prompt\n"
assert backup_path.read_text(encoding="utf-8") == "Original prompt\n"
store.write_prompt(prompt_path.name, "Third prompt")
assert prompt_path.read_text(encoding="utf-8") == "Third prompt\n"
assert backup_path.read_text(encoding="utf-8") == "Second prompt\n"
assert store.list_prompts()[0].has_backup is True
store.recover_prompt(prompt_path.name)
assert prompt_path.read_text(encoding="utf-8") == "Second prompt\n"
assert backup_path.read_text(encoding="utf-8") == "Third prompt\n"
assert list(prompt_dir.glob("*.tmp")) == []
@pytest.mark.parametrize(
"name",
[
"../outside.md",
"nested/prompt.md",
r"nested\prompt.md",
"prompt.txt",
],
)
def test_prompt_names_are_constrained(prompt_store, name):
store, _ = prompt_store
with pytest.raises(PromptStoreError) as caught:
store.read_prompt(name)
assert caught.value.category == ErrorCategory.VALIDATION
def test_prompt_creation_and_empty_content_are_rejected(prompt_store):
store, _ = prompt_store
with pytest.raises(PromptStoreError) as missing:
store.write_prompt("new_prompt.md", "content")
with pytest.raises(PromptStoreError) as empty:
store.write_prompt("transcribe_document.md", " \n")
assert missing.value.category == ErrorCategory.NOT_FOUND
assert empty.value.category == ErrorCategory.VALIDATION
def test_recovery_requires_a_backup(prompt_store):
store, _ = prompt_store
with pytest.raises(PromptStoreError) as caught:
store.recover_prompt("transcribe_document.md")
assert caught.value.category == ErrorCategory.NOT_FOUND
def test_failed_active_replace_preserves_complete_prompt(prompt_store, monkeypatch):
store, prompt_dir = prompt_store
prompt_path = prompt_dir / "transcribe_document.md"
original_replace = type(prompt_path).replace
def fail_active_replace(path, target):
if target == prompt_path:
raise OSError("simulated replace failure")
return original_replace(path, target)
monkeypatch.setattr(type(prompt_path), "replace", fail_active_replace)
with pytest.raises(PromptStoreError) as caught:
store.write_prompt(prompt_path.name, "Replacement prompt")
assert caught.value.category == ErrorCategory.INFRA_PERSISTENT
assert prompt_path.read_text(encoding="utf-8") == "Original prompt\n"
assert (prompt_dir / "transcribe_document.md.bak").read_text(encoding="utf-8") == "Original prompt\n"
assert list(prompt_dir.glob(".*.tmp")) == []
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
from uuid import uuid4
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.errors import ErrorCategory
from transcription.services.documents import DocumentService
from transcription.services.documents import DocumentTypeError
from transcription.services.people import PeopleService
from transcription.services.people import PersonRoleError
@pytest.mark.asyncio
async def test_document_type_maintenance_uses_alphabetical_labels(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
second = await service.create_document_type(label="Court Record")
first = await service.create_document_type(label="Letter")
updated = await service.update_document_type(
second.id,
label="Archive",
is_active=False,
)
assert updated.label == "Archive"
assert updated.is_active is False
assert [item.id for item in await service.list_document_types(active_only=False)] == [second.id, first.id]
assert [item.id for item in await service.list_document_types()] == [first.id]
summaries = await service.list_document_type_summaries()
assert [item.label for item in summaries] == ["Archive", "Letter"]
assert [item.document_count for item in summaries] == [0, 0]
@pytest.mark.asyncio
async def test_document_type_duplicate_normalized_label_is_conflict(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
await service.create_document_type(label="Letter")
with pytest.raises(DocumentTypeError) as caught:
await service.create_document_type(label=" letter ")
assert caught.value.category == ErrorCategory.CONFLICT
@pytest.mark.asyncio
async def test_document_type_delete_allows_unreferenced_and_blocks_referenced(default_session_factory):
service = DocumentService(session_factory=default_session_factory)
unused = await service.create_document_type(label="Unused")
referenced = await service.create_document_type(label="Record")
await service.create_document(Document(id=uuid4(), name="Typed document", document_type_id=referenced.id))
summaries = {item.id: item for item in await service.list_document_type_summaries()}
assert summaries[referenced.id].document_count == 1
await service.delete_document_type(unused.id)
with pytest.raises(DocumentTypeError) as caught:
await service.delete_document_type(referenced.id)
assert caught.value.category == ErrorCategory.CONFLICT
relabeled = await service.update_document_type(
referenced.id,
label="Referenced Record",
is_active=False,
)
assert relabeled.label == "Referenced Record"
assert relabeled.is_active is False
@pytest.mark.asyncio
async def test_person_role_maintenance_orders_by_label_then_code(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")
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 [item.id for item in await service.list_person_roles()] == [first.id]
@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")
document = await documents.create_document(Document(name="Role document"))
person = await people.create_person(Person(full_name="Role Person"))
await people.create_document_person(
DocumentPerson(
document_id=document.id,
person_id=person.id,
role_id=referenced.id,
role=DocumentPersonRole.AUTHOR,
)
)
await people.delete_person_role(unused.id)
with pytest.raises(PersonRoleError) as caught:
await people.delete_person_role(referenced.id)
assert caught.value.category == ErrorCategory.CONFLICT
relabeled = await people.update_person_role(referenced.id, label="Creator", is_active=False)
assert relabeled.label == "Creator"
assert relabeled.is_active is False
@pytest.mark.asyncio
async def test_person_role_duplicate_code_is_conflict(default_session_factory):
service = PeopleService(session_factory=default_session_factory)
await service.create_person_role(code="author", label="Author")
with pytest.raises(PersonRoleError) as caught:
await service.create_person_role(code=" AUTHOR ", label="Duplicate")
assert caught.value.category == ErrorCategory.CONFLICT
@pytest.mark.asyncio
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")
document = await documents.create_document(Document(name="Witnessed document"))
person = await people.create_person(Person(full_name="Archive Witness"))
link = await people.add_document_person_link(
document_id=document.id,
person_id=person.id,
role_id=role.id,
)
loaded = await people.list_document_people(document_id=document.id)
assert link.role == "witness"
assert loaded[0].role_ref is not None
assert loaded[0].role_ref.code == "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)
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,
)
)
await session.commit()
assert await people.is_person_role_referenced(role.id) is True
with pytest.raises(PersonRoleError) as caught:
await people.delete_person_role(role.id)
assert caught.value.category == ErrorCategory.CONFLICT
+93 -8
View File
@@ -1,5 +1,7 @@
"""Tests for the database runtime and V2 schema bootstrap behavior."""
from uuid import uuid4
import pytest
from sqlalchemy import inspect
from sqlalchemy import text
@@ -97,10 +99,10 @@ async def test_create_all_seeds_default_registry_rows(tmp_path):
await create_all(engine=runtime.engine)
async with AsyncSession(runtime.engine, expire_on_commit=False) as session:
role_codes = set((await session.exec(select(PersonRole.code))).all())
type_codes = set((await session.exec(select(DocumentType.code))).all())
type_labels = set((await session.exec(select(DocumentType.label))).all())
assert {"author", "recipient", "mentioned"}.issubset(role_codes)
assert {"letter", "record", "memo"}.issubset(type_codes)
assert {"Letter", "Record", "Memo"}.issubset(type_labels)
finally:
await dispose_database_runtime()
@@ -130,9 +132,94 @@ async def test_create_all_upgrades_existing_person_table_for_family_search(tmp_p
)
assert "family_search_id" in columns
assert any(
index["column_names"] == ["family_search_id"] and index["unique"] for index in indexes
)
assert any(index["column_names"] == ["family_search_id"] and index["unique"] for index in indexes)
finally:
await dispose_database_runtime()
@pytest.mark.asyncio
async def test_upgrade_migrates_document_types_to_uuid_only_identity(tmp_path):
settings = Settings(
openrouter_api_key="test-key",
database=SqliteSettings(path=str(tmp_path / "type-upgrade.db")),
environment="test",
)
runtime = initialize_database_runtime(settings=settings)
type_id = uuid4().hex
document_id = uuid4().hex
try:
async with runtime.engine.begin() as connection:
await connection.execute(
text(
"CREATE TABLE document_type ("
"id CHAR(32) PRIMARY KEY NOT NULL, "
"code VARCHAR NOT NULL, "
"label VARCHAR NOT NULL, "
"is_active BOOLEAN NOT NULL, "
"sort_order INTEGER NOT NULL, "
"created_at DATETIME NOT NULL, "
"updated_at DATETIME NOT NULL"
")"
)
)
await connection.execute(text("CREATE UNIQUE INDEX ix_document_type_code ON document_type (code)"))
await connection.execute(
text(
"CREATE TABLE document ("
"id CHAR(32) PRIMARY KEY NOT NULL, "
"name VARCHAR NOT NULL, "
"document_type_id CHAR(32), "
"document_type VARCHAR, "
"document_date DATE, "
"document_date_raw VARCHAR, "
"location_created VARCHAR, "
"notes VARCHAR, "
"archive_identifier VARCHAR, "
"created_at DATETIME NOT NULL, "
"updated_at DATETIME NOT NULL"
")"
)
)
await connection.execute(
text(
"INSERT INTO document_type "
"(id, code, label, is_active, sort_order, created_at, updated_at) "
"VALUES (:id, 'letter', 'Letter', 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{"id": type_id},
)
await connection.execute(
text(
"INSERT INTO document "
"(id, name, document_type_id, document_type, created_at, updated_at) "
"VALUES (:id, 'Legacy Letter', NULL, 'letter', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{"id": document_id},
)
await upgrade_schema(engine=runtime.engine)
async with runtime.engine.connect() as connection:
type_columns, document_columns, migrated_type_id, normalized_label = await connection.run_sync(
lambda sync_connection: (
{column["name"] for column in inspect(sync_connection).get_columns("document_type")},
{column["name"] for column in inspect(sync_connection).get_columns("document")},
sync_connection.execute(
text("SELECT document_type_id FROM document WHERE id = :id"),
{"id": document_id},
).scalar_one(),
sync_connection.execute(
text("SELECT normalized_label FROM document_type WHERE id = :id"),
{"id": type_id},
).scalar_one(),
)
)
assert {"code", "sort_order"}.isdisjoint(type_columns)
assert "document_type" not in document_columns
assert migrated_type_id == type_id
assert normalized_label == "letter"
finally:
await dispose_database_runtime()
@@ -175,9 +262,7 @@ async def test_v42_upgrade_adds_evidence_tables_without_rewriting_legacy_snapsho
async with runtime.engine.connect() as connection:
table_names = set(await connection.run_sync(lambda c: inspect(c).get_table_names()))
legacy_snapshot = (
await connection.execute(
text("SELECT raw_api_response FROM job_source WHERE id = 'link-1'")
)
await connection.execute(text("SELECT raw_api_response FROM job_source WHERE id = 'link-1'"))
).scalar_one()
assert {"execution_attempt", "processing_artifact"}.issubset(table_names)
+8 -8
View File
@@ -27,8 +27,8 @@ def _make_document(**overrides) -> Document:
return Document(**defaults)
def _persist_document_type(session, *, code: str = "letter", label: str = "Letter") -> DocumentType:
document_type = DocumentType(code=code, label=label)
def _persist_document_type(session, *, label: str = "Letter") -> DocumentType:
document_type = DocumentType(label=label, normalized_label=label.strip().casefold())
session.add(document_type)
session.commit()
session.refresh(document_type)
@@ -45,7 +45,7 @@ def _persist_person_role(session, *, code: str = "author", label: str = "Author"
def _persist_document(session) -> Document:
document_type = _persist_document_type(session)
document = _make_document(document_type_id=document_type.id, document_type=document_type.code)
document = _make_document(document_type_id=document_type.id)
session.add(document)
session.commit()
session.refresh(document)
@@ -117,8 +117,8 @@ class TestDocumentModel:
assert document.updated_at is not None
def test_can_reference_document_type_registry(self, session):
document_type = _persist_document_type(session, code="record", label="Record")
document = _make_document(document_type_id=document_type.id, document_type=document_type.code)
document_type = _persist_document_type(session, label="Record")
document = _make_document(document_type_id=document_type.id)
session.add(document)
session.commit()
session.refresh(document)
@@ -264,9 +264,9 @@ class TestRegistryModels:
with pytest.raises(IntegrityError):
session.commit()
def test_document_type_code_is_unique(self, session):
_persist_document_type(session, code="journal", label="Journal")
duplicate = DocumentType(code="journal", label="Journal Duplicate")
def test_document_type_normalized_label_is_unique(self, session):
_persist_document_type(session, label="Journal")
duplicate = DocumentType(label=" journal ", normalized_label="journal")
session.add(duplicate)
with pytest.raises(IntegrityError):
session.commit()
+14 -12
View File
@@ -4,11 +4,13 @@ from datetime import date
import pytest
import pytest_asyncio
from sqlmodel import select
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import DocumentPersonRole
from transcription.db.models import DocumentType
from transcription.db.models import Job
from transcription.db.models import Person
from transcription.db.models import Source
@@ -20,13 +22,14 @@ from transcription.db.models import Source
async def seed_person_and_document():
"""Seed a Person and Document linked by DocumentPerson role."""
async with session_scope() as session:
letter_type = (await session.exec(select(DocumentType).where(DocumentType.label == "Letter"))).one()
person = Person(full_name="Zenna Cochran")
session.add(person)
await session.flush()
doc = Document(
name="Letter from Hig",
document_type="letter",
document_type_id=letter_type.id,
archive_identifier="ZC-1924-001",
)
session.add(doc)
@@ -63,7 +66,12 @@ class TestDocumentsPageRendering:
_, client = app_client
async with session_scope() as session:
doc = Document(name="1924 Postcard", document_type="postcard", archive_identifier="PC-001")
postcard_type = (await session.exec(select(DocumentType).where(DocumentType.label == "Postcard"))).one()
doc = Document(
name="1924 Postcard",
document_type_id=postcard_type.id,
archive_identifier="PC-001",
)
session.add(doc)
await session.commit()
@@ -71,7 +79,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200
assert "1924 Postcard" in response.text
assert "postcard" in response.text
assert "Postcard" in response.text
assert "PC-001" in response.text
assert "Document Date" in response.text
assert "Author" in response.text
@@ -106,9 +114,7 @@ class TestDocumentsPageRendering:
assert "Hig - Albert Edward Higgins (1885)" in response.text
@pytest.mark.asyncio
async def test_document_detail_page_renders_bento_grid_and_metadata(
self, app_client, seed_person_and_document
):
async def test_document_detail_page_renders_bento_grid_and_metadata(self, app_client, seed_person_and_document):
_, client = app_client
doc_id, _ = seed_person_and_document
@@ -143,9 +149,7 @@ class TestDocumentsPageRendering:
assert f"Job ID: {job_id}" in response.text
@pytest.mark.asyncio
async def test_document_edit_page_prefills_existing_values(
self, app_client, seed_person_and_document
):
async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document):
_, client = app_client
doc_id, _ = seed_person_and_document
@@ -157,9 +161,7 @@ class TestDocumentsPageRendering:
assert "ZC-1924-001" in response.text
@pytest.mark.asyncio
async def test_document_delete_page_blocks_deletion_when_dependencies_exist(
self, app_client
):
async def test_document_delete_page_blocks_deletion_when_dependencies_exist(self, app_client):
_, client = app_client
async with session_scope() as session:
+3 -9
View File
@@ -84,9 +84,7 @@ class TestJobsPageRendering:
assert "Preselected Journal Entry" in response.text
@pytest.mark.asyncio
async def test_job_detail_page_renders_logistics_and_links(
self, app_client, seed_document_with_unlinked_job
):
async def test_job_detail_page_renders_logistics_and_links(self, app_client, seed_document_with_unlinked_job):
_, client = app_client
_, job_id = seed_document_with_unlinked_job
@@ -102,9 +100,7 @@ class TestJobsPageRendering:
assert "updates automatically while the job is active" in response.text
@pytest.mark.asyncio
async def test_job_cancel_page_renders_confirmation(
self, app_client, seed_document_with_unlinked_job
):
async def test_job_cancel_page_renders_confirmation(self, app_client, seed_document_with_unlinked_job):
_, client = app_client
_, job_id = seed_document_with_unlinked_job
@@ -116,9 +112,7 @@ class TestJobsPageRendering:
assert "Cancel job" in response.text
@pytest.mark.asyncio
async def test_job_resubmit_page_renders_counts(
self, app_client, seed_job
):
async def test_job_resubmit_page_renders_counts(self, app_client, seed_job):
_, client = app_client
job_id = await seed_job(
filename="failed-resubmit.png",
+2 -1
View File
@@ -31,6 +31,7 @@ class TestNavigationAndMounts:
"/ui/people",
"/ui/sources",
"/ui/jobs",
"/ui/settings",
],
)
def test_registered_pages_render_successfully(self, app_client, route_path: str):
@@ -39,4 +40,4 @@ class TestNavigationAndMounts:
response = client.get(route_path)
assert response.status_code == 200
assert "html" in response.headers.get("content-type", "").lower()
assert "html" in response.headers.get("content-type", "").lower()
+2
View File
@@ -16,9 +16,11 @@ class TestPageRegistration:
people_response = client.get("/ui/people")
sources_response = client.get("/ui/sources")
jobs_response = client.get("/ui/jobs")
settings_response = client.get("/ui/settings")
assert homepage_response.status_code == 200
assert documents_response.status_code == 200
assert people_response.status_code == 200
assert sources_response.status_code == 200
assert jobs_response.status_code == 200
assert settings_response.status_code == 200
+8 -28
View File
@@ -48,9 +48,7 @@ class TestSourceModelProperties:
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
assert source is not None
# Validate computed properties
@@ -159,9 +157,7 @@ class TestSourcesPageRendering:
assert "job-page.png" in response.text
@pytest.mark.asyncio
async def test_sources_page_job_context_shows_job_source_status_and_error_detail(
self, app_client, seed_job
):
async def test_sources_page_job_context_shows_job_source_status_and_error_detail(self, app_client, seed_job):
_, client = app_client
job_id = await seed_job(
filename="job-failed-page.png",
@@ -178,17 +174,9 @@ class TestSourcesPageRendering:
assert "Provider timed out" in response.text
@pytest.mark.asyncio
async def test_source_detail_page_renders_preview_and_revision_box(
self, app_client, seed_job
):
async def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
_, client = app_client
fixture_path = (
Path(__file__).resolve().parents[1]
/ "fixtures"
/ "images"
/ "valid"
/ "small_png.png"
)
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png"
job_id = await seed_job(
filename="detail-source.png",
transcription_text="original transcription text",
@@ -201,9 +189,7 @@ class TestSourcesPageRendering:
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
assert source is not None
source_id = str(source.id)
@@ -232,9 +218,7 @@ class TestSourcesPageRendering:
async with session_scope(session_factory=app.state.runtime.session_factory) as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
assert source is not None
source_id = source.id
@@ -268,18 +252,14 @@ class TestSourcesPageRendering:
assert "Derived Artifacts" in response.text
@pytest.mark.asyncio
async def test_source_delete_page_blocks_when_source_is_job_linked(
self, app_client, seed_job
):
async def test_source_delete_page_blocks_when_source_is_job_linked(self, app_client, seed_job):
_, client = app_client
job_id = await seed_job(filename="linked-source.png", transcription_text="linked text")
async with session_scope() as session:
job = await session.get(Job, job_id)
assert job is not None
source = (
await session.exec(select(Source).where(Source.document_id == job.document_id))
).first()
source = (await session.exec(select(Source).where(Source.document_id == job.document_id))).first()
assert source is not None
source_id = str(source.id)