V4.6 Phase 7: drive ty check to zero and add a blocking quality gate [HIGH-06]

Baseline was 207 diagnostics. Two real bugs were hiding in the noise:

- tools/run_destructive_tests.py imported ctypes.wintypes at module scope,
  which raises on non-Windows, and called fcntl unconditionally. The Windows
  and POSIX implementations now live under a module-level sys.platform split.
- tests/ui/test_sources_page.py constructed Source(...) without document_id.

Structural fixes, not suppressions:

- New src/transcription/db/loading.py owns the SQLModel-field to
  QueryableAttribute reinterpretation via orm_attribute()/selectinload()/
  defer(). This removed 42 "# pyright: ignore[reportArgumentType]" comments
  across documents/jobs/people/sources. Its docstring records that
  selectinload(A.b, B.c) is NOT equivalent to the chained form: varargs
  applies the selectin strategy only to the last path element, which under
  lazy="raise" raises InvalidRequestError at render time.
- db/session.py transaction_scope no longer accepts or yields
  AsyncSessionTransaction. No caller ever passed one, sessionmaker.begin()
  yields an AsyncSession, and the dead branch was latently buggy because
  services call .exec(). Cleared 7 workflows.py diagnostics.
- services/registry.py RegistryService is bound by a new RegistryEntry
  Protocol instead of bare SQLModel, so the shared implementation can read
  id/label/normalized_label/is_active. Cleared 9 diagnostics.
- Column expressions in sources.py/jobs.py/test_store.py wrap in sqlmodel
  col(), the idiom already used in registry.py.
- read_source_navigation wraps its literal tuple bounds in literal().
- normalization.py narrows with isinstance(image, TiffImageFile) rather than
  comparing image.format, since tag_v2 is TIFF-only.
- linked_people.render uses @ui.refreshable_method, the NiceGUI API for bound
  methods.
- The OpenRouter capturing client re-raises ResponseNotRead when the response
  stream is not async rather than mis-wrapping it.

Tooling gate:

- New .pre-commit-config.yaml runs ruff check and ty check as blocking hooks.
  No pre-commit config previously existed. Negative-tested: injecting a type
  error fails both hooks.
- The last two "# pyright: ignore" comments (config.py) are removed; ty does
  not honor pyright directives. One "# ty: ignore" remains, in
  tests/test_prompts.py, where the test deliberately assigns to a frozen
  field to assert ValidationError.
- asyncio_default_fixture_loop_scope is pinned to "function" so
  pytest-asyncio behavior does not shift on upgrade.

