diff --git a/docs/ver4.3/implementation_plan_v4_3.md b/docs/ver4.3/implementation_plan_v4_3.md index 6526115..ea3ba81 100644 --- a/docs/ver4.3/implementation_plan_v4_3.md +++ b/docs/ver4.3/implementation_plan_v4_3.md @@ -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. diff --git a/docs/ver4.3/scope_boundary_v4_3.md b/docs/ver4.3/scope_boundary_v4_3.md index 4e9e3ce..d497fb4 100644 --- a/docs/ver4.3/scope_boundary_v4_3.md +++ b/docs/ver4.3/scope_boundary_v4_3.md @@ -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. diff --git a/docs/ver4/architecture_v4.md b/docs/ver4/architecture_v4.md index 3888258..db0e046 100644 --- a/docs/ver4/architecture_v4.md +++ b/docs/ver4/architecture_v4.md @@ -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 diff --git a/docs/ver4/implementation_plan_v4.md b/docs/ver4/implementation_plan_v4.md index 8d5bf9c..70f31d9 100644 --- a/docs/ver4/implementation_plan_v4.md +++ b/docs/ver4/implementation_plan_v4.md @@ -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 diff --git a/docs/ver4/requirements_v4.md b/docs/ver4/requirements_v4.md index 03a3890..d32b417 100644 --- a/docs/ver4/requirements_v4.md +++ b/docs/ver4/requirements_v4.md @@ -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. diff --git a/docs/ver4/schema_v4.md b/docs/ver4/schema_v4.md index ddd9563..aaf0bc4 100644 --- a/docs/ver4/schema_v4.md +++ b/docs/ver4/schema_v4.md @@ -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. diff --git a/docs/ver4/scope_boundary_v4.md b/docs/ver4/scope_boundary_v4.md index 1c4bb55..8d94943 100644 --- a/docs/ver4/scope_boundary_v4.md +++ b/docs/ver4/scope_boundary_v4.md @@ -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 diff --git a/src/transcription/api/v4_documents.py b/src/transcription/api/v4_documents.py index 020df16..081a79d 100644 --- a/src/transcription/api/v4_documents.py +++ b/src/transcription/api/v4_documents.py @@ -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) diff --git a/src/transcription/db/models.py b/src/transcription/db/models.py index c4f3de3..bec8088 100644 --- a/src/transcription/db/models.py +++ b/src/transcription/db/models.py @@ -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)) diff --git a/src/transcription/db/operations.py b/src/transcription/db/operations.py index b4bee09..79e028d 100644 --- a/src/transcription/db/operations.py +++ b/src/transcription/db/operations.py @@ -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() diff --git a/src/transcription/services/__init__.py b/src/transcription/services/__init__.py index 27cc6e2..7ed9a35 100644 --- a/src/transcription/services/__init__.py +++ b/src/transcription/services/__init__.py @@ -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) diff --git a/src/transcription/services/documents.py b/src/transcription/services/documents.py index bd28f88..b29a419 100644 --- a/src/transcription/services/documents.py +++ b/src/transcription/services/documents.py @@ -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 diff --git a/src/transcription/services/people.py b/src/transcription/services/people.py index 76ddd19..3dc62e5 100644 --- a/src/transcription/services/people.py +++ b/src/transcription/services/people.py @@ -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: diff --git a/src/transcription/services/prompts.py b/src/transcription/services/prompts.py new file mode 100644 index 0000000..b0b18be --- /dev/null +++ b/src/transcription/services/prompts.py @@ -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.", + ) diff --git a/src/transcription/ui/__init__.py b/src/transcription/ui/__init__.py index fae2dbe..ad27e35 100644 --- a/src/transcription/ui/__init__.py +++ b/src/transcription/ui/__init__.py @@ -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) diff --git a/src/transcription/ui/components/app_shell.py b/src/transcription/ui/components/app_shell.py index e6584e8..bfc7491 100644 --- a/src/transcription/ui/components/app_shell.py +++ b/src/transcription/ui/components/app_shell.py @@ -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 diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index 013173c..0c577b6 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -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: diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 88de542..cd6e614 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -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") diff --git a/src/transcription/ui/pages/settings_page.py b/src/transcription/ui/pages/settings_page.py new file mode 100644 index 0000000..702ec64 --- /dev/null +++ b/src/transcription/ui/pages/settings_page.py @@ -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", + """ + + + + """, + ) + + 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 diff --git a/tests/api/test_v4_documents.py b/tests/api/test_v4_documents.py index f7f336c..c9d99c7 100644 --- a/tests/api/test_v4_documents.py +++ b/tests/api/test_v4_documents.py @@ -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 diff --git a/tests/services/test_document_service.py b/tests/services/test_document_service.py index 049f2ca..5caf10f 100644 --- a/tests/services/test_document_service.py +++ b/tests/services/test_document_service.py @@ -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 diff --git a/tests/services/test_prompt_store.py b/tests/services/test_prompt_store.py new file mode 100644 index 0000000..ab58926 --- /dev/null +++ b/tests/services/test_prompt_store.py @@ -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")) == [] diff --git a/tests/services/test_settings_services.py b/tests/services/test_settings_services.py new file mode 100644 index 0000000..2204bca --- /dev/null +++ b/tests/services/test_settings_services.py @@ -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 diff --git a/tests/test_db.py b/tests/test_db.py index 7186fd9..2409ba7 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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) diff --git a/tests/test_models.py b/tests/test_models.py index b20314d..6fdc89f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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() diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index e525e3b..69f8d81 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -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: diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index 0743002..858dd96 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -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", diff --git a/tests/ui/test_navigation_and_mounts.py b/tests/ui/test_navigation_and_mounts.py index 9c612d8..f4434bc 100644 --- a/tests/ui/test_navigation_and_mounts.py +++ b/tests/ui/test_navigation_and_mounts.py @@ -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() \ No newline at end of file + assert "html" in response.headers.get("content-type", "").lower() diff --git a/tests/ui/test_pages_registration.py b/tests/ui/test_pages_registration.py index 2e3ace6..73ba460 100644 --- a/tests/ui/test_pages_registration.py +++ b/tests/ui/test_pages_registration.py @@ -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 diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py index 2c75988..b6d76c6 100644 --- a/tests/ui/test_sources_page.py +++ b/tests/ui/test_sources_page.py @@ -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)