generated from john/python-template
416 lines
14 KiB
Markdown
416 lines
14 KiB
Markdown
# SQLModel Table Models
|
|
|
|
These models implement the canonical [Version 2 database schema](../schema_v2.md). Each schema entity is represented by exactly one `SQLModel` table 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()`.
|
|
|
|
The schema's behavioral invariants are enforced outside the table shape where appropriate:
|
|
|
|
- `PersonRole`, `JobStatus`, and `JobSourceStatus` define the exact values listed by the schema.
|
|
- `unique_document_person_role` enforces role uniqueness for `(document_id, person_id, role)`.
|
|
- Services order document sources by `Source.document_id` and `Source.page_number`.
|
|
- Services derive aggregate `Job.status` from related `JobSource.status` values.
|
|
- Services preserve `JobSource.raw_transcription` and `JobSource.raw_api_response` as point-in-time outputs while updating the active text on `Source`. |