generated from john/python-template
This commit is contained in:
@@ -29,12 +29,13 @@ Perform thorough, evidence-based code reviews for Python projects. Every finding
|
|||||||
2. **Establish Canonical Authority First:** Read architecture/contracts (`docs/ver4/*`, `docs/invariant/*`, UI docs) and active instructions/skills before evaluating source behavior.
|
2. **Establish Canonical Authority First:** Read architecture/contracts (`docs/ver4/*`, `docs/invariant/*`, UI docs) and active instructions/skills before evaluating source behavior.
|
||||||
3. **Read Representative Modules:** Sample across all layers (routes/pages, UI components, services, workers, persistence, provider adapters, settings, tests) before drawing conclusions.
|
3. **Read Representative Modules:** Sample across all layers (routes/pages, UI components, services, workers, persistence, provider adapters, settings, tests) before drawing conclusions.
|
||||||
4. **Run Drift Analysis:** Compare documented intended behavior versus repository ground truth; identify both implementation drift and undocumented-but-repeatable conventions that should be formalized.
|
4. **Run Drift Analysis:** Compare documented intended behavior versus repository ground truth; identify both implementation drift and undocumented-but-repeatable conventions that should be formalized.
|
||||||
5. **Assess Boundary and Coupling Health:** Evaluate UI/service/persistence/provider dependency flow, identify circular dependencies, leaky abstractions, and transaction ownership ambiguity.
|
5. **Run Dead-Code/Orphan Sweep:** Identify candidate orphan modules/functions/classes with zero inbound references, then verify expected exceptions (entrypoints, framework/plugin registration, dynamic imports/reflection, CLI hooks, test-only utilities) before marking as orphaned.
|
||||||
6. **Assess Invariant Placement:** For each hard rule, decide whether it belongs in docs (rationale), instructions (active steering), skills (periodic audit procedure), or deterministic tests (enforcement).
|
6. **Assess Boundary and Coupling Health:** Evaluate UI/service/persistence/provider dependency flow, identify circular dependencies, leaky abstractions, and transaction ownership ambiguity.
|
||||||
7. **Verify Claims:** Run or reference project tooling (`ruff check`, `ty`, `pytest`) rather than guessing.
|
7. **Assess Invariant Placement:** For each hard rule, decide whether it belongs in docs (rationale), instructions (active steering), skills (periodic audit procedure), or deterministic tests (enforcement).
|
||||||
8. **Prioritize Hot Paths:** Focus deeply on request handling, database sessions, background workers, and external API calls.
|
8. **Verify Claims:** Run or reference project tooling (`ruff check`, `ty`, `pytest`) rather than guessing.
|
||||||
9. **Enforce Read-Only Safety:** Do not modify code unless explicitly instructed.
|
9. **Prioritize Hot Paths:** Focus deeply on request handling, database sessions, background workers, and external API calls.
|
||||||
10. **Escalate Provenance Audits:** For evidence/provenance-heavy changes, apply invariant checks from `.github/skills/evidence-provenance-auditor/skill.md` and include pass/fail outcomes in the report.
|
10. **Enforce Read-Only Safety:** Do not modify code unless explicitly instructed.
|
||||||
|
11. **Escalate Provenance Audits:** For evidence/provenance-heavy changes, apply invariant checks from `.github/skills/evidence-provenance-auditor/skill.md` and include pass/fail outcomes in the report.
|
||||||
|
|
||||||
## Repo-Specific Deterministic Checks (Transcription)
|
## Repo-Specific Deterministic Checks (Transcription)
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ When reviewing this repository, always include explicit pass/fail checks for:
|
|||||||
7. **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths.
|
7. **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths.
|
||||||
8. **Eager-loading conformance:** service/UI read paths satisfy `lazy="raise"` expectations.
|
8. **Eager-loading conformance:** service/UI read paths satisfy `lazy="raise"` expectations.
|
||||||
9. **Cross-cutting error conformance:** service/API/UI translation and retry behavior align with `.github/instructions/error-handling.instructions.md`.
|
9. **Cross-cutting error conformance:** service/API/UI translation and retry behavior align with `.github/instructions/error-handling.instructions.md`.
|
||||||
|
10. **Orphaned/dead-code conformance:** include a deterministic orphan sweep and report confirmed orphans removed/retained with rationale.
|
||||||
|
|
||||||
## Core Review Areas
|
## Core Review Areas
|
||||||
|
|
||||||
@@ -93,7 +95,12 @@ When reviewing this repository, always include explicit pass/fail checks for:
|
|||||||
### 9. Duplication & Consolidation
|
### 9. Duplication & Consolidation
|
||||||
- Identify repeated code blocks, candidate helper abstractions, divergent patterns for identical operations, and duplicated domain constants.
|
- Identify repeated code blocks, candidate helper abstractions, divergent patterns for identical operations, and duplicated domain constants.
|
||||||
|
|
||||||
### 10. Architecture & Governance
|
### 10. Orphaned/Dead Code Audit
|
||||||
|
- Find candidate orphan modules/functions/classes with no inbound references.
|
||||||
|
- Validate each candidate against dynamic wiring exceptions (entrypoints, plugin registration, reflection/dynamic imports, CLI hooks, test utilities).
|
||||||
|
- Report outcomes as: removed orphan, retained-with-justification, or uncertain-follow-up.
|
||||||
|
|
||||||
|
### 11. Architecture & Governance
|
||||||
- **Architectural Drift:** Compare intended architecture rules against implementation behavior and cite concrete drift points.
|
- **Architectural Drift:** Compare intended architecture rules against implementation behavior and cite concrete drift points.
|
||||||
- **Systemic Health:** Evaluate domain cohesion, dependency direction, lifecycle consistency, and operational reliability seams.
|
- **Systemic Health:** Evaluate domain cohesion, dependency direction, lifecycle consistency, and operational reliability seams.
|
||||||
- **Invariant Routing:** Recommend the correct enforcement layer per rule (docs vs instructions vs skills vs tests).
|
- **Invariant Routing:** Recommend the correct enforcement layer per rule (docs vs instructions vs skills vs tests).
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ Implement **Phase 4 (Consolidation & refactoring)** from `docs/architecture-code
|
|||||||
Adjust the signature to match the real parameters used at each call site (inspect all three before finalizing).
|
Adjust the signature to match the real parameters used at each call site (inspect all three before finalizing).
|
||||||
- Refactor the three call sites to use the shared wrapper, preserving exact existing error types/messages raised on failure (this matters especially given Phase 3's error-taxonomy work — make sure this consolidation uses whatever the post-Phase-3 canonical/internal error categories are, not the pre-Phase-3 ones).
|
- Refactor the three call sites to use the shared wrapper, preserving exact existing error types/messages raised on failure (this matters especially given Phase 3's error-taxonomy work — make sure this consolidation uses whatever the post-Phase-3 canonical/internal error categories are, not the pre-Phase-3 ones).
|
||||||
|
|
||||||
|
### 4. Perform an orphaned/dead-code sweep (behavior-preserving)
|
||||||
|
**Scope:** `src/transcription/**` and closely related tests/docs touched by this phase.
|
||||||
|
|
||||||
|
**Problem:** Prior reviews have found orphaned code blocks/files that are no longer referenced, which increases maintenance burden and can hide drift.
|
||||||
|
|
||||||
|
**Required work:**
|
||||||
|
- Identify candidate orphaned code (modules/functions/classes) with zero inbound references.
|
||||||
|
- Treat these as potential orphans only after checking expected exceptions: app entrypoints, framework/plugin registration paths, dynamic imports/reflection, CLI hooks, and test-only utilities.
|
||||||
|
- Verify each candidate with deterministic repository checks (e.g. `rg` import/call-site search plus relevant runtime/tests for the owning area), rather than assumption.
|
||||||
|
- For each confirmed orphan:
|
||||||
|
- remove it if safe and truly unused, or
|
||||||
|
- keep it with a short justification where dynamic wiring or contract requirements make static references incomplete.
|
||||||
|
- Include a concise "orphaned code audit" section in the final report listing:
|
||||||
|
- confirmed removed orphans,
|
||||||
|
- intentionally retained candidates and justification,
|
||||||
|
- any uncertain candidates that require follow-up.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
- Run `pytest` (via the project's normal invocation, e.g. `uv run pytest`) after each consolidation step and ensure the full suite still passes unchanged — this is a refactor, so a full-suite regression is the primary correctness signal.
|
- Run `pytest` (via the project's normal invocation, e.g. `uv run pytest`) after each consolidation step and ensure the full suite still passes unchanged — this is a refactor, so a full-suite regression is the primary correctness signal.
|
||||||
|
|||||||
@@ -72,19 +72,17 @@ class DocumentPeopleResponse(ApiModel):
|
|||||||
|
|
||||||
|
|
||||||
def _document_type_to_read(item: DocumentType) -> DocumentTypeRead:
|
def _document_type_to_read(item: DocumentType) -> DocumentTypeRead:
|
||||||
return DocumentTypeRead(
|
item_id, label, is_active = _registry_read_values(item)
|
||||||
id=item.id,
|
return DocumentTypeRead(id=item_id, label=label, is_active=is_active)
|
||||||
label=item.label,
|
|
||||||
is_active=item.is_active,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _person_role_to_read(item: PersonRole) -> PersonRoleRead:
|
def _person_role_to_read(item: PersonRole) -> PersonRoleRead:
|
||||||
return PersonRoleRead(
|
item_id, label, is_active = _registry_read_values(item)
|
||||||
id=item.id,
|
return PersonRoleRead(id=item_id, label=label, is_active=is_active)
|
||||||
label=item.label,
|
|
||||||
is_active=item.is_active,
|
|
||||||
)
|
def _registry_read_values(item: DocumentType | PersonRole) -> tuple[UUID, str, bool]:
|
||||||
|
return item.id, item.label, item.is_active
|
||||||
|
|
||||||
|
|
||||||
def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead:
|
def _document_person_to_read(item: DocumentPerson) -> DocumentPersonRead:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from ..errors import AppError
|
|||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
from .registry import RegistryService
|
from .registry import RegistryService
|
||||||
|
from .registry import RegistrySummary
|
||||||
from .source_media import lookup_source_mime_type
|
from .source_media import lookup_source_mime_type
|
||||||
from .source_media import supported_source_formats
|
from .source_media import supported_source_formats
|
||||||
|
|
||||||
@@ -71,15 +72,7 @@ class DocumentTypeRegistry(RegistryService[DocumentType]):
|
|||||||
return col(Document.document_type_id)
|
return col(Document.document_type_id)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
type DocumentTypeSummary = RegistrySummary
|
||||||
class DocumentTypeSummary:
|
|
||||||
"""Settings read model for a Document Type and its usage count."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
label: str
|
|
||||||
is_active: bool
|
|
||||||
is_built_in: bool
|
|
||||||
document_count: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -377,12 +370,12 @@ class DocumentService(ServiceBase):
|
|||||||
"""List Document Types alphabetically with current usage counts."""
|
"""List Document Types alphabetically with current usage counts."""
|
||||||
rows = await self._document_types.list_entries_with_counts(session=session)
|
rows = await self._document_types.list_entries_with_counts(session=session)
|
||||||
return [
|
return [
|
||||||
DocumentTypeSummary(
|
RegistrySummary(
|
||||||
id=document_type.id,
|
id=document_type.id,
|
||||||
label=document_type.label,
|
label=document_type.label,
|
||||||
is_active=document_type.is_active,
|
is_active=document_type.is_active,
|
||||||
is_built_in=document_type.semantic_key is not None,
|
is_built_in=document_type.semantic_key is not None,
|
||||||
document_count=document_count,
|
reference_count=document_count,
|
||||||
)
|
)
|
||||||
for document_type, document_count in rows
|
for document_type, document_count in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -52,6 +52,36 @@ async def write_media_bytes(
|
|||||||
return stored_path
|
return stored_path
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_named_media(
|
||||||
|
*,
|
||||||
|
root: Path,
|
||||||
|
filename: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
error: type[AppError],
|
||||||
|
failure_message: str,
|
||||||
|
failure_suggestion: str,
|
||||||
|
log_label: str,
|
||||||
|
namespace: Path | str | None = None,
|
||||||
|
filename_stem: str | None = None,
|
||||||
|
preserve_original_name: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
"""Resolve a target directory/name and persist media bytes safely."""
|
||||||
|
target_dir = root if namespace is None else root / Path(namespace)
|
||||||
|
stored_name = Path(filename).name if preserve_original_name else build_stored_filename(
|
||||||
|
filename=filename,
|
||||||
|
filename_stem=filename_stem,
|
||||||
|
)
|
||||||
|
return await write_media_bytes(
|
||||||
|
target_dir=target_dir,
|
||||||
|
stored_name=stored_name,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
error=error,
|
||||||
|
failure_message=failure_message,
|
||||||
|
failure_suggestion=failure_suggestion,
|
||||||
|
log_label=log_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write(stored_path: Path, file_bytes: bytes) -> None:
|
def _write(stored_path: Path, file_bytes: bytes) -> None:
|
||||||
stored_path.parent.mkdir(parents=True, exist_ok=True)
|
stored_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
stored_path.write_bytes(file_bytes)
|
stored_path.write_bytes(file_bytes)
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ from ..db.models import PersonRole
|
|||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
from .media_storage import build_stored_filename
|
from .media_storage import persist_named_media
|
||||||
from .media_storage import write_media_bytes
|
|
||||||
from .registry import RegistryService
|
from .registry import RegistryService
|
||||||
|
from .registry import RegistrySummary
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -83,15 +83,7 @@ def normalize_family_search_id(value: str | None) -> str | None:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
type PersonRoleSummary = RegistrySummary
|
||||||
class PersonRoleSummary:
|
|
||||||
"""Settings read model for a Person Role and its usage count."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
label: str
|
|
||||||
is_active: bool
|
|
||||||
is_built_in: bool
|
|
||||||
link_count: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -241,12 +233,12 @@ class PeopleService(ServiceBase):
|
|||||||
"""List Person Roles alphabetically with current link counts."""
|
"""List Person Roles alphabetically with current link counts."""
|
||||||
rows = await self._person_roles.list_entries_with_counts(session=session)
|
rows = await self._person_roles.list_entries_with_counts(session=session)
|
||||||
return [
|
return [
|
||||||
PersonRoleSummary(
|
RegistrySummary(
|
||||||
id=role.id,
|
id=role.id,
|
||||||
label=role.label,
|
label=role.label,
|
||||||
is_active=role.is_active,
|
is_active=role.is_active,
|
||||||
is_built_in=role.semantic_key is not None,
|
is_built_in=role.semantic_key is not None,
|
||||||
link_count=link_count,
|
reference_count=link_count,
|
||||||
)
|
)
|
||||||
for role, link_count in rows
|
for role, link_count in rows
|
||||||
]
|
]
|
||||||
@@ -543,9 +535,10 @@ async def store_person_portrait(
|
|||||||
)
|
)
|
||||||
|
|
||||||
runtime_settings = settings or get_settings()
|
runtime_settings = settings or get_settings()
|
||||||
return await write_media_bytes(
|
return await persist_named_media(
|
||||||
target_dir=runtime_settings.upload_dir / "persons" / str(person_id),
|
root=runtime_settings.upload_dir,
|
||||||
stored_name=build_stored_filename(filename=filename),
|
namespace=Path("persons") / str(person_id),
|
||||||
|
filename=filename,
|
||||||
file_bytes=file_bytes,
|
file_bytes=file_bytes,
|
||||||
error=PersonMediaError,
|
error=PersonMediaError,
|
||||||
failure_message="Failed to persist Person portrait media",
|
failure_message="Failed to persist Person portrait media",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -43,6 +44,27 @@ class RegistryEntry(Protocol):
|
|||||||
def __init__(self, /, **data: Any) -> None: ...
|
def __init__(self, /, **data: Any) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegistrySummary:
|
||||||
|
"""Shared settings read model for label-keyed registries and usage counts."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
label: str
|
||||||
|
is_active: bool
|
||||||
|
is_built_in: bool
|
||||||
|
reference_count: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def document_count(self) -> int:
|
||||||
|
"""Backward-compatible alias for document-type settings consumers."""
|
||||||
|
return self.reference_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def link_count(self) -> int:
|
||||||
|
"""Backward-compatible alias for person-role settings consumers."""
|
||||||
|
return self.reference_count
|
||||||
|
|
||||||
|
|
||||||
class RegistryService[ModelT: RegistryEntry](ServiceBase):
|
class RegistryService[ModelT: RegistryEntry](ServiceBase):
|
||||||
"""Generic create/read/update/delete behavior for a registry table.
|
"""Generic create/read/update/delete behavior for a registry table.
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ from ..db.models import Source
|
|||||||
from ..db.session import SessionFactory
|
from ..db.session import SessionFactory
|
||||||
from ..db.session import session_scope
|
from ..db.session import session_scope
|
||||||
from .errors import TranscriptionError
|
from .errors import TranscriptionError
|
||||||
from .media_storage import build_stored_filename
|
from .media_storage import persist_named_media
|
||||||
from .media_storage import write_media_bytes
|
|
||||||
from .normalization import normalize_orientation_async
|
from .normalization import normalize_orientation_async
|
||||||
from .sources import build_prompt_execution
|
from .sources import build_prompt_execution
|
||||||
from .sources import source_mime_type
|
from .sources import source_mime_type
|
||||||
@@ -375,9 +374,11 @@ async def store_source_file(
|
|||||||
file_bytes = normalized.content
|
file_bytes = normalized.content
|
||||||
|
|
||||||
upload_dir = runtime_settings.upload_dir
|
upload_dir = runtime_settings.upload_dir
|
||||||
stored_path = await write_media_bytes(
|
stored_path = await persist_named_media(
|
||||||
target_dir=upload_dir if relative_directory is None else upload_dir / relative_directory,
|
root=upload_dir,
|
||||||
stored_name=build_stored_filename(filename=filename, filename_stem=filename_stem),
|
namespace=relative_directory,
|
||||||
|
filename=filename,
|
||||||
|
filename_stem=filename_stem,
|
||||||
file_bytes=file_bytes,
|
file_bytes=file_bytes,
|
||||||
error=SourceStorageError,
|
error=SourceStorageError,
|
||||||
failure_message="Failed to persist Source file",
|
failure_message="Failed to persist Source file",
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
@@ -9,6 +14,33 @@ from transcription.errors import ErrorCategory
|
|||||||
from transcription.errors import canonical_error_category
|
from transcription.errors import canonical_error_category
|
||||||
from transcription.errors import classify_unexpected_error
|
from transcription.errors import classify_unexpected_error
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UiActionOutcome[T]:
|
||||||
|
"""Result of a UI action wrapper."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
value: T | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def run_ui_action(
|
||||||
|
*,
|
||||||
|
operation: str,
|
||||||
|
title: str,
|
||||||
|
action: Callable[[], Awaitable[T]],
|
||||||
|
passthrough: tuple[type[Exception], ...] = (),
|
||||||
|
) -> UiActionOutcome[T]:
|
||||||
|
"""Run an async UI action and render consistent errors."""
|
||||||
|
try:
|
||||||
|
return UiActionOutcome(ok=True, value=await action())
|
||||||
|
except Exception as exc:
|
||||||
|
if isinstance(exc, passthrough):
|
||||||
|
raise
|
||||||
|
show_error(exc, title=title, operation=operation)
|
||||||
|
return UiActionOutcome(ok=False, value=None)
|
||||||
|
|
||||||
|
|
||||||
def to_app_error(exc: Exception, *, operation: str) -> AppError:
|
def to_app_error(exc: Exception, *, operation: str) -> AppError:
|
||||||
"""Normalize any exception for consistent UI display."""
|
"""Normalize any exception for consistent UI display."""
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
|||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.services.media_storage import write_media_bytes
|
from transcription.services.media_storage import persist_named_media
|
||||||
|
|
||||||
HOME_PAGE_MARKDOWN_NAME = "homepage.md"
|
HOME_PAGE_MARKDOWN_NAME = "homepage.md"
|
||||||
SUPPORTED_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
SUPPORTED_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
||||||
@@ -68,9 +68,10 @@ async def store_homepage_image(
|
|||||||
msg = "Homepage image filename is required"
|
msg = "Homepage image filename is required"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
return await write_media_bytes(
|
return await persist_named_media(
|
||||||
target_dir=homepage_dir(settings),
|
root=homepage_dir(settings),
|
||||||
stored_name=safe_name,
|
filename=safe_name,
|
||||||
|
preserve_original_name=True,
|
||||||
file_bytes=file_bytes,
|
file_bytes=file_bytes,
|
||||||
error=HomepageStorageError,
|
error=HomepageStorageError,
|
||||||
failure_message="Failed to persist homepage image",
|
failure_message="Failed to persist homepage image",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from transcription.ui.components.confirm_delete import render_delete_actions
|
|||||||
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
||||||
from transcription.ui.components.data_display import archival_badge
|
from transcription.ui.components.data_display import archival_badge
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.error_presenter import run_ui_action
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.formatters import compact_date
|
from transcription.ui.components.formatters import compact_date
|
||||||
from transcription.ui.components.formatters import parse_iso_date
|
from transcription.ui.components.formatters import parse_iso_date
|
||||||
@@ -77,8 +78,13 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
requested_person_id = parse_uuid(request.query_params.get("person_id"))
|
requested_person_id = parse_uuid(request.query_params.get("person_id"))
|
||||||
staged_links: list[StagedLinkedPerson] = []
|
staged_links: list[StagedLinkedPerson] = []
|
||||||
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
|
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
|
||||||
try:
|
author_role_outcome = await run_ui_action(
|
||||||
author_role = await people_service.read_person_role_by_semantic_key(AUTHOR_ROLE_SEMANTIC_KEY)
|
operation="documents.create.preselect",
|
||||||
|
title="Author role unavailable",
|
||||||
|
action=lambda: people_service.read_person_role_by_semantic_key(AUTHOR_ROLE_SEMANTIC_KEY),
|
||||||
|
)
|
||||||
|
if author_role_outcome.ok and author_role_outcome.value is not None:
|
||||||
|
author_role = author_role_outcome.value
|
||||||
if author_role.is_active:
|
if author_role.is_active:
|
||||||
staged_links.append(StagedLinkedPerson(person_id=requested_person_id, role_id=author_role.id))
|
staged_links.append(StagedLinkedPerson(person_id=requested_person_id, role_id=author_role.id))
|
||||||
else:
|
else:
|
||||||
@@ -86,8 +92,6 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
"The Author role is inactive, so the Person could not be preselected.",
|
"The Author role is inactive, so the Person could not be preselected.",
|
||||||
type="warning",
|
type="warning",
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Author role unavailable", operation="documents.create.preselect")
|
|
||||||
elif request.query_params.get("person_id"):
|
elif request.query_params.get("person_id"):
|
||||||
ui.notify("The requested person could not be preselected.", type="warning")
|
ui.notify("The requested person could not be preselected.", type="warning")
|
||||||
linked_people = LinkedPeopleEditor(
|
linked_people = LinkedPeopleEditor(
|
||||||
@@ -127,16 +131,19 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
archive_identifier=(form.archive.value or "").strip() or None,
|
archive_identifier=(form.archive.value or "").strip() or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
created_outcome = await run_ui_action(
|
||||||
created = await create_document_with_people(
|
operation="documents.create",
|
||||||
|
title="Create failed",
|
||||||
|
action=lambda: create_document_with_people(
|
||||||
document=candidate,
|
document=candidate,
|
||||||
links=linked_people.values(),
|
links=linked_people.values(),
|
||||||
documents=document_service,
|
documents=document_service,
|
||||||
people=people_service,
|
people=people_service,
|
||||||
)
|
),
|
||||||
except Exception as exc: # noqa: BLE001
|
)
|
||||||
show_error(exc, title="Create failed", operation="documents.create")
|
if not created_outcome.ok or created_outcome.value is None:
|
||||||
return
|
return
|
||||||
|
created = created_outcome.value
|
||||||
|
|
||||||
ui.notify("Document created", type="positive")
|
ui.notify("Document created", type="positive")
|
||||||
if return_to == "jobs_new":
|
if return_to == "jobs_new":
|
||||||
@@ -162,15 +169,18 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
icon="note_add",
|
icon="note_add",
|
||||||
).classes("ui-btn-primary")
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
try:
|
documents_outcome = await run_ui_action(
|
||||||
documents = sorted(
|
operation="documents.list",
|
||||||
await document_service.list_documents(),
|
title="Load failed",
|
||||||
key=lambda item: item.created_at,
|
action=document_service.list_documents,
|
||||||
reverse=True,
|
)
|
||||||
)
|
if not documents_outcome.ok:
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Load failed", operation="documents.list")
|
|
||||||
return
|
return
|
||||||
|
documents = sorted(
|
||||||
|
documents_outcome.value or (),
|
||||||
|
key=lambda item: item.created_at,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
DocumentTableRow(
|
DocumentTableRow(
|
||||||
@@ -349,15 +359,17 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
updated_at=document.updated_at,
|
updated_at=document.updated_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
save_outcome = await run_ui_action(
|
||||||
await update_document_with_people(
|
operation="documents.edit.save",
|
||||||
|
title="Save failed",
|
||||||
|
action=lambda: update_document_with_people(
|
||||||
document=candidate,
|
document=candidate,
|
||||||
links=linked_people.values(),
|
links=linked_people.values(),
|
||||||
documents=document_service,
|
documents=document_service,
|
||||||
people=people_service,
|
people=people_service,
|
||||||
)
|
),
|
||||||
except Exception as exc: # noqa: BLE001
|
)
|
||||||
show_error(exc, title="Save failed", operation="documents.edit.save")
|
if not save_outcome.ok:
|
||||||
return
|
return
|
||||||
|
|
||||||
ui.notify("Document updated", type="positive")
|
ui.notify("Document updated", type="positive")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from transcription.ui.components.confirm_delete import render_delete_actions
|
|||||||
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
||||||
from transcription.ui.components.data_display import archival_badge
|
from transcription.ui.components.data_display import archival_badge
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.error_presenter import run_ui_action
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.formatters import parse_uuid
|
from transcription.ui.components.formatters import parse_uuid
|
||||||
from transcription.ui.components.guards import parsed_record_id
|
from transcription.ui.components.guards import parsed_record_id
|
||||||
@@ -98,15 +99,18 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
|
|
||||||
requested_source_id = parse_uuid(request.query_params.get("source_id"))
|
requested_source_id = parse_uuid(request.query_params.get("source_id"))
|
||||||
try:
|
locked_source_outcome = await run_ui_action(
|
||||||
locked_source = (
|
operation="jobs.retranscribe.load",
|
||||||
await sources_service.read_source_detail(requested_source_id)
|
title="Source unavailable",
|
||||||
|
action=(
|
||||||
|
(lambda: sources_service.read_source_detail(requested_source_id))
|
||||||
if requested_source_id is not None
|
if requested_source_id is not None
|
||||||
else None
|
else _none_async
|
||||||
)
|
),
|
||||||
except Exception as exc: # noqa: BLE001
|
)
|
||||||
show_error(exc, title="Source unavailable", operation="jobs.retranscribe.load")
|
if not locked_source_outcome.ok:
|
||||||
return
|
return
|
||||||
|
locked_source = locked_source_outcome.value
|
||||||
documents = await documents_service.list_documents()
|
documents = await documents_service.list_documents()
|
||||||
if not documents:
|
if not documents:
|
||||||
_render_no_documents_card()
|
_render_no_documents_card()
|
||||||
@@ -156,21 +160,24 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
async def submit_create() -> None:
|
async def submit_create() -> None:
|
||||||
if locked_source is not None:
|
if locked_source is not None:
|
||||||
try:
|
services = ServiceBundle(
|
||||||
services = ServiceBundle(
|
documents=documents_service,
|
||||||
documents=documents_service,
|
jobs=JobService(session_factory=session_factory, settings=settings),
|
||||||
jobs=JobService(session_factory=session_factory, settings=settings),
|
sources=SourceService(session_factory=session_factory, settings=settings),
|
||||||
sources=SourceService(session_factory=session_factory, settings=settings),
|
)
|
||||||
)
|
result_outcome = await run_ui_action(
|
||||||
result_job = await create_source_retranscription_job(
|
operation="jobs.retranscribe",
|
||||||
|
title="Create job failed",
|
||||||
|
action=lambda: create_source_retranscription_job(
|
||||||
source_id=locked_source.id,
|
source_id=locked_source.id,
|
||||||
model=str(model_input.value),
|
model=str(model_input.value),
|
||||||
services=services,
|
services=services,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
),
|
||||||
except Exception as exc: # noqa: BLE001
|
)
|
||||||
show_error(exc, title="Create job failed", operation="jobs.retranscribe")
|
if not result_outcome.ok or result_outcome.value is None:
|
||||||
return
|
return
|
||||||
|
result_job = result_outcome.value
|
||||||
resolve_worker_notifier(request.app.state).notify()
|
resolve_worker_notifier(request.app.state).notify()
|
||||||
ui.notify(f"Created retranscription job {result_job.id}", type="positive")
|
ui.notify(f"Created retranscription job {result_job.id}", type="positive")
|
||||||
ui.navigate.to(f"/jobs/{result_job.id}")
|
ui.navigate.to(f"/jobs/{result_job.id}")
|
||||||
@@ -189,17 +196,20 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.notify("Selected document id is invalid.", type="warning")
|
ui.notify("Selected document id is invalid.", type="warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
create_outcome = await run_ui_action(
|
||||||
result = await create_job_for_document(
|
operation="jobs.create",
|
||||||
|
title="Create job failed",
|
||||||
|
action=lambda: create_job_for_document(
|
||||||
document_id=document_id,
|
document_id=document_id,
|
||||||
source_files=uploaded_files,
|
source_files=uploaded_files,
|
||||||
provider=(provider_input.value or None),
|
provider=(provider_input.value or None),
|
||||||
model=(model_input.value or None),
|
model=(model_input.value or None),
|
||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
)
|
),
|
||||||
except Exception as exc: # noqa: BLE001
|
)
|
||||||
show_error(exc, title="Create job failed", operation="jobs.create")
|
if not create_outcome.ok or create_outcome.value is None:
|
||||||
return
|
return
|
||||||
|
result = create_outcome.value
|
||||||
|
|
||||||
resolve_worker_notifier(request.app.state).notify()
|
resolve_worker_notifier(request.app.state).notify()
|
||||||
ui.notify(f"Created job {result.job_id}", type="positive")
|
ui.notify(f"Created job {result.job_id}", type="positive")
|
||||||
@@ -250,12 +260,15 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
timer.cancel()
|
timer.cancel()
|
||||||
timer_holder[0] = None
|
timer_holder[0] = None
|
||||||
|
|
||||||
try:
|
refresh_outcome = await run_ui_action(
|
||||||
current_job[0] = await jobs_service.read_job(job_id=parsed_job_id)
|
operation="jobs.detail.refresh",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Auto-refresh failed",
|
||||||
|
action=lambda: jobs_service.read_job(job_id=parsed_job_id),
|
||||||
|
)
|
||||||
|
if not refresh_outcome.ok or refresh_outcome.value is None:
|
||||||
stop_refresh()
|
stop_refresh()
|
||||||
show_error(exc, title="Auto-refresh failed", operation="jobs.detail.refresh")
|
|
||||||
return
|
return
|
||||||
|
current_job[0] = refresh_outcome.value
|
||||||
|
|
||||||
render_detail.refresh()
|
render_detail.refresh()
|
||||||
if current_job[0].status not in {JobStatus.QUEUED, JobStatus.PROCESSING}:
|
if current_job[0].status not in {JobStatus.QUEUED, JobStatus.PROCESSING}:
|
||||||
@@ -567,3 +580,7 @@ def _render_job_document_links(job: Job) -> None:
|
|||||||
|
|
||||||
def _latest_prompt_name(job: Job) -> str | None:
|
def _latest_prompt_name(job: Job) -> str | None:
|
||||||
return job.prompt_name
|
return job.prompt_name
|
||||||
|
|
||||||
|
|
||||||
|
async def _none_async() -> None:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from transcription.ui.components.app_shell import render_navigation_header
|
|||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
from transcription.ui.components.confirm_delete import render_delete_actions
|
from transcription.ui.components.confirm_delete import render_delete_actions
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.error_presenter import run_ui_action
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.formatters import compact_date
|
from transcription.ui.components.formatters import compact_date
|
||||||
from transcription.ui.components.formatters import family_search_url
|
from transcription.ui.components.formatters import family_search_url
|
||||||
@@ -77,15 +78,18 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
icon="person_add",
|
icon="person_add",
|
||||||
).classes("ui-btn-primary")
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
try:
|
people_outcome = await run_ui_action(
|
||||||
people = sorted(
|
operation="people.list",
|
||||||
await people_service.list_people(),
|
title="Load failed",
|
||||||
key=lambda item: item.created_at,
|
action=people_service.list_people,
|
||||||
reverse=True,
|
)
|
||||||
)
|
if not people_outcome.ok:
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Load failed", operation="people.list")
|
|
||||||
return
|
return
|
||||||
|
people = sorted(
|
||||||
|
people_outcome.value or (),
|
||||||
|
key=lambda item: item.created_at,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
PersonTableRow(
|
PersonTableRow(
|
||||||
@@ -139,11 +143,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
family_search_id=(form.family_search_id.value or "").strip() or None,
|
family_search_id=(form.family_search_id.value or "").strip() or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
create_outcome = await run_ui_action(
|
||||||
created = await people_service.create_person(candidate)
|
operation="people.create",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Create failed",
|
||||||
show_error(exc, title="Create failed", operation="people.create")
|
action=lambda: people_service.create_person(candidate),
|
||||||
|
)
|
||||||
|
if not create_outcome.ok or create_outcome.value is None:
|
||||||
return
|
return
|
||||||
|
created = create_outcome.value
|
||||||
|
|
||||||
ui.notify("Person created", type="positive")
|
ui.notify("Person created", type="positive")
|
||||||
ui.navigate.to(f"/people/{created.id}")
|
ui.navigate.to(f"/people/{created.id}")
|
||||||
@@ -256,10 +263,12 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
updated_at=person.updated_at,
|
updated_at=person.updated_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
save_outcome = await run_ui_action(
|
||||||
await people_service.update_person(candidate)
|
operation="people.edit.save",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Save failed",
|
||||||
show_error(exc, title="Save failed", operation="people.edit.save")
|
action=lambda: people_service.update_person(candidate),
|
||||||
|
)
|
||||||
|
if not save_outcome.ok:
|
||||||
return
|
return
|
||||||
|
|
||||||
ui.notify("Person updated", type="positive")
|
ui.notify("Person updated", type="positive")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from transcription.services.people import PeopleService
|
|||||||
from transcription.services.prompts import PromptStore
|
from transcription.services.prompts import PromptStore
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.cards import archival_card
|
from transcription.ui.components.cards import archival_card
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import run_ui_action
|
||||||
from transcription.ui.components.primitives import destructive_button
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from transcription.ui.components.primitives import section_header_row
|
from transcription.ui.components.primitives import section_header_row
|
||||||
@@ -45,11 +45,14 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
ui.label("Types are listed alphabetically. Select one row to edit or delete it.").classes(
|
ui.label("Types are listed alphabetically. Select one row to edit or delete it.").classes(
|
||||||
"text-xs ui-text-muted mb-3"
|
"text-xs ui-text-muted mb-3"
|
||||||
)
|
)
|
||||||
try:
|
document_types_outcome = await run_ui_action(
|
||||||
document_types = await documents.list_document_type_summaries()
|
operation="settings.types.list",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Document Types unavailable",
|
||||||
show_error(exc, title="Document Types unavailable", operation="settings.types.list")
|
action=documents.list_document_type_summaries,
|
||||||
|
)
|
||||||
|
if not document_types_outcome.ok:
|
||||||
return
|
return
|
||||||
|
document_types = document_types_outcome.value or ()
|
||||||
|
|
||||||
if not document_types:
|
if not document_types:
|
||||||
render_empty_state("No Document Types are configured.", extra_classes="mt-3")
|
render_empty_state("No Document Types are configured.", extra_classes="mt-3")
|
||||||
@@ -75,17 +78,22 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
label: str,
|
label: str,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
try:
|
async def _save_type() -> None:
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
await documents.create_document_type(label=label, is_active=is_active)
|
await documents.create_document_type(label=label, is_active=is_active)
|
||||||
else:
|
return
|
||||||
await documents.update_document_type(
|
await documents.update_document_type(
|
||||||
item_id,
|
item_id,
|
||||||
label=label,
|
label=label,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Document Type save failed", operation="settings.types.save")
|
save_outcome = await run_ui_action(
|
||||||
|
operation="settings.types.save",
|
||||||
|
title="Document Type save failed",
|
||||||
|
action=_save_type,
|
||||||
|
)
|
||||||
|
if not save_outcome.ok:
|
||||||
return False
|
return False
|
||||||
ui.notify("Document Type saved", type="positive")
|
ui.notify("Document Type saved", type="positive")
|
||||||
render_document_types.refresh()
|
render_document_types.refresh()
|
||||||
@@ -131,10 +139,12 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
if selected is None:
|
if selected is None:
|
||||||
ui.notify("Select one Document Type to delete.", type="warning")
|
ui.notify("Select one Document Type to delete.", type="warning")
|
||||||
return
|
return
|
||||||
try:
|
delete_outcome = await run_ui_action(
|
||||||
await documents.delete_document_type(UUID(str(selected["id"])))
|
operation="settings.types.delete",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Document Type deletion failed",
|
||||||
show_error(exc, title="Document Type deletion failed", operation="settings.types.delete")
|
action=lambda: documents.delete_document_type(UUID(str(selected["id"]))),
|
||||||
|
)
|
||||||
|
if not delete_outcome.ok:
|
||||||
return
|
return
|
||||||
ui.notify("Document Type deleted", type="positive")
|
ui.notify("Document Type deleted", type="positive")
|
||||||
render_document_types.refresh()
|
render_document_types.refresh()
|
||||||
@@ -153,11 +163,14 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
"Roles are listed alphabetically. Built-ins cannot be deleted; "
|
"Roles are listed alphabetically. Built-ins cannot be deleted; "
|
||||||
"referenced custom roles must be deactivated."
|
"referenced custom roles must be deactivated."
|
||||||
).classes("text-xs ui-text-muted mb-3")
|
).classes("text-xs ui-text-muted mb-3")
|
||||||
try:
|
roles_outcome = await run_ui_action(
|
||||||
roles = await people.list_person_role_summaries()
|
operation="settings.roles.list",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Person Roles unavailable",
|
||||||
show_error(exc, title="Person Roles unavailable", operation="settings.roles.list")
|
action=people.list_person_role_summaries,
|
||||||
|
)
|
||||||
|
if not roles_outcome.ok:
|
||||||
return
|
return
|
||||||
|
roles = roles_outcome.value or ()
|
||||||
if not roles:
|
if not roles:
|
||||||
render_empty_state("No Person Roles are configured.", extra_classes="mt-3")
|
render_empty_state("No Person Roles are configured.", extra_classes="mt-3")
|
||||||
rows = [
|
rows = [
|
||||||
@@ -182,13 +195,18 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
label: str,
|
label: str,
|
||||||
is_active: bool,
|
is_active: bool,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
try:
|
async def _save_role() -> None:
|
||||||
if item_id is None:
|
if item_id is None:
|
||||||
await people.create_person_role(label=label, is_active=is_active)
|
await people.create_person_role(label=label, is_active=is_active)
|
||||||
else:
|
return
|
||||||
await people.update_person_role(item_id, label=label, is_active=is_active)
|
await people.update_person_role(item_id, label=label, is_active=is_active)
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Person Role save failed", operation="settings.roles.save")
|
save_outcome = await run_ui_action(
|
||||||
|
operation="settings.roles.save",
|
||||||
|
title="Person Role save failed",
|
||||||
|
action=_save_role,
|
||||||
|
)
|
||||||
|
if not save_outcome.ok:
|
||||||
return False
|
return False
|
||||||
ui.notify("Person Role saved", type="positive")
|
ui.notify("Person Role saved", type="positive")
|
||||||
render_person_roles.refresh()
|
render_person_roles.refresh()
|
||||||
@@ -234,10 +252,12 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
if selected is None:
|
if selected is None:
|
||||||
ui.notify("Select one Person Role to delete.", type="warning")
|
ui.notify("Select one Person Role to delete.", type="warning")
|
||||||
return
|
return
|
||||||
try:
|
delete_outcome = await run_ui_action(
|
||||||
await people.delete_person_role(UUID(str(selected["id"])))
|
operation="settings.roles.delete",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Person Role deletion failed",
|
||||||
show_error(exc, title="Person Role deletion failed", operation="settings.roles.delete")
|
action=lambda: people.delete_person_role(UUID(str(selected["id"]))),
|
||||||
|
)
|
||||||
|
if not delete_outcome.ok:
|
||||||
return
|
return
|
||||||
ui.notify("Person Role deleted", type="positive")
|
ui.notify("Person Role deleted", type="positive")
|
||||||
render_person_roles.refresh()
|
render_person_roles.refresh()
|
||||||
@@ -250,25 +270,31 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
destructive_button("Delete", icon="delete", on_click=delete_selected_role)
|
destructive_button("Delete", icon="delete", on_click=delete_selected_role)
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
def render_prompts() -> None:
|
async def render_prompts() -> None:
|
||||||
with archival_card("Prompts"):
|
with archival_card("Prompts"):
|
||||||
ui.label("Only existing Markdown prompts can be edited. Changes affect future Jobs only.").classes(
|
ui.label("Only existing Markdown prompts can be edited. Changes affect future Jobs only.").classes(
|
||||||
"text-xs ui-text-muted mb-3"
|
"text-xs ui-text-muted mb-3"
|
||||||
)
|
)
|
||||||
try:
|
summaries_outcome = await run_ui_action(
|
||||||
summaries = prompts.list_prompts()
|
operation="settings.prompts.list",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Prompts unavailable",
|
||||||
show_error(exc, title="Prompts unavailable", operation="settings.prompts.list")
|
action=lambda: _list_prompts(prompts),
|
||||||
|
)
|
||||||
|
if not summaries_outcome.ok:
|
||||||
return
|
return
|
||||||
|
summaries = summaries_outcome.value or ()
|
||||||
|
|
||||||
if not summaries:
|
if not summaries:
|
||||||
render_empty_state("No editable Markdown prompts were found.")
|
render_empty_state("No editable Markdown prompts were found.")
|
||||||
for summary in summaries:
|
for summary in summaries:
|
||||||
try:
|
read_outcome = await run_ui_action(
|
||||||
content = prompts.read_prompt(summary.name)
|
operation="settings.prompts.read",
|
||||||
except Exception as exc: # noqa: BLE001
|
title=f"{summary.name} unavailable",
|
||||||
show_error(exc, title=f"{summary.name} unavailable", operation="settings.prompts.read")
|
action=lambda name=summary.name: _read_prompt(prompts, name),
|
||||||
|
)
|
||||||
|
if not read_outcome.ok:
|
||||||
continue
|
continue
|
||||||
|
content = read_outcome.value or ""
|
||||||
|
|
||||||
with ui.column().classes("w-full gap-2 py-3 ui-header-divider"):
|
with ui.column().classes("w-full gap-2 py-3 ui-header-divider"):
|
||||||
with section_header_row():
|
with section_header_row():
|
||||||
@@ -283,28 +309,28 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
.classes("w-full")
|
.classes("w-full")
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_prompt(
|
async def save_prompt(
|
||||||
*,
|
*,
|
||||||
name: str = summary.name,
|
name: str = summary.name,
|
||||||
field: Any = editor,
|
field: Any = editor,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
save_outcome = await run_ui_action(
|
||||||
prompts.write_prompt(name, str(field.value or ""))
|
operation="settings.prompts.write",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Prompt save failed",
|
||||||
show_error(exc, title="Prompt save failed", operation="settings.prompts.write")
|
action=lambda: _write_prompt(prompts, name, str(field.value or "")),
|
||||||
|
)
|
||||||
|
if not save_outcome.ok:
|
||||||
return
|
return
|
||||||
ui.notify(f"{name} saved for future Jobs", type="positive")
|
ui.notify(f"{name} saved for future Jobs", type="positive")
|
||||||
render_prompts.refresh()
|
render_prompts.refresh()
|
||||||
|
|
||||||
def recover_prompt(*, name: str = summary.name) -> None:
|
async def recover_prompt(*, name: str = summary.name) -> None:
|
||||||
try:
|
recover_outcome = await run_ui_action(
|
||||||
prompts.recover_prompt(name)
|
operation="settings.prompts.recover",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Prompt recovery failed",
|
||||||
show_error(
|
action=lambda: _recover_prompt(prompts, name),
|
||||||
exc,
|
)
|
||||||
title="Prompt recovery failed",
|
if not recover_outcome.ok:
|
||||||
operation="settings.prompts.recover",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
ui.notify(f"{name} restored from its previous version", type="positive")
|
ui.notify(f"{name} restored from its previous version", type="positive")
|
||||||
render_prompts.refresh()
|
render_prompts.refresh()
|
||||||
@@ -321,7 +347,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
await render_document_types()
|
await render_document_types()
|
||||||
await render_person_roles()
|
await render_person_roles()
|
||||||
render_prompts()
|
await render_prompts()
|
||||||
|
|
||||||
|
|
||||||
def _selected_table_row(table: Any) -> dict[str, Any] | None:
|
def _selected_table_row(table: Any) -> dict[str, Any] | None:
|
||||||
@@ -329,3 +355,19 @@ def _selected_table_row(table: Any) -> dict[str, Any] | None:
|
|||||||
if len(selected) != 1:
|
if len(selected) != 1:
|
||||||
return None
|
return None
|
||||||
return selected[0]
|
return selected[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_prompts(prompts: PromptStore) -> tuple[Any, ...]:
|
||||||
|
return prompts.list_prompts()
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_prompt(prompts: PromptStore, name: str) -> str:
|
||||||
|
return prompts.read_prompt(name)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_prompt(prompts: PromptStore, name: str, content: str) -> None:
|
||||||
|
prompts.write_prompt(name, content)
|
||||||
|
|
||||||
|
|
||||||
|
async def _recover_prompt(prompts: PromptStore, name: str) -> None:
|
||||||
|
prompts.recover_prompt(name)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from transcription.ui.components.confirm_delete import render_delete_actions
|
|||||||
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
|
||||||
from transcription.ui.components.data_display import archival_badge
|
from transcription.ui.components.data_display import archival_badge
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.error_presenter import run_ui_action
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.formatters import parse_uuid
|
from transcription.ui.components.formatters import parse_uuid
|
||||||
from transcription.ui.components.guards import parsed_record_id
|
from transcription.ui.components.guards import parsed_record_id
|
||||||
@@ -62,11 +63,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
elif parsed_job_id is not None:
|
elif parsed_job_id is not None:
|
||||||
header_title = "Sources for Job"
|
header_title = "Sources for Job"
|
||||||
|
|
||||||
try:
|
sources_outcome = await run_ui_action(
|
||||||
sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id)
|
operation="sources.list",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Load failed",
|
||||||
show_error(exc, title="Load failed", operation="sources.list")
|
action=lambda: sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id),
|
||||||
|
)
|
||||||
|
if not sources_outcome.ok:
|
||||||
return
|
return
|
||||||
|
sources = sources_outcome.value or ()
|
||||||
|
|
||||||
render_navigation_header(current_path="/sources")
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
@@ -424,11 +428,14 @@ def _transport_display(latest_attempt: LatestExecutionAttempt) -> dict[str, obje
|
|||||||
|
|
||||||
|
|
||||||
async def _download_evidence(*, source_id: UUID, evidence_service: EvidenceService) -> None:
|
async def _download_evidence(*, source_id: UUID, evidence_service: EvidenceService) -> None:
|
||||||
try:
|
payload_outcome = await run_ui_action(
|
||||||
payload = await evidence_service.build_evidence_export(source_id=source_id)
|
operation="sources.evidence_export",
|
||||||
except Exception as exc: # noqa: BLE001
|
title="Export failed",
|
||||||
show_error(exc, title="Export failed", operation="sources.evidence_export")
|
action=lambda: evidence_service.build_evidence_export(source_id=source_id),
|
||||||
|
)
|
||||||
|
if not payload_outcome.ok or payload_outcome.value is None:
|
||||||
return
|
return
|
||||||
|
payload = payload_outcome.value
|
||||||
ui.download(
|
ui.download(
|
||||||
json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False).encode("utf-8"),
|
json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False).encode("utf-8"),
|
||||||
filename=f"source-{source_id}-evidence-v1.json",
|
filename=f"source-{source_id}-evidence-v1.json",
|
||||||
@@ -594,13 +601,15 @@ def _render_machine_candidates(
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def promote(candidate_id: UUID = attempt.id) -> None:
|
async def promote(candidate_id: UUID = attempt.id) -> None:
|
||||||
try:
|
promote_outcome = await run_ui_action(
|
||||||
await evidence_service.promote_machine_attempt(
|
operation="sources.candidate.promote",
|
||||||
|
title="Promotion failed",
|
||||||
|
action=lambda: evidence_service.promote_machine_attempt(
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
execution_attempt_id=candidate_id,
|
execution_attempt_id=candidate_id,
|
||||||
)
|
),
|
||||||
except Exception as exc: # noqa: BLE001
|
)
|
||||||
show_error(exc, title="Promotion failed", operation="sources.candidate.promote")
|
if not promote_outcome.ok:
|
||||||
return
|
return
|
||||||
ui.notify("Preferred machine transcription updated", type="positive")
|
ui.notify("Preferred machine transcription updated", type="positive")
|
||||||
ui.navigate.to(f"/sources/{source.id}")
|
ui.navigate.to(f"/sources/{source.id}")
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of John
|
|||||||
Some who get this book will consider the group picture the best thing in the book. It took a lot of preliminary photographing to reduce some pictures, enlarge others and bring out the tin types. We wish that instead of 44 faces we could have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished enough pictures but we had to take only part of them. We think there are great possibilities in reproducing old pictures. We wish we had a Pickard group. Some Pickard descendant may wish to make a collection.
|
Some who get this book will consider the group picture the best thing in the book. It took a lot of preliminary photographing to reduce some pictures, enlarge others and bring out the tin types. We wish that instead of 44 faces we could have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished enough pictures but we had to take only part of them. We think there are great possibilities in reproducing old pictures. We wish we had a Pickard group. Some Pickard descendant may wish to make a collection.
|
||||||
|
|
||||||
We are much impressed with the future possibilities of getting a complete geneology [sic] of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones.
|
We are much impressed with the future possibilities of getting a complete geneology [sic] of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones.
|
||||||
-2-
|
~2~
|
||||||
|
|||||||
@@ -3,36 +3,107 @@ provider: openrouter
|
|||||||
model: openai/gpt-5.3-codex
|
model: openai/gpt-5.3-codex
|
||||||
---
|
---
|
||||||
[document body typeset]
|
[document body typeset]
|
||||||
Family Only
|
Family Only
|
||||||
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
|
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
|
||||||
|
|
||||||
OMIE WRITES HOME
|
OMIE WRITES HOME
|
||||||
|
|
||||||
Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the original envelope with its 2 cent stamp. The letter has a number of references to the Shinn children. Peter was Louis; Polly was Edith; and the 'little black rascal' referred to Maurice. Miss Saville was the nurse at the Nome Hospital that was mentioned in the article by Inez in the family newsletter two years ago.
|
Ed. Note: The following letter was written by Omie Cochran in Nome, Alaska and sent to her
|
||||||
|
sister, Ethel Shinn, in Canfield, Idaho in 1923. It has been stored away these 63 years in the
|
||||||
|
original envelope with its 2 cent stamp. The letter has a number of references to the Shinn
|
||||||
|
children. Peter was Louis; Polly was Edith; and the 'little black rascal' referred to Maurice.
|
||||||
|
Miss Saville was the nurse at the Nome Hospital that was mentioned in the article by Inez in
|
||||||
|
the family newsletter two years ago.
|
||||||
|
|
||||||
Nome Alaska August 26, 1923
|
Nome Alaska August 26, 1923
|
||||||
|
|
||||||
My Dear Ethel et al.
|
My Dear Ethel et al.
|
||||||
|
|
||||||
I don't know when I did write or when you did but I am going to write now however and never the less. But I wish I could talk (I can yet but I mean to tell you all) instead and see ole Unc Pete and Polly sit up and listen and that little black rascal of yours would fairly sparkle with listening. Can't I see him listening now to all the yarns we told last summer?
|
I don't know when I did write or when you did
|
||||||
|
but I am going to write now however and never
|
||||||
|
the less. But I wish I could talk (I can yet but I
|
||||||
|
mean to tell you all) instead and see ole Unc Pete
|
||||||
|
and Polly sit up and listen and that little black
|
||||||
|
rascal of yours would fairly sparkle with
|
||||||
|
listening. Can't I see him listening now to all the
|
||||||
|
yarns we told last summer?
|
||||||
|
|
||||||
You see, we-Miss Saville and I, took a trip north on the Buford and it was very interesting. We went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain of our craft and some other mighty hunters went out first in kayaks and later in row boats and shot seven walrus. When they also took a movie man and camera, so you will likely see all this in the movies before I get to tell you. They came back on board and the ship went up along the icebergs and the walrus were 'heisted' on board by the use of the crane which loads tons of freight and the beasts were so huge that they made the pulleys just creak. They were over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are the most repulsive looking animals imaginable and tho I have always read about them I never expect such disagreeable looking creatures. They had a rough brown hairy skin and some of them looked warty. They must have weighed two ton at least. Ere we got them back to Nome to the natives they were getting extremely odiferous-in fact, you could scarcely stay on the ship with any degree of comfort unless you had per chance lost your sense of smell.
|
You see, we-Miss Saville and I, took a trip north
|
||||||
|
on the Buford and it was very interesting. We
|
||||||
|
went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain
|
||||||
|
of our craft and some other mighty hunters went out first in kayaks and later in row boats and
|
||||||
|
shot seven walrus. When they also took a movie man and camera, so you will likely see all
|
||||||
|
this in the movies before I get to tell you. They came back on board and the ship went up
|
||||||
|
along the icebergs and the walrus were 'heisted' on board by the use of the crane which loads
|
||||||
|
tons of freight and the beasts were so huge that they made the pulleys just creak. They were
|
||||||
|
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
|
||||||
|
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
|
||||||
|
|
||||||
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile we would go to Wrangell Island where some men from Steffonsons ship were supposed to be stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23 years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or 3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room was partitioned off at the sides with skins for sleeping quarters. In the main part they had the fire on the ground and the fish drying on lines and the skins hanging around and the dogs and babies and children. They wore skin clothes entirely. The women's were made like bloomers and were heavily padded for warmth. They wore high mukluks and really looked very comfortable. The babies were in fur skins with the fur inside and they looked like little Teddy bears with faces. I guess they had never seen white women, not so many at one time anyway. We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
|
[photograph: black-and-white photo of children standing on snow]
|
||||||
|
|
||||||
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists' went wild. They rushed helter-skelter, hither and thither, here and there, trying to find something to buy. Prices raised right before your eyes. One would but[sic] something for $1.00 and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to have it. One man I was sort of taking care of, tho he had his son along for the purpose, bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the little natives throw them at the flocks of geese and ducks which fly close over the village and the slings entangle their wings and legs, sometimes more than one, and they can't fly. They come down and the natives capture them. There was more junk brot aboard than baggage, I do believe. And they say that at the first stop it was worse than here. The red flag was flying over Whalen and the Russian soldiers were there-a few, one or two or three, I forget the number.
|
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
|
||||||
|
the most repulsive looking animals imaginable and tho I have always read about them I never
|
||||||
|
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
|
||||||
|
them looked warty. They must have weighed two ton at least. Ere we got them back to Nome
|
||||||
|
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
|
||||||
|
ship with any degree of comfort unless you had per chance lost your sense of smell.
|
||||||
|
|
||||||
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he didn't and Polly would in my lap and I don't know much about the youngest one of yours so likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
|
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile
|
||||||
|
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
|
||||||
|
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
|
||||||
|
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
|
||||||
|
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
|
||||||
|
3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room
|
||||||
|
was partitioned off at the sides with skins for sleeping quarters. In the main part they had the
|
||||||
|
fire on the ground and the fish drying on lines and the skins hanging around and the dogs and
|
||||||
|
babies and children. They wore skin clothes entirely. The women's were made like bloomers
|
||||||
|
and were heavily padded for warmth. They wore high mukluks and really looked very
|
||||||
|
comfortable. The babies were in fur skins with the fur inside and they looked like little Teddy
|
||||||
|
bears with faces. I guess they had never seen white women, not so many at one time anyway.
|
||||||
|
We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band
|
||||||
|
playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
|
||||||
|
|
||||||
[photograph of bundled children standing in snow with skis and poles]
|
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists'
|
||||||
|
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
|
||||||
|
something to buy. Prices raised right before your eyes. One would buy something for $1.00
|
||||||
|
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
|
||||||
|
have it. One man I was sort of taking care of, tho he had his son along for the purpose,
|
||||||
|
bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one
|
||||||
|
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
|
||||||
|
little natives throw them at the flocks of geese and ducks which fly close over the village and
|
||||||
|
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
|
||||||
|
come down and the natives capture them. There was more junk brot [sic] aboard than baggage, I
|
||||||
|
do believe. And they say that at the first stop it was worse than here. The red flag was flying
|
||||||
|
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
|
||||||
|
number.
|
||||||
|
|
||||||
I expect there were 150 passengers on board and almost or more of the crew and helpers. We had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out
|
||||||
|
until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let
|
||||||
|
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
|
||||||
|
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
|
||||||
|
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
|
||||||
|
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf [sic].
|
||||||
|
|
||||||
I have had jobs nearly all summer but not very much in them. Next week, September 4, school opens. I wish they would wait for a week but you know these school men. Wouldn't make any special difference I suppose for I would just fritter away the time but still one likes to postpone the inevitable.
|
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
|
||||||
|
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
|
||||||
|
|
||||||
I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray for reading over your shoulder. It would seem, then that you have bright children. Maybe they do know something about Geography. But it is ridiculous to speak of Louis finishing the eighth grade. Why you and I were grown children when we finished and he is only a baby. I am rather afraid he doesn't know much. I quite remember your little timid Maurice and how he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose he does?
|
I have had jobs nearly all summer but not very much in them. Next week, September 4,
|
||||||
|
school opens. I wish they would wait for a week but you know these school men. Wouldn't
|
||||||
|
make any special difference I suppose for I would just fritter away the time but still one likes
|
||||||
|
to postpone the inevitable.
|
||||||
|
|
||||||
Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens, I'll write again. Don't suppose it ever will, tho.
|
I just now stopped and re-read your letter and it sure was a bird. I don't especially blame Ray
|
||||||
|
for reading over your shoulder. It would seem, then that you have bright children. Maybe
|
||||||
|
they do know something about Geography. But it is ridiculous to speak of Louis finishing the
|
||||||
|
eighth grade. Why you and I were grown children when we finished and he is only a baby. I
|
||||||
|
am rather afraid he doesn't know much. I quite remember your little timid Maurice and how
|
||||||
|
he shifted his affection from his Aunt Om and yelled and howled and screamed steadily for a
|
||||||
|
week while his mother went to S.S. Ask him is he recalls this little interview. Do you suppose
|
||||||
|
he does?
|
||||||
|
|
||||||
|
Ever hear from Zen? or Inez? They don't seem to be very writing inclined tho Zen has done
|
||||||
|
well this year. Even sent me a telegram a few weeks ago. Well, if anything else ever happens,
|
||||||
|
I'll write again. Don't suppose it ever will, tho.
|
||||||
|
|
||||||
Lots of love to all,
|
Lots of love to all,
|
||||||
|
|
||||||
|
|||||||
@@ -3,31 +3,31 @@ provider: openrouter
|
|||||||
model: openai/gpt-5.3-codex
|
model: openai/gpt-5.3-codex
|
||||||
---
|
---
|
||||||
[document body mixed]
|
[document body mixed]
|
||||||
JOHN ISBILL R. T. MOSER
|
JOHN ISBILL R. T. MOSER
|
||||||
|
|
||||||
ISBILL & MOSER
|
ISBILL & MOSER
|
||||||
DEALERS IN
|
DEALERS IN
|
||||||
GENERAL MERCHANDISE
|
GENERAL MERCHANDISE
|
||||||
|
|
||||||
Vonore, Tenn., [handwritten: May 27-] 191[handwritten: 3]
|
Vonore, Tenn., [handwritten: Jan'y 27-] 191[handwritten: 3]
|
||||||
|
|
||||||
[handwritten: Dear Uncle Aunt & Cousins
|
[handwritten: Dear Uncle Aunt & Cousins
|
||||||
I was at home a
|
I was at home a
|
||||||
few nights ago & saw a
|
few nights ago & saw a
|
||||||
letter from your folks, so
|
letter from your folks, so
|
||||||
I decided to write you
|
I decided to write you
|
||||||
a few lines myself as
|
a few lines myself &
|
||||||
I am contemplating a
|
I am [contemplating?] a
|
||||||
trip out west next summer
|
trip out west next summer
|
||||||
& want lots of [places?] to go
|
& [wyant?] [lot?] of [olders?] to go
|
||||||
where I am [from?].
|
where I am from.
|
||||||
|
|
||||||
Am getting
|
Am getting
|
||||||
up in years & wondering,
|
up in years & [wondering?].
|
||||||
so you'll see the object of
|
So [you?] see the object of
|
||||||
my trip, is to get a wife
|
my trip, is to get a wife
|
||||||
If there is any old maid
|
If there is any old maids
|
||||||
or widow out there I
|
or widows out there I
|
||||||
want you to [hie?] them
|
want you to [hire?] them
|
||||||
at [our find?] me at them
|
at one [illegible] [illegible] at them
|
||||||
as soon as I get there.]
|
as soon as I get there.]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
from transcription.ui.components.error_presenter import display_error_category
|
from transcription.ui.components.error_presenter import display_error_category
|
||||||
|
from transcription.ui.components.error_presenter import run_ui_action
|
||||||
|
|
||||||
|
|
||||||
def test_display_error_category_uses_canonical_taxonomy():
|
def test_display_error_category_uses_canonical_taxonomy():
|
||||||
@@ -12,3 +15,48 @@ def test_display_error_category_uses_canonical_taxonomy():
|
|||||||
assert display_error_category(AppError("x", category=ErrorCategory.INFRA_TRANSIENT)) == "timeout"
|
assert display_error_category(AppError("x", category=ErrorCategory.INFRA_TRANSIENT)) == "timeout"
|
||||||
assert display_error_category(AppError("x", category=ErrorCategory.PROCESSING)) == "internal"
|
assert display_error_category(AppError("x", category=ErrorCategory.PROCESSING)) == "internal"
|
||||||
assert display_error_category(AppError("x", category=ErrorCategory.INFRA_PERSISTENT)) == "internal"
|
assert display_error_category(AppError("x", category=ErrorCategory.INFRA_PERSISTENT)) == "internal"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_ui_action_returns_success_value():
|
||||||
|
outcome = await run_ui_action(
|
||||||
|
operation="ui.test.success",
|
||||||
|
title="Should not show",
|
||||||
|
action=lambda: _async_value(123),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.ok is True
|
||||||
|
assert outcome.value == 123
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_ui_action_shows_error_and_returns_failed(monkeypatch):
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def _capture(exc: Exception, *, title: str, operation: str) -> None:
|
||||||
|
captured["exc"] = exc
|
||||||
|
captured["title"] = title
|
||||||
|
captured["operation"] = operation
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.ui.components.error_presenter.show_error", _capture)
|
||||||
|
|
||||||
|
outcome = await run_ui_action(
|
||||||
|
operation="ui.test.failure",
|
||||||
|
title="Load failed",
|
||||||
|
action=lambda: _async_raises(RuntimeError("boom")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert outcome.ok is False
|
||||||
|
assert outcome.value is None
|
||||||
|
assert isinstance(captured["exc"], RuntimeError)
|
||||||
|
assert str(captured["exc"]) == "boom"
|
||||||
|
assert captured["title"] == "Load failed"
|
||||||
|
assert captured["operation"] == "ui.test.failure"
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_value(value: int) -> int:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_raises(exc: Exception) -> int:
|
||||||
|
raise exc
|
||||||
|
|||||||
Reference in New Issue
Block a user