doc updates for pydantic

This commit is contained in:
John Lancaster
2026-07-31 10:12:34 -05:00
parent ec6617a1c4
commit 1fa5eb1127
4 changed files with 547 additions and 275 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ This application is a family history archival and transcription platform. Its pr
## Technical Stack & Architecture
* **Database:** PostgreSQL 13+ with native `UUID` (`gen_random_uuid()`) and `JSONB` columns.
* **Backend Runtime / Concurrency:** Python utilizing `asyncio` for concurrent HTTP API calls to AI providers, with strict rate-limiting via `asyncio.Semaphore`.
* **Validation & Types:** TypeScript with **Zod** schema definitions. Incoming AI responses must be parsed and validated with Zod schemas *before* database insertion.
* **ORM / Database Access:** Raw parameterized SQL queries or lightweight query builders (e.g., Kysely/Prisma) respecting PostgreSQL native types.
* **Validation & Types:** Python with **Pydantic** model definitions. Incoming AI responses must be parsed and validated with Pydantic models *before* database insertion.
* **ORM / Database Access:** SQLModel and SQLAlchemy, using parameterized statements and PostgreSQL-native types.
---
+408
View File
@@ -0,0 +1,408 @@
# SQLModel Table Models
Each V2 table is represented by one `SQLModel` class. Because `SQLModel` is built on Pydantic and SQLAlchemy, these classes provide application validation and PostgreSQL mappings without parallel row and create models.
Database-generated UUIDs and timestamps are `None` until PostgreSQL supplies their values during insert. The database columns remain non-nullable. `Person.metadata_` maps to the `metadata` column because `metadata` is reserved by SQLAlchemy's declarative API.
```python
from datetime import date
from datetime import datetime
from enum import StrEnum
from uuid import UUID
from pydantic import JsonValue
from sqlalchemy import Column
from sqlalchemy import Date
from sqlalchemy import DateTime
from sqlalchemy import ForeignKey
from sqlalchemy import Index
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import Text
from sqlalchemy import UniqueConstraint
from sqlalchemy import text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID
from sqlmodel import Field
from sqlmodel import Relationship
from sqlmodel import SQLModel
class PersonRole(StrEnum):
AUTHOR = "author"
RECIPIENT = "recipient"
class JobStatus(StrEnum):
QUEUED = "queued"
PROCESSING = "processing"
COMPLETED = "completed"
PARTIAL_SUCCESS = "partial_success"
FAILED = "failed"
class JobSourceStatus(StrEnum):
PENDING = "pending"
TRANSCRIBED = "transcribed"
FAILED = "failed"
class Person(SQLModel, table=True):
__tablename__ = "person"
__table_args__ = (Index("idx_person_full_name", "full_name"),)
id: UUID | None = Field(
default=None,
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
),
)
full_name: str = Field(sa_column=Column(Text, nullable=False))
display_name: str | None = Field(default=None, sa_column=Column(Text))
maiden_name: str | None = Field(default=None, sa_column=Column(Text))
birth_date: date | None = Field(default=None, sa_column=Column(Date))
birth_date_raw: str | None = Field(default=None, sa_column=Column(Text))
birth_place: str | None = Field(default=None, sa_column=Column(Text))
death_date: date | None = Field(default=None, sa_column=Column(Date))
death_date_raw: str | None = Field(default=None, sa_column=Column(Text))
death_place: str | None = Field(default=None, sa_column=Column(Text))
biography: str | None = Field(default=None, sa_column=Column(Text))
portrait_path: str | None = Field(default=None, sa_column=Column(Text))
metadata_: JsonValue | None = Field(
default_factory=dict,
sa_column=Column(
"metadata",
JSONB,
server_default=text("'{}'::jsonb"),
),
)
created_at: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
document_people: list["DocumentPerson"] = Relationship(
back_populates="person",
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
)
class Document(SQLModel, table=True):
__tablename__ = "document"
__table_args__ = (Index("idx_document_date", "document_date"),)
id: UUID | None = Field(
default=None,
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
),
)
name: str = Field(sa_column=Column(Text, nullable=False))
document_type: str | None = Field(default=None, sa_column=Column(Text))
document_date: date | None = Field(default=None, sa_column=Column(Date))
document_date_raw: str | None = Field(default=None, sa_column=Column(Text))
location_created: str | None = Field(default=None, sa_column=Column(Text))
notes: str | None = Field(default=None, sa_column=Column(Text))
archive_identifier: str | None = Field(default=None, sa_column=Column(Text))
created_at: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
document_people: list["DocumentPerson"] = Relationship(
back_populates="document",
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
)
jobs: list["Job"] = Relationship(
back_populates="document",
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
)
sources: list["Source"] = Relationship(
back_populates="document",
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
)
class DocumentPerson(SQLModel, table=True):
__tablename__ = "document_person"
__table_args__ = (
UniqueConstraint(
"document_id",
"person_id",
"role",
name="unique_document_person_role",
),
Index("idx_document_person_doc", "document_id"),
Index("idx_document_person_per", "person_id"),
)
id: UUID | None = Field(
default=None,
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
),
)
document_id: UUID = Field(
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
ForeignKey("document.id", ondelete="CASCADE"),
nullable=False,
),
)
person_id: UUID = Field(
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
ForeignKey("person.id", ondelete="CASCADE"),
nullable=False,
),
)
role: PersonRole = Field(sa_column=Column(String(20), nullable=False))
created_at: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
document: Document | None = Relationship(
back_populates="document_people",
sa_relationship_kwargs={"lazy": "raise"},
)
person: Person | None = Relationship(
back_populates="document_people",
sa_relationship_kwargs={"lazy": "raise"},
)
class Job(SQLModel, table=True):
__tablename__ = "job"
__table_args__ = (Index("idx_job_document", "document_id"),)
id: UUID | None = Field(
default=None,
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
),
)
document_id: UUID = Field(
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
ForeignKey("document.id", ondelete="CASCADE"),
nullable=False,
),
)
status: JobStatus = Field(
default=JobStatus.QUEUED,
sa_column=Column(
String(50),
nullable=False,
server_default=text("'queued'"),
),
)
retry_count: int = Field(
default=0,
sa_column=Column(
Integer,
nullable=False,
server_default=text("0"),
),
)
provider: str = Field(sa_column=Column(Text, nullable=False))
model: str = Field(sa_column=Column(Text, nullable=False))
prompt_name: str | None = Field(default=None, sa_column=Column(Text))
date_created: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
date_updated: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
document: Document | None = Relationship(
back_populates="jobs",
sa_relationship_kwargs={"lazy": "raise"},
)
job_sources: list["JobSource"] = Relationship(
back_populates="job",
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
)
class Source(SQLModel, table=True):
__tablename__ = "source"
__table_args__ = (
Index("idx_source_document", "document_id"),
Index("idx_source_page_order", "document_id", "page_number"),
)
id: UUID | None = Field(
default=None,
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
),
)
document_id: UUID = Field(
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
ForeignKey("document.id", ondelete="CASCADE"),
nullable=False,
),
)
page_number: int = Field(
default=1,
sa_column=Column(
Integer,
nullable=False,
server_default=text("1"),
),
)
upload_name: str = Field(sa_column=Column(Text, nullable=False))
filename: str = Field(sa_column=Column(Text, nullable=False))
file_path: str = Field(sa_column=Column(Text, nullable=False))
raw_transcription: str | None = Field(default=None, sa_column=Column(Text))
revised_text: str | None = Field(default=None, sa_column=Column(Text))
date_uploaded: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
date_revised: datetime | None = Field(
default=None,
sa_column=Column(DateTime(timezone=True)),
)
document: Document | None = Relationship(
back_populates="sources",
sa_relationship_kwargs={"lazy": "raise"},
)
job_sources: list["JobSource"] = Relationship(
back_populates="source",
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
)
class JobSource(SQLModel, table=True):
__tablename__ = "job_source"
__table_args__ = (
UniqueConstraint("job_id", "source_id", name="unique_job_source"),
Index("idx_job_source_job", "job_id"),
Index("idx_job_source_source", "source_id"),
Index(
"idx_job_source_ai_metadata",
"ai_metadata",
postgresql_using="gin",
),
)
id: UUID | None = Field(
default=None,
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
primary_key=True,
server_default=text("gen_random_uuid()"),
),
)
job_id: UUID = Field(
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
ForeignKey("job.id", ondelete="CASCADE"),
nullable=False,
),
)
source_id: UUID = Field(
sa_column=Column(
PostgreSQLUUID(as_uuid=True),
ForeignKey("source.id", ondelete="CASCADE"),
nullable=False,
),
)
status: JobSourceStatus = Field(
default=JobSourceStatus.PENDING,
sa_column=Column(
String(50),
nullable=False,
server_default=text("'pending'"),
),
)
raw_transcription: str | None = Field(default=None, sa_column=Column(Text))
ai_metadata: JsonValue | None = Field(
default=None,
sa_column=Column(JSONB),
)
raw_api_response: JsonValue | None = Field(
default=None,
sa_column=Column(JSONB),
)
error_detail: str | None = Field(default=None, sa_column=Column(Text))
executed_at: datetime | None = Field(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=text("now()"),
),
)
job: Job | None = Relationship(
back_populates="job_sources",
sa_relationship_kwargs={"lazy": "raise"},
)
source: Source | None = Relationship(
back_populates="job_sources",
sa_relationship_kwargs={"lazy": "raise"},
)
```
The enum annotations validate application values while the mapped columns retain the `VARCHAR` types specified by the DDL. PostgreSQL owns generated UUIDs and timestamps through `server_default`; call `session.refresh(instance)` after a flush or commit when those generated values are needed immediately.
`ai_metadata`, `raw_api_response`, and `metadata_` accept any JSON value supported by `JSONB`. Validate provider-specific payload structure before assigning it to these fields, while preserving the complete raw response in `raw_api_response`.
Relationships use `lazy="raise"` to prevent implicit database I/O in async code. Queries must explicitly load relationships they need, for example with `selectinload()`.
-169
View File
@@ -1,169 +0,0 @@
## TypeScript Zod schemas
Here are the TypeScript Zod schemas matching your V2 PostgreSQL database definition.
These schemas cover:
1. Database Entities: Pure runtime validators representing rows fetched directly from PostgreSQL.
2. AI Payload Extensions: The structured document output stored inside job.ai_metadata.
3. Insert/Create Schemas: Utility types derived with .omit() for creating new records where auto-generated columns (id, created_at, updated_at, etc.) are handled by PostgreSQL defaults.
---
```
import { z } from "zod";
// ==========================================
// 1. ATOMIC & REUSABLE SCHEMAS
// ==========================================
export const UUIDSchema = z.string().uuid();
export const ISODateTimeSchema = z.coerce.date();
export const BoundingBoxSchema = z.object({
ymin: z.number().min(0).max(1000),
xmin: z.number().min(0).max(1000),
ymax: z.number().min(0).max(1000),
xmax: z.number().min(0).max(1000),
});
export const BlockTypeSchema = z.enum([
"heading",
"paragraph",
"table",
"margin_note",
"signature",
"footnote",
"header",
]);
// ==========================================
// 2. PAGE-LEVEL AI METADATA SCHEMA (job_source.ai_metadata)
// ==========================================
export const TranscribedBlockSchema = z.object({
text: z.string(),
confidence: z.number().min(0).max(1),
blockType: BlockTypeSchema,
boundingBox: BoundingBoxSchema.optional(),
});
export const PageAIMetadataSchema = z.object({
detectedLanguage: z.string().optional(),
overallConfidence: z.number().min(0).max(1),
blocks: z.array(TranscribedBlockSchema),
inputTokens: z.number().optional(),
outputTokens: z.number().optional(),
extractedEntities: z.record(z.string(), z.unknown()).optional(),
});
export type PageAIMetadata = z.infer<typeof PageAIMetadataSchema>;
// ==========================================
// 3. TABLE ENTITY SCHEMAS
// ==========================================
// --- PERSON TABLE ---
export const PersonSchema = z.object({
id: UUIDSchema,
fullName: z.string().min(1),
displayName: z.string().nullable().optional(),
maidenName: z.string().nullable().optional(),
birthDate: z.string().nullable().optional(),
birthDateRaw: z.string().nullable().optional(),
birthPlace: z.string().nullable().optional(),
deathDate: z.string().nullable().optional(),
deathDateRaw: z.string().nullable().optional(),
deathPlace: z.string().nullable().optional(),
biography: z.string().nullable().optional(),
portraitPath: z.string().nullable().optional(),
metadata: z.record(z.string(), z.unknown()).default({}),
createdAt: ISODateTimeSchema,
updatedAt: ISODateTimeSchema,
});
// --- DOCUMENT TABLE ---
export const DocumentSchema = z.object({
id: UUIDSchema,
name: z.string().min(1),
documentType: z.string().nullable().optional(),
documentDate: z.string().nullable().optional(),
documentDateRaw: z.string().nullable().optional(),
locationCreated: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
archiveIdentifier: z.string().nullable().optional(),
createdAt: ISODateTimeSchema,
updatedAt: ISODateTimeSchema,
});
// --- DOCUMENT_PERSON JUNCTION ---
export const PersonRoleSchema = z.enum(["author", "recipient"]);
export const DocumentPersonSchema = z.object({
id: UUIDSchema,
documentId: UUIDSchema,
personId: UUIDSchema,
role: PersonRoleSchema,
createdAt: ISODateTimeSchema,
});
// --- JOB TABLE ---
export const JobStatusSchema = z.enum([
"queued",
"processing",
"completed",
"partial_success",
"failed",
]);
export const JobSchema = z.object({
id: UUIDSchema,
documentId: UUIDSchema,
status: JobStatusSchema.default("queued"),
retryCount: z.number().int().nonnegative().default(0),
provider: z.string(),
model: z.string(),
promptName: z.string().nullable().optional(),
dateCreated: ISODateTimeSchema,
dateUpdated: ISODateTimeSchema,
});
// --- SOURCE TABLE ---
export const SourceSchema = z.object({
id: UUIDSchema,
documentId: UUIDSchema,
pageNumber: z.number().int().positive().default(1),
uploadName: z.string(),
filename: z.string(),
filePath: z.string(),
rawTranscription: z.string().nullable().optional(),
revisedText: z.string().nullable().optional(),
dateUploaded: ISODateTimeSchema,
dateRevised: ISODateTimeSchema.nullable().optional(),
});
// --- JOB_SOURCE JUNCTION (Page Execution Output) ---
export const JobSourceStatusSchema = z.enum([
"pending",
"transcribed",
"failed",
]);
export const JobSourceSchema = z.object({
id: UUIDSchema,
jobId: UUIDSchema,
sourceId: UUIDSchema,
status: JobSourceStatusSchema.default("pending"),
rawTranscription: z.string().nullable().optional(),
aiMetadata: PageAIMetadataSchema.nullable().optional(),
rawApiResponse: z.record(z.string(), z.unknown()).nullable().optional(),
errorDetail: z.string().nullable().optional(),
executedAt: ISODateTimeSchema,
});
export type Person = z.infer<typeof PersonSchema>;
export type Document = z.infer<typeof DocumentSchema>;
export type DocumentPerson = z.infer<typeof DocumentPersonSchema>;
export type Job = z.infer<typeof JobSchema>;
export type Source = z.infer<typeof SourceSchema>;
export type JobSource = z.infer<typeof JobSourceSchema>;
```
+137 -104
View File
@@ -1,150 +1,183 @@
# Version 2 Plan
Desired Enhancements:
1. Data store
* Upgrade db to PostgresSQL
* Begin capturing JSONB data (which will allow future migration to MongoDB if desired)
* Relocate db and uploaded images to a location outside of the project folder that can be backed up. (This needs to be done for all projects.) (c:/github/data/transcription?)
2. Add ability to upload multiple images (or a folder of images)
* How many is too many?
* If there is a practical max image count, can I break a block of images up into smaller batches automatically?
3. UI
* Introduce the concept of "documents" to the UI.
* Before an image can be uploaded a "document" needs to be created/defined.
* As part of the upload process, document images need to be associated with a document.
* Multiple image upload
* Refine the job detail/log screen
* Is document id + original filename the best name for uploaded images?
* How to present multiple images within one job?
* Add document name, original filename to job detail.
## Purpose
Version 2 extends the V1 baseline by introducing a production-oriented persistence architecture while preserving current user workflows.
Version 2 updates the existing SQLModel domain schema to support multi-page documents, page-level transcription results, richer document metadata, and author/recipient attribution.
Primary target changes:
PostgreSQL support is already present in the database runtime. V2 does not require a database-layer rewrite or a general SQLite-to-PostgreSQL migration system. PostgreSQL adoption consists primarily of selecting the existing PostgreSQL settings, provisioning the database, creating the V2 schema, and verifying the application against it.
- Migrate relational persistence from SQLite to PostgreSQL
- Introduce optional MongoDB for document-oriented adjunct data (non-canonical)
V1 behavior remains the functional baseline unless explicitly superseded by approved V2 requirements.
The main implementation effort is the schema update and the application changes that depend on it.
---
## V2 Goals
## Current State
1. **Relational migration complete**
- PostgreSQL becomes the default system of record for `Document`, `Source`, `Job`, and `Revision`.
1. **Operational maturity**
- Repeatable migrations, rollback paths, and environment-specific deployment procedures are documented and tested.
1. **Optional document store integration**
- MongoDB is introduced only for clearly scoped use cases that do not replace canonical relational ownership.
1. **No regression of V1 workflows**
- Upload, queue/worker processing, status inspection, original transcription, and optional single revision remain stable.
- The application uses Python 3.12, Pydantic v2, SQLModel, and async SQLAlchemy sessions.
- The database engine already supports both SQLite and PostgreSQL through `SqliteSettings` and `PostgresSettings`.
- The PostgreSQL async driver is installed and the engine already builds `postgresql+asyncpg` connections.
- Schema bootstrap currently uses `SQLModel.metadata.create_all()`.
- SQLite remains the default local configuration and the current Compose configuration still selects SQLite.
- The current V1 domain contains `Document`, `Source`, `Job`, and `Revision` tables.
- The V2 target is defined in [V2 DB Schema](V2%20DB%20Schema.md) and [V2 PostgreSQL DDL Specification](V2%20PostgreSQL%20DDL%20Specification.md).
---
## Non-Goals (V2)
## V2 Outcomes
- Replacing SQLModel domain ownership with MongoDB
- Introducing breaking UI behavior for existing V1 flows
- Expanding revision cardinality beyond current `0..1` without explicit requirements update
1. **V2 schema implemented**
- SQLModel models, relationships, enums, constraints, and indexes match the approved V2 schema.
1. **Page-level batch processing supported**
- A job can process multiple sources and retain an independent result for each source through `JobSource`.
1. **Document metadata expanded**
- Documents support ordered pages, descriptive metadata, and multiple authors and recipients.
1. **Raw provider data retained**
- Complete provider payloads are stored in PostgreSQL `JSONB` without flattening or discarding fields.
- Stored documents remain suitable for a future MongoDB import if one is ever needed.
1. **PostgreSQL enabled through configuration**
- The application starts against a provisioned PostgreSQL database using the existing runtime path.
1. **Existing workflows remain reliable**
- Upload, worker execution, status inspection, and transcription revision work with the new schema.
---
## Proposed Scope
## Non-Goals
### A) PostgreSQL migration (required)
- Rewriting the database engine or session layer
- Building a general-purpose SQLite-to-PostgreSQL migration utility
- Rehearsing a production database cutover when no production dataset requires preservation
- Running or integrating MongoDB in V2
- Building MongoDB projections, synchronization, or fallback behavior
- Replacing Python, Pydantic, SQLModel, or SQLAlchemy
- Supporting more than one active human revision per source
- Add PostgreSQL runtime profile for local/dev/prod
- Introduce migration toolchain and migration history
- Convert bootstrap strategy from compatibility patching to explicit migrations
- Validate model constraints and indexes against PostgreSQL
- Add operational checks (connectivity, pool, transaction behavior)
If an existing SQLite dataset must be retained, define a small one-time import task separately. It is not part of the default V2 implementation path.
### B) MongoDB integration (optional, gated)
---
- Define approved use cases (for example: denormalized read models, audit/event projections, or search-oriented materializations)
- Keep canonical write path in relational store
- Add feature flag/config gate to enable or disable Mongo features
- Document consistency model and failure behavior
## Scope
### A) SQLModel schema update (primary)
- Add `Person`, `DocumentPerson`, and `JobSource` models.
- Expand `Document` with type, date, location, notes, archive identifier, and timestamps.
- Update `Source` with page ordering, active raw transcription, revised text, and revision timestamp.
- Update `Job` for batch execution and the `partial_success` terminal state.
- Replace the standalone `Revision` table with revision fields on `Source`.
- Remove the direct `Source.job_id` relationship; connect sources to jobs through `JobSource`.
- Add role, job status, and job-source status enums.
- Add required uniqueness constraints, foreign-key delete behavior, lookup indexes, and PostgreSQL JSON indexes.
- Keep model definitions aligned with [V2 Python Pydantic Models](V2%20Python%20Pydantic%20Models.md).
### B) Raw document storage
- Store structured AI metadata in `job_source.ai_metadata` as `JSONB`.
- Store the complete raw provider response in `job_source.raw_api_response` as `JSONB`.
- Preserve the original document structure, field names, nested values, and unknown fields in the raw response.
- Keep validation of extracted application fields separate from retention of the raw response.
- Serialize UUIDs and datetimes using portable string representations.
- Use MongoDB Extended JSON representations only if a provider value cannot be represented faithfully in standard JSON.
- Do not store opaque BSON bytes in PostgreSQL unless a future payload contains BSON-only values that cannot be preserved in `JSONB`.
### C) Schema creation and verification
- Use a fresh V2 database during development unless preservation of existing data becomes a requirement.
- Create the schema from SQLModel metadata and verify it against the approved DDL.
- Keep SQLite available for fast unit tests where its behavior is equivalent.
- Add focused PostgreSQL integration tests for native UUIDs, JSON storage, constraints, indexes, and transactions.
- Introduce migration tooling only if V2 must update a populated deployed database in place.
### D) Service and worker alignment
- Update document, source, job, and store operations for the new relationships.
- Create one `JobSource` row per source included in a job.
- Persist page-level status, transcription, AI metadata, raw provider response, and errors on `JobSource`.
- Derive the parent job status from its page results:
- `completed` when all pages succeed
- `partial_success` when successful and failed pages are mixed
- `failed` when all pages fail or a job-level failure prevents execution
- Update `Source.raw_transcription` after a successful page result while keeping the original `JobSource.raw_transcription` immutable.
- Read `Source.revised_text` in preference to `Source.raw_transcription` when presenting active text.
### E) Document and multi-image workflows
- Require a document before associating uploaded sources.
- Support uploading multiple images into one document.
- Preserve page order through `Source.page_number`.
- Allow a job to include one or more sources from the same document.
- Update job details to show the document, each source filename, page order, page status, and page-level errors.
- Define a practical upload limit and split oversized selections into manageable batches if needed.
### F) PostgreSQL configuration
- Provision PostgreSQL for local and deployed environments.
- Configure the existing `Settings.database` field with PostgreSQL host, port, database, user, and password values.
- Update Compose and environment configuration to stop selecting SQLite.
- Decide whether schema bootstrap is enabled for local development or performed as a separate deployment step.
- Run a connectivity and schema smoke test against PostgreSQL.
- Keep uploaded files on a persistent, backup-capable path outside the application image.
---
## Milestones
## M1 — Requirements and architecture baseline
### M1 - Schema models
- Create V2 requirements delta from V1 baseline
- Define relational/document ownership boundaries
- Approve migration strategy and cutover approach
- Implement the V2 SQLModel models and enums.
- Implement relationships, constraints, indexes, and JSON column types.
- Update the Pydantic data contracts where model decisions change.
- Add schema-focused tests.
**Exit criteria:** signed architecture decision and updated traceability map.
**Exit criteria:** SQLModel metadata represents the approved V2 schema and schema tests pass.
## M2 PostgreSQL foundation
### M2 - Persistence and worker behavior
- Add PostgreSQL environment wiring and secrets strategy
- Add migration framework and initial schema migration
- Add CI path using PostgreSQL service container
- Update database operations and services for the V2 entities.
- Implement page-level `JobSource` execution records.
- Preserve complete raw provider responses in `JSONB`.
- Implement aggregate job status calculation.
- Add transaction, partial-success, and failure-isolation tests.
**Exit criteria:** test suite green on PostgreSQL in CI.
**Exit criteria:** single-page and multi-page jobs persist correct page, raw payload, and aggregate states.
## M3 Data migration and cutover rehearsal
### M3 - Document and upload workflows
- Build SQLite -> PostgreSQL migration utility/playbook
- Rehearse migration on representative datasets
- Validate rollback/recovery procedures
- Update document creation and source association flows.
- Add ordered multi-image upload.
- Update job and document detail views for page-level results.
- Add focused UI and service tests.
**Exit criteria:** successful dry-run migration with measured rollback test.
**Exit criteria:** a user can create a document, upload ordered pages, run a job, and inspect each result.
## M4 — MongoDB optional integration
### M4 - PostgreSQL verification and release
- Implement scoped Mongo use case(s)
- Add fallback behavior when Mongo unavailable
- Add tests and operational runbook updates
- Switch local or test configuration to the existing PostgreSQL runtime path.
- Create the V2 schema in a fresh PostgreSQL database.
- Run PostgreSQL-specific schema and workflow tests.
- Document startup, backup, and recovery settings.
- Run the final regression suite.
**Exit criteria:** feature-gated Mongo behavior validated with no V1 flow regressions.
## M5 — Release readiness
- Final regression suite (functional + reliability)
- Performance and failure-mode checks
- Production release checklist and sign-off
**Exit criteria:** V2 release approval.
**Exit criteria:** V2 workflows pass against PostgreSQL and release checks are complete.
---
## Risks and Mitigations
- **Schema drift risk** -> enforce migration-first policy and CI migration checks.
- **Dual-store consistency risk** -> keep relational source of truth and explicit projection contracts.
- **Operational complexity** -> staged rollout, runbooks, and feature flags.
- **Regression risk in worker lifecycle** -> keep dedicated reliability tests around terminal-state guarantees.
- **Model and DDL drift** -> compare generated metadata with the approved schema and test named constraints and indexes.
- **Raw payload loss** -> retain the complete provider response separately from validated and extracted fields.
- **Cross-database differences** -> retain fast SQLite tests but verify PostgreSQL-native UUID, JSON, and index behavior in integration tests.
- **Batch state errors** -> test all-success, mixed-result, and all-failed jobs explicitly.
- **Page ordering errors** -> enforce uniqueness and ordering rules for document pages.
- **Unexpected data-preservation need** -> confirm whether existing SQLite data matters before implementation; add a one-time importer only when required.
- **Worker regressions** -> preserve terminal-state and retry reliability tests while changing persistence ownership.
---
## Traceability and Evidence
## Suggested First Tasks
Maintain a V2 table with:
- requirement/change ID
- status (`not started` / `in progress` / `done`)
- implementation PR
- validation evidence (test names, migration rehearsal logs, runbook references)
---
## Suggested first implementation tasks
1. Create `docs/ver2/adr/` and draft ADR for persistence ownership boundaries.
2. Add PostgreSQL compose profile and env contract.
3. Introduce migration tooling and generate initial migration from current schema.
4. Add CI job for PostgreSQL-backed `pytest -m "not external"`.
1. Update `src/transcription/db/models.py` to represent the approved V2 schema.
2. Add schema tests for tables, columns, relationships, constraints, indexes, and enums.
3. Define and test lossless raw provider response storage in `job_source.raw_api_response`.
4. Update database operations and services to use `JobSource` and source-level revisions.
5. Add page-result aggregation tests before changing the worker workflow.
6. Update document and multi-image upload flows.
7. Select PostgreSQL in configuration and run the integration suite against a fresh V2 database.