Verification: ruff check clean, ty check reports 0 diagnostics, 292 passed
and 4 skipped, pre-commit passes and demonstrably fails on a regression, and
tools/run_destructive_tests.py runs on Windows.

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
zoltan57
2026-08-17 19:57:23 -05:00
co-authored by Copilot App
parent 597be2691c
commit 66e2dce465
28 changed files with 316 additions and 184 deletions
+2 -2
View File
@@ -172,13 +172,13 @@ class Settings(BaseSettings):
@cache
def get_settings(**kwargs: Any) -> Settings:
"""Load cached settings without reading process CLI arguments."""
return Settings(_cli_parse_args=False, **kwargs) # pyright: ignore[reportCallIssue]
return Settings(_cli_parse_args=False, **kwargs)
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
"""Load settings with CLI arguments at the executable boundary."""
cli_args = True if args is None else list(args)
return Settings(_cli_parse_args=cli_args) # pyright: ignore[reportCallIssue]
return Settings(_cli_parse_args=cli_args)
LOGGING_CONFIG: dict[str, Any] = {
+45
View File
@@ -0,0 +1,45 @@
"""Typed loader-option wrappers for SQLModel relationship attributes.
SQLModel declares relationships with their runtime Python type, so
``Document.jobs`` is annotated ``list[Job]`` even though at runtime it is an
``InstrumentedAttribute``. SQLAlchemy's loader options are typed against
``QueryableAttribute``, so every eager-load call site reads as a type error to a
static checker even though the code is correct.
These wrappers put that reinterpretation in one documented place instead of
scattering a suppression comment across every eager-load call. Import
``selectinload`` and ``defer`` from here rather than from ``sqlalchemy.orm``.
Multi-level eager loads must keep using the chained form --
``selectinload(A.b).selectinload(orm_attribute(B.c))`` -- and not the varargs
form ``selectinload(A.b, B.c)``. The two produce the same loader path, but
varargs applies the selectin strategy only to the last element while the
intermediate falls back to its default strategy. Every relationship here
declares ``lazy="raise"``, so the varargs form raises at render time.
"""
from __future__ import annotations
from typing import Any
from typing import cast
from sqlalchemy.orm import defer as _defer
from sqlalchemy.orm import selectinload as _selectinload
from sqlalchemy.orm.attributes import QueryableAttribute
from sqlalchemy.orm.strategy_options import _AbstractLoad
def orm_attribute(attribute: object) -> QueryableAttribute[Any]:
"""Reinterpret a SQLModel relationship or field as its ORM descriptor."""
return cast("QueryableAttribute[Any]", attribute)
def selectinload(*keys: object) -> _AbstractLoad:
"""``sqlalchemy.orm.selectinload`` accepting SQLModel-annotated attributes."""
return _selectinload(*(orm_attribute(key) for key in keys))
def defer(*keys: object, raiseload: bool = False) -> _AbstractLoad:
"""``sqlalchemy.orm.defer`` accepting SQLModel-annotated attributes."""
first, *rest = (orm_attribute(key) for key in keys)
return _defer(first, *rest, raiseload=raiseload)
+2 -1
View File
@@ -23,6 +23,7 @@ from sqlalchemy import Uuid
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm.state import InstanceState
from sqlalchemy.types import TypeDecorator
from sqlmodel import Field
from sqlmodel import Relationship
@@ -38,7 +39,7 @@ def _loaded_attribute(instance: object, attribute: str) -> Any | None:
without catching exceptions indiscriminately.
"""
try:
state = sqlalchemy_inspect(instance)
state: InstanceState[Any] = sqlalchemy_inspect(instance, raiseerr=True)
except NoInspectionAvailable:
return None
if attribute in state.unloaded:
+8 -16
View File
@@ -3,7 +3,6 @@ from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -86,17 +85,13 @@ async def transaction_scope(
settings: Settings | None = None,
database_url: str | None = None,
session_factory: SessionFactory | None = None,
session: AsyncSession | AsyncSessionTransaction | None = None,
) -> AsyncGenerator[AsyncSession | AsyncSessionTransaction]:
match session:
case AsyncSession() as async_session:
if not async_session.in_transaction():
raise RuntimeError("A supplied session must have an active transaction")
yield async_session
return
case AsyncSessionTransaction() as async_transaction:
yield async_transaction
return
session: AsyncSession | None = None,
) -> AsyncGenerator[AsyncSession]:
if session is not None:
if not session.in_transaction():
raise RuntimeError("A supplied session must have an active transaction")
yield session
return
active_session_factory = session_factory or resolve_session_factory(
database_url,
@@ -106,7 +101,4 @@ async def transaction_scope(
yield owned_session
type TransactionScopeDep = Annotated[
AsyncSession | AsyncSessionTransaction,
Depends(transaction_scope),
]
type TransactionScopeDep = Annotated[AsyncSession, Depends(transaction_scope)]
+4 -1
View File
@@ -74,7 +74,10 @@ class _CapturingAsyncClient:
try:
self.last_body = response.content
except httpx.ResponseNotRead:
response.stream = _CapturingAsyncByteStream(response.stream, self._capture_body)
stream = response.stream
if not isinstance(stream, httpx.AsyncByteStream):
raise
response.stream = _CapturingAsyncByteStream(stream, self._capture_body)
return response
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
+15 -14
View File
@@ -10,13 +10,14 @@ from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlmodel import SQLModel
from sqlmodel import col
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..db.loading import orm_attribute
from ..db.loading import selectinload
from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import DocumentType
@@ -189,8 +190,8 @@ class DocumentService(ServiceBase):
session=_session,
document_id=document_id,
options=(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
selectinload(Document.jobs),
selectinload(Document.sources),
),
suggestion="Re-upload the source document and retry.",
)
@@ -218,9 +219,9 @@ class DocumentService(ServiceBase):
session=_session,
document_id=document.id,
options=(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people), # pyright: ignore[reportArgumentType]
selectinload(Document.jobs),
selectinload(Document.sources),
selectinload(Document.document_people),
),
)
@@ -275,9 +276,9 @@ class DocumentService(ServiceBase):
"""List documents with relations needed by the archival table."""
async with self._session_scope(session) as _session:
query = select(Document).options(
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
selectinload(Document.document_type_ref), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.person)),
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
selectinload(Document.document_type_ref),
)
result = await _session.exec(query)
return result.all()
@@ -288,11 +289,11 @@ class DocumentService(ServiceBase):
query = (
select(Document)
.options(
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
selectinload(Document.document_type_ref), # pyright: ignore[reportArgumentType]
selectinload(Document.jobs),
selectinload(Document.sources),
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.person)),
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
selectinload(Document.document_type_ref),
)
.where(Document.id == document_id)
.execution_options(populate_existing=True)
+18 -14
View File
@@ -6,10 +6,12 @@ from pathlib import Path
from uuid import UUID
from sqlalchemy import func
from sqlalchemy.orm import selectinload
from sqlmodel import col
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.loading import orm_attribute
from ..db.loading import selectinload
from ..db.models import ExecutionAttempt
from ..db.models import Job
from ..db.models import JobSource
@@ -64,8 +66,8 @@ class JobService(ServiceBase):
query = (
select(Job)
.options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
selectinload(Job.document),
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
)
.where(Job.id == job_id)
.execution_options(populate_existing=True)
@@ -100,13 +102,15 @@ class JobService(ServiceBase):
"""Query jobs from the database based on provided filters."""
async with self._session_scope(session) as _session:
query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
selectinload(Job.document),
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
)
if status is not None:
query = query.where(Job.status == status)
if filename is not None:
query = query.where(Job.job_sources.any(JobSource.source.has(Source.filename == filename)))
query = query.where(
col(Job.job_sources).any(col(JobSource.source).has(col(Source.filename) == filename))
)
result = await _session.exec(query)
return result.all()
@@ -118,8 +122,8 @@ class JobService(ServiceBase):
"""List all jobs in the database with eagerly loaded documents."""
async with self._session_scope(session) as _session:
query = select(Job).options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
selectinload(Job.document),
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
)
result = await _session.exec(query)
return result.all()
@@ -152,8 +156,8 @@ class JobService(ServiceBase):
query = (
select(Job)
.options(
selectinload(Job.document), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
selectinload(Job.document),
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
)
.where(Job.id == job_id)
.execution_options(populate_existing=True)
@@ -184,7 +188,7 @@ class JobService(ServiceBase):
select(Job)
.where(Job.status == JobStatus.QUEUED)
# Break ties by id so "next" is stable when two rows share close timestamps.
.order_by(Job.date_created, Job.id) # pyright: ignore[reportArgumentType]
.order_by(col(Job.date_created), col(Job.id))
.limit(1)
)
if _session.get_bind().dialect.name == "postgresql":
@@ -304,7 +308,7 @@ class JobService(ServiceBase):
(
await session.exec(
select(ProcessingArtifact).where(
ProcessingArtifact.execution_attempt_id.in_(attempt_ids)
col(ProcessingArtifact.execution_attempt_id).in_(attempt_ids)
)
)
).all()
@@ -351,7 +355,7 @@ class JobService(ServiceBase):
query = (
select(Job)
.options(
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
)
.where(Job.id == job_id)
.execution_options(populate_existing=True)
@@ -388,7 +392,7 @@ class JobService(ServiceBase):
query = (
select(Job)
.options(
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
)
.where(Job.id == job_id)
.execution_options(populate_existing=True)
+2 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from PIL import Image
from PIL import UnidentifiedImageError
from PIL.TiffImagePlugin import TiffImageFile
from transcription.errors import AppError
from transcription.errors import ErrorCategory
@@ -65,7 +66,7 @@ def normalize_orientation(path: str | Path, *, media_type: str) -> OrientationNo
return None
transpose, rotation = transformation
if image.format == "TIFF":
if isinstance(image, TiffImageFile):
original_width = int(image.tag_v2.get(256, image.width))
original_height = int(image.tag_v2.get(257, image.height))
# Pillow applies TIFF orientation while decoding; copying freezes those upright pixels.
+8 -7
View File
@@ -12,7 +12,6 @@ from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlmodel import SQLModel
from sqlmodel import col
from sqlmodel import select
@@ -20,6 +19,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..config import Settings
from ..config import get_settings
from ..db.loading import orm_attribute
from ..db.loading import selectinload
from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import Person
@@ -144,7 +145,7 @@ class PeopleService(ServiceBase):
existing = await _session.get(
Person,
person.id,
options=(selectinload(Person.document_people),), # pyright: ignore[reportArgumentType]
options=(selectinload(Person.document_people),),
)
if existing is None:
raise self._not_found(f"Person with id {person.id} not found")
@@ -209,8 +210,8 @@ class PeopleService(ServiceBase):
query = (
select(Person)
.options(
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
selectinload(Person.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.document)),
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
)
.where(Person.id == person_id)
.execution_options(populate_existing=True)
@@ -329,9 +330,9 @@ class PeopleService(ServiceBase):
) -> Sequence[DocumentPerson]:
async with self._session_scope(session) as _session:
query = select(DocumentPerson).options(
selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
selectinload(DocumentPerson.document),
selectinload(DocumentPerson.person),
selectinload(DocumentPerson.role_ref),
)
if document_id is not None:
query = query.where(DocumentPerson.document_id == document_id)
+18 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
from abc import abstractmethod
from collections.abc import Sequence
from typing import Any
from typing import Protocol
from uuid import UUID
from sqlalchemy import func
@@ -26,7 +27,23 @@ from ..errors import ErrorCategory
from .base import ServiceBase
class RegistryService[ModelT: SQLModel](ServiceBase):
class RegistryEntry(Protocol):
"""Structural contract every registry table row satisfies.
Bounding ``RegistryService`` by this protocol rather than by bare ``SQLModel``
lets the shared implementation read ``id``/``label``/``normalized_label``/
``is_active`` off the model class without suppressions.
"""
id: UUID
label: str
normalized_label: str
is_active: bool
def __init__(self, /, **data: Any) -> None: ...
class RegistryService[ModelT: RegistryEntry](ServiceBase):
"""Generic create/read/update/delete behavior for a registry table.
Subclasses declare the model, the error type, the user-facing noun, and the
+28 -26
View File
@@ -25,10 +25,9 @@ from pydantic import TypeAdapter
from pydantic import ValidationError
from sqlalchemy import func
from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy import literal
from sqlalchemy import tuple_
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import defer
from sqlalchemy.orm import selectinload
from sqlmodel import col
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -55,6 +54,9 @@ from transcription.providers import TransportEvidence
from transcription.providers import get_transcription_provider
from transcription.providers.evidence import canonical_json_bytes
from ..db.loading import defer
from ..db.loading import orm_attribute
from ..db.loading import selectinload
from .base import ServiceBase
from .normalization import ORIENTATION_PRODUCER
from .normalization import ORIENTATION_PRODUCER_VERSION
@@ -195,8 +197,8 @@ class SourceService(ServiceBase):
query = (
select(Source)
.options(
selectinload(Source.document), # pyright: ignore[reportArgumentType]
selectinload(Source.job_sources).selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
selectinload(Source.document),
selectinload(Source.job_sources).selectinload(orm_attribute(JobSource.job)),
)
.where(Source.id == source_id)
.execution_options(populate_existing=True)
@@ -226,11 +228,11 @@ class SourceService(ServiceBase):
async with self._session_scope(session) as _session:
query = (
select(ExecutionAttempt)
.options(defer(ExecutionAttempt.transport_body)) # pyright: ignore[reportArgumentType]
.options(defer(ExecutionAttempt.transport_body))
.where(ExecutionAttempt.job_source_id == job_source_id)
.order_by(
ExecutionAttempt.attempt_number.desc(), # pyright: ignore[reportAttributeAccessIssue]
ExecutionAttempt.id.desc(), # pyright: ignore[reportAttributeAccessIssue]
col(ExecutionAttempt.attempt_number).desc(),
col(ExecutionAttempt.id).desc(),
)
.limit(1)
)
@@ -250,7 +252,7 @@ class SourceService(ServiceBase):
async with self._session_scope(session) as _session:
source = await self._read_source(session=_session, source_id=source_id)
position = (col(Source.page_number), col(Source.id))
current = (source.page_number, source_id)
current = (literal(source.page_number), literal(source_id))
previous_query = (
select(col(Source.id))
@@ -290,8 +292,8 @@ class SourceService(ServiceBase):
session=_session,
source_id=source_id,
options=(
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
selectinload(Source.processing_artifacts), # pyright: ignore[reportArgumentType]
selectinload(Source.job_sources),
selectinload(Source.processing_artifacts),
),
)
@@ -379,7 +381,7 @@ class SourceService(ServiceBase):
job_source = await _session.get(
JobSource,
job_source_id,
options=(selectinload(JobSource.source),), # pyright: ignore[reportArgumentType]
options=(selectinload(JobSource.source),),
)
if job_source is None:
raise TranscriptionNotFoundError(
@@ -444,8 +446,8 @@ class SourceService(ServiceBase):
session=_session,
source_id=source_id,
options=(
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
selectinload(Source.processing_artifacts), # pyright: ignore[reportArgumentType]
selectinload(Source.job_sources),
selectinload(Source.processing_artifacts),
),
)
@@ -511,8 +513,8 @@ class SourceService(ServiceBase):
"""List job-source records, optionally filtered by job."""
async with self._session_scope(session) as _session:
query = select(JobSource).options(
selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
selectinload(JobSource.job),
selectinload(JobSource.source),
)
if job_id is not None:
query = query.where(JobSource.job_id == job_id)
@@ -720,10 +722,10 @@ class SourceService(ServiceBase):
if job_id is not None:
query = query.where(ExecutionAttempt.job_id == job_id)
query = query.order_by(
ExecutionAttempt.job_id,
ExecutionAttempt.source_id,
ExecutionAttempt.attempt_number,
ExecutionAttempt.id,
col(ExecutionAttempt.job_id),
col(ExecutionAttempt.source_id),
col(ExecutionAttempt.attempt_number),
col(ExecutionAttempt.id),
)
return (await _session.exec(query)).all()
@@ -987,7 +989,7 @@ class SourceService(ServiceBase):
query = (
select(ProcessingArtifact)
.where(ProcessingArtifact.source_id == source_id)
.order_by(ProcessingArtifact.created_at, ProcessingArtifact.id)
.order_by(col(ProcessingArtifact.created_at), col(ProcessingArtifact.id))
.limit(limit)
)
return (await _session.exec(query)).all()
@@ -1003,9 +1005,9 @@ class SourceService(ServiceBase):
async with self._session_scope(session) as _session:
query = (
select(ProcessingArtifact)
.options(defer(ProcessingArtifact.inline_payload)) # pyright: ignore[reportArgumentType]
.options(defer(ProcessingArtifact.inline_payload))
.where(ProcessingArtifact.source_id == source_id)
.order_by(ProcessingArtifact.created_at, ProcessingArtifact.id)
.order_by(col(ProcessingArtifact.created_at), col(ProcessingArtifact.id))
.limit(limit)
)
return (await _session.exec(query)).all()
@@ -1138,10 +1140,10 @@ class SourceService(ServiceBase):
async with self._session_scope(session) as _session:
query = (
select(Source)
.join(JobSource, JobSource.source_id == Source.id)
.where(JobSource.job_id == job_id)
.where(Source.revised_text.is_not(None))
.order_by(Source.date_revised) # pyright: ignore[reportArgumentType]
.join(JobSource, col(JobSource.source_id) == col(Source.id))
.where(col(JobSource.job_id) == job_id)
.where(col(Source.revised_text).is_not(None))
.order_by(col(Source.date_revised))
)
result = await _session.exec(query)
return result.all()
@@ -50,7 +50,7 @@ class LinkedPeopleEditor:
"""Return the complete staged link set for persistence."""
return [DocumentPersonInput(person_id=link.person_id, role_id=link.role_id) for link in self.links]
@ui.refreshable
@ui.refreshable_method
def render(self) -> None:
ui.label("Linked People").classes("text-sm font-semibold ui-text-primary mt-2")
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
+1 -1
View File
@@ -407,7 +407,7 @@ def _render_provider_evidence(
def _artifact_display(artifacts: list[ProcessingArtifact]) -> list[dict[str, object]] | None:
payload = [
payload: list[dict[str, object]] = [
{
"id": str(artifact.id),
"type": artifact.artifact_type,