generated from john/python-template
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3d0b7ee0 | ||
|
|
7eca9fe7dc | ||
|
|
e54c2d9f26 | ||
|
|
065acad125 | ||
|
|
eeb1888aa3 | ||
|
|
321c454a4f | ||
|
|
6f4accf275 | ||
|
|
27ec81ca5b | ||
|
|
4410d23f5c | ||
|
|
d5e798825d | ||
|
|
02dca888d2 | ||
|
|
a896a11d2e | ||
|
|
0b48c80d87 | ||
|
|
70f8d6182e | ||
|
|
fc4288ff88 | ||
|
|
e5ef4d4422 | ||
|
|
88cef169c4 | ||
|
|
15a4814e23 | ||
|
|
16391463d6 | ||
|
|
96bb80d91f | ||
|
|
494f378e48 | ||
|
|
75dc946123 | ||
|
|
929f8d6de9 | ||
|
|
72909c1fd5 | ||
|
|
a5ec9f40a8 | ||
|
|
0d5206fe97 | ||
|
|
32b6b5f29a | ||
|
|
9b6bb9ae66 | ||
|
|
4c877dd6a2 | ||
|
|
9990583345 | ||
|
|
daa1642933 | ||
|
|
d198cc0c68 | ||
|
|
34c7b16675 | ||
|
|
ca29bc8b74 | ||
|
|
89ed83239e | ||
|
|
2c6ef46f5f | ||
|
|
c2ed98c16d | ||
|
|
c85cc6be20 | ||
|
|
234476ba6d | ||
|
|
faa30fd27b | ||
|
|
867cc9eb78 | ||
|
|
0e43094b80 | ||
|
|
c05c054b94 | ||
|
|
c0112c2714 | ||
|
|
96af7b2dc0 | ||
|
|
9a7970c533 | ||
|
|
f15c9834e4 | ||
|
|
2c59cbd2c7 | ||
|
|
5ff66a8c40 | ||
|
|
626b5d4b10 | ||
|
|
12f125761a | ||
|
|
803237371e | ||
|
|
d4ae97c1b1 | ||
|
|
c52d41ec33 |
@@ -1,66 +0,0 @@
|
|||||||
# Canonical settings mirror for src/transcription/config.py (Settings).
|
|
||||||
# Any value here overrides the in-code default.
|
|
||||||
|
|
||||||
# --- NiceGUI Server ---
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=8000
|
|
||||||
# LOG_LEVEL: critical | error | warning | info | debug | trace
|
|
||||||
LOG_LEVEL=info
|
|
||||||
RELOAD=false
|
|
||||||
LOG_DIR=./data/logs
|
|
||||||
LOG_FILE_NAME=transcription.log
|
|
||||||
LOG_FILE_MAX_BYTES=10485760
|
|
||||||
LOG_FILE_BACKUP_COUNT=5
|
|
||||||
|
|
||||||
# --- AI provider ---
|
|
||||||
# PROVIDER: openrouter
|
|
||||||
PROVIDER=openrouter
|
|
||||||
# Required.
|
|
||||||
OPENROUTER_API_KEY=your-api-key-goes-here
|
|
||||||
PROVIDER_MODEL=google/gemini-2.5-flash
|
|
||||||
# PROVIDER_MODELS default: derived from PROVIDER_MODEL when omitted.
|
|
||||||
# If provided, use a non-empty JSON array.
|
|
||||||
# PROVIDER_MODELS=["google/gemini-2.5-flash","anthropic/claude-sonnet-4"]
|
|
||||||
# OPENROUTER_HTTP_REFERER=
|
|
||||||
# OPENROUTER_APP_TITLE=
|
|
||||||
DEFAULT_PROMPT_NAME=transcribe_document.md
|
|
||||||
# TRANSCRIPTION_TEMPERATURE default: unset (optional range 0.0..2.0)
|
|
||||||
# TRANSCRIPTION_TEMPERATURE=
|
|
||||||
# TRANSCRIPTION_TOP_P default: unset (optional range 0.0..1.0)
|
|
||||||
# TRANSCRIPTION_TOP_P=
|
|
||||||
|
|
||||||
# --- runtime environment ---
|
|
||||||
# ENVIRONMENT: development | test | production
|
|
||||||
ENVIRONMENT=development
|
|
||||||
# TRANSCRIPTION_COMMIT default: unset (optional build/commit identifier for provenance evidence)
|
|
||||||
# TRANSCRIPTION_COMMIT=
|
|
||||||
|
|
||||||
# --- persistence ---
|
|
||||||
# Use nested keys (env_nested_delimiter="__").
|
|
||||||
DATABASE__DRIVER=sqlite
|
|
||||||
DATABASE__PATH=./data/transcription.db
|
|
||||||
# Postgres example:
|
|
||||||
# DATABASE__DRIVER=postgres
|
|
||||||
# DATABASE__HOST=localhost
|
|
||||||
# DATABASE__PORT=5432
|
|
||||||
# DATABASE__DATABASE=transcription
|
|
||||||
# DATABASE__USER=postgres
|
|
||||||
# DATABASE__PASSWORD=change-me
|
|
||||||
BOOTSTRAP_SCHEMA_ON_STARTUP=false
|
|
||||||
SQLITE_CHECK_SAME_THREAD=false
|
|
||||||
|
|
||||||
# --- filesystem paths ---
|
|
||||||
UPLOAD_DIR=./data
|
|
||||||
PROMPT_DIR=./prompts
|
|
||||||
DATABASE_BACKUP_DIR=./data/backups
|
|
||||||
|
|
||||||
# --- worker reliability ---
|
|
||||||
WORKER_MAX_RETRIES=0
|
|
||||||
WORKER_PROVIDER_TIMEOUT_SECONDS=30.0
|
|
||||||
WORKER_STALE_JOB_SECONDS=30.0
|
|
||||||
WORKER_RETRY_BACKOFF_SECONDS=1.0
|
|
||||||
WORKER_SHUTDOWN_GRACE_SECONDS=5.0
|
|
||||||
WORKER_POLL_INTERVAL_SECONDS=1.0
|
|
||||||
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
|
||||||
WORKER_MIN_TRANSCRIPTION_LINES=0
|
|
||||||
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Production environment example for docker-compose.production.yml
|
||||||
|
|
||||||
|
# --- NiceGUI Server ---
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
RELOAD=false
|
||||||
|
ENVIRONMENT=production
|
||||||
|
# TRANSCRIPTION_COMMIT=
|
||||||
|
RUN_EMBEDDED_WORKER=false
|
||||||
|
LOG_DIR=/app/data/logs
|
||||||
|
LOG_FILE_NAME=transcription.log
|
||||||
|
LOG_FILE_MAX_BYTES=10485760
|
||||||
|
LOG_FILE_BACKUP_COUNT=5
|
||||||
|
|
||||||
|
# --- AI provider ---
|
||||||
|
PROVIDER=openrouter
|
||||||
|
OPENROUTER_API_KEY=replace-with-real-key
|
||||||
|
PROVIDER_MODEL=google/gemini-2.5-flash
|
||||||
|
# PROVIDER_MODELS=["google/gemini-2.5-flash","anthropic/claude-sonnet-4"]
|
||||||
|
# OPENROUTER_HTTP_REFERER=
|
||||||
|
# OPENROUTER_APP_TITLE=
|
||||||
|
DEFAULT_PROMPT_NAME=transcribe_document.md
|
||||||
|
# TRANSCRIPTION_TEMPERATURE=
|
||||||
|
# TRANSCRIPTION_TOP_P=
|
||||||
|
|
||||||
|
# --- persistence ---
|
||||||
|
# Common database settings:
|
||||||
|
DATABASE__DRIVER=postgres
|
||||||
|
DATABASE__DATABASE=transcription
|
||||||
|
DATABASE__USER=transcription
|
||||||
|
DATABASE__PASSWORD=replace-with-strong-password
|
||||||
|
BOOTSTRAP_SCHEMA_ON_STARTUP=false
|
||||||
|
|
||||||
|
# SQLite-specific settings:
|
||||||
|
# DATABASE__PATH=./data/transcription.db
|
||||||
|
# SQLITE_CHECK_SAME_THREAD=false
|
||||||
|
|
||||||
|
# Postgres-specific settings:
|
||||||
|
DATABASE__HOST=postgres
|
||||||
|
DATABASE__PORT=5432
|
||||||
|
|
||||||
|
# --- filesystem paths ---
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
PROMPT_DIR=/app/prompts
|
||||||
|
# --- backup workflow helpers (not Runtime Settings model fields) ---
|
||||||
|
BACKUP_DIR=/backup
|
||||||
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
|
||||||
|
# --- worker reliability ---
|
||||||
|
WORKER_MAX_RETRIES=0
|
||||||
|
WORKER_PROVIDER_TIMEOUT_SECONDS=30.0
|
||||||
|
WORKER_STALE_JOB_SECONDS=90.0
|
||||||
|
WORKER_RETRY_BACKOFF_SECONDS=1.0
|
||||||
|
WORKER_SHUTDOWN_GRACE_SECONDS=5.0
|
||||||
|
WORKER_POLL_INTERVAL_SECONDS=1.0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_LINES=0
|
||||||
|
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||||
|
|
||||||
|
# --- cloudflare tunnel ---
|
||||||
|
# Required for token-based tunnel startup.
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token
|
||||||
|
|
||||||
|
# --- deployment wiring helpers ---
|
||||||
|
# Runtime Settings writes target this file path inside the app container.
|
||||||
|
RUNTIME_SETTINGS_ENV_FILE=/app/.env.production
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Production environment
|
||||||
|
|
||||||
|
# --- NiceGUI Server ---
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
RELOAD=false
|
||||||
|
ENVIRONMENT=production
|
||||||
|
# TRANSCRIPTION_COMMIT=
|
||||||
|
RUN_EMBEDDED_WORKER=false
|
||||||
|
LOG_DIR=/app/data/logs
|
||||||
|
LOG_FILE_NAME=transcription.log
|
||||||
|
LOG_FILE_MAX_BYTES=10485760
|
||||||
|
LOG_FILE_BACKUP_COUNT=5
|
||||||
|
|
||||||
|
# --- AI provider ---
|
||||||
|
PROVIDER=openrouter
|
||||||
|
OPENROUTER_API_KEY=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
|
||||||
|
PROVIDER_MODEL=google/gemini-2.5-flash
|
||||||
|
PROVIDER_MODELS=["google/gemini-2.5-pro","google/gemini-2.5-flash","anthropic/claude-opus-5","anthropic/claude-sonnet-4","openai/gpt-5.6","openai/gpt-4o"]
|
||||||
|
# OPENROUTER_HTTP_REFERER=
|
||||||
|
# OPENROUTER_APP_TITLE=
|
||||||
|
DEFAULT_PROMPT_NAME=transcribe_document.md
|
||||||
|
# TRANSCRIPTION_TEMPERATURE=
|
||||||
|
# TRANSCRIPTION_TOP_P=
|
||||||
|
|
||||||
|
# --- persistence ---
|
||||||
|
# Common database settings:
|
||||||
|
DATABASE__DRIVER=postgres
|
||||||
|
DATABASE__DATABASE=transcription
|
||||||
|
DATABASE__USER=transcription
|
||||||
|
DATABASE__PASSWORD=<password>
|
||||||
|
BOOTSTRAP_SCHEMA_ON_STARTUP=false
|
||||||
|
|
||||||
|
# SQLite-specific settings:
|
||||||
|
# DATABASE__PATH=./data/transcription.db
|
||||||
|
# SQLITE_CHECK_SAME_THREAD=false
|
||||||
|
|
||||||
|
# Postgres-specific settings:
|
||||||
|
DATABASE__HOST=postgres
|
||||||
|
DATABASE__PORT=5432
|
||||||
|
|
||||||
|
# --- filesystem paths ---
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
PROMPT_DIR=/app/prompts
|
||||||
|
# --- backup workflow helpers (not Runtime Settings model fields) ---
|
||||||
|
BACKUP_DIR=/backup
|
||||||
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
|
||||||
|
# --- worker reliability ---
|
||||||
|
WORKER_MAX_RETRIES=0
|
||||||
|
WORKER_PROVIDER_TIMEOUT_SECONDS=30.0
|
||||||
|
WORKER_STALE_JOB_SECONDS=30.0
|
||||||
|
WORKER_RETRY_BACKOFF_SECONDS=1.0
|
||||||
|
WORKER_SHUTDOWN_GRACE_SECONDS=5.0
|
||||||
|
WORKER_POLL_INTERVAL_SECONDS=1.0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_LINES=0
|
||||||
|
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||||
|
|
||||||
|
# --- cloudflare tunnel ---
|
||||||
|
# Required for token-based tunnel startup.
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiYTRhNjM0NzNhNzBiZjhhYmY3OWUyNjE4ZTcyNjgwZmMiLCJ0IjoiZWY1MjFkNWItYzY1ZS00ZGFmLTlmYTMtMzQyOGYzMGUyMDY4IiwicyI6IlpHWm1NVGRsTVRVdFpEYzNaaTAwWkRJeUxXRmhPRFV0TmpKallXRmhPRFJrWXpSaSJ9
|
||||||
|
|
||||||
|
# --- deployment wiring helpers ---
|
||||||
|
# Runtime Settings writes target this file path inside the app container.
|
||||||
|
RUNTIME_SETTINGS_ENV_FILE=/app/.env.production
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Production environment
|
||||||
|
|
||||||
|
# --- NiceGUI Server ---
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
RELOAD=false
|
||||||
|
ENVIRONMENT=production
|
||||||
|
# TRANSCRIPTION_COMMIT=
|
||||||
|
RUN_EMBEDDED_WORKER=false
|
||||||
|
LOG_DIR=/app/data/logs
|
||||||
|
LOG_FILE_NAME=transcription.log
|
||||||
|
LOG_FILE_MAX_BYTES=10485760
|
||||||
|
LOG_FILE_BACKUP_COUNT=5
|
||||||
|
|
||||||
|
# --- AI provider ---
|
||||||
|
PROVIDER=openrouter
|
||||||
|
OPENROUTER_API_KEY=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
|
||||||
|
PROVIDER_MODEL=google/gemini-2.5-flash
|
||||||
|
PROVIDER_MODELS=["google/gemini-2.5-pro","google/gemini-2.5-flash","anthropic/claude-opus-5","anthropic/claude-sonnet-4","openai/gpt-5.6","openai/gpt-4o"]
|
||||||
|
# OPENROUTER_HTTP_REFERER=
|
||||||
|
# OPENROUTER_APP_TITLE=
|
||||||
|
DEFAULT_PROMPT_NAME=transcribe_document.md
|
||||||
|
# TRANSCRIPTION_TEMPERATURE=
|
||||||
|
# TRANSCRIPTION_TOP_P=
|
||||||
|
|
||||||
|
# --- persistence ---
|
||||||
|
# Common database settings:
|
||||||
|
DATABASE__DRIVER=sqlite
|
||||||
|
# DATABASE__DATABASE=transcription
|
||||||
|
# DATABASE__USER=transcription-local
|
||||||
|
# DATABASE__PASSWORD=My!3sons
|
||||||
|
# BOOTSTRAP_SCHEMA_ON_STARTUP=false
|
||||||
|
|
||||||
|
# SQLite-specific settings:
|
||||||
|
DATABASE__PATH=./data-local/transcription-local.db
|
||||||
|
SQLITE_CHECK_SAME_THREAD=false
|
||||||
|
|
||||||
|
# Postgres-specific settings:
|
||||||
|
# DATABASE__HOST=postgres
|
||||||
|
# DATABASE__PORT=5432
|
||||||
|
|
||||||
|
# --- filesystem paths ---
|
||||||
|
UPLOAD_DIR=./data-local
|
||||||
|
PROMPT_DIR=/data/prompts
|
||||||
|
# --- backup workflow helpers (not Runtime Settings model fields) ---
|
||||||
|
BACKUP_DIR=/backup
|
||||||
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
|
||||||
|
# --- worker reliability ---
|
||||||
|
WORKER_MAX_RETRIES=0
|
||||||
|
WORKER_PROVIDER_TIMEOUT_SECONDS=30.0
|
||||||
|
WORKER_STALE_JOB_SECONDS=30.0
|
||||||
|
WORKER_RETRY_BACKOFF_SECONDS=1.0
|
||||||
|
WORKER_SHUTDOWN_GRACE_SECONDS=5.0
|
||||||
|
WORKER_POLL_INTERVAL_SECONDS=1.0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
||||||
|
WORKER_MIN_TRANSCRIPTION_LINES=0
|
||||||
|
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
||||||
|
|
||||||
|
# --- cloudflare tunnel ---
|
||||||
|
# Required for token-based tunnel startup.
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=CLOUDFLARE_TUNNEL_TOKEN=eyJhIjoiYTRhNjM0NzNhNzBiZjhhYmY3OWUyNjE4ZTcyNjgwZmMiLCJ0IjoiZWY1MjFkNWItYzY1ZS00ZGFmLTlmYTMtMzQyOGYzMGUyMDY4IiwicyI6IlpHWm1NVGRsTVRVdFpEYzNaaTAwWkRJeUxXRmhPRFV0TmpKallXRmhPRFJrWXpSaSJ9
|
||||||
|
|
||||||
|
# --- deployment wiring helpers ---
|
||||||
|
# Runtime Settings writes target this file path inside the app container.
|
||||||
|
RUNTIME_SETTINGS_ENV_FILE=/app/.env.production
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Python Architect Reviewer
|
name: Python Architect Reviewer
|
||||||
description: Evidence-based senior architect reviewer for FastAPI, NiceGUI, and SQLModel codebases.
|
description: Evidence-based senior architect reviewer for FastAPI, NiceGUI, and SQLModel codebases.
|
||||||
tools:
|
|
||||||
- read_file
|
|
||||||
- list_dir
|
|
||||||
- file_search
|
|
||||||
- grep_search
|
|
||||||
- run_in_terminal
|
|
||||||
skills:
|
skills:
|
||||||
- python-code-reviewer
|
- python-code-reviewer
|
||||||
---
|
---
|
||||||
@@ -15,10 +9,15 @@ skills:
|
|||||||
|
|
||||||
You are a Senior Python Architect performing an evidence-based, read-only code review.
|
You are a Senior Python Architect performing an evidence-based, read-only code review.
|
||||||
|
|
||||||
|
> No `tools:` allowlist is declared here on purpose. Tool identifiers differ between the runtimes
|
||||||
|
> this agent is invoked from, so a hard-coded list silently under-tools the agent in one of them.
|
||||||
|
> Read-only discipline is enforced by the **Read-Only Scope** rule below, not by the frontmatter.
|
||||||
|
|
||||||
## Operating Principles
|
## Operating Principles
|
||||||
|
|
||||||
- **Stack Context:** Python 3.12+, FastAPI, NiceGUI, SQLModel, SQLAlchemy (SQLite/PostgreSQL), Pydantic V2, asyncio workers, and OpenRouter adapters.
|
- **Stack Context:** Python 3.12+, FastAPI, NiceGUI, SQLModel, SQLAlchemy (SQLite/PostgreSQL), Pydantic V2, asyncio workers, and OpenRouter adapters.
|
||||||
- **Evidence-Based:** Always inspect real files. Every finding must reference concrete file paths and line numbers (e.g., `app/services/worker.py:45-78`). Do not speculate.
|
- **Evidence-Based:** Always inspect real files. Every finding must reference concrete file paths and line numbers (e.g., `app/services/worker.py:45-78`). Do not speculate.
|
||||||
- **Tool Verification:** Run linters and tests via the terminal (`ruff check`, `pytest`, `ty`) to verify issues before reporting.
|
- **Tool Verification:** This is a `uv` project; the toolchain is not on `PATH`. Verify with `uv run ruff check .`, `uv run ty check`, and `uv run pytest -q -m "not external"`, and record the exact commands and outcomes. Never report a lint, type, or test claim you did not run.
|
||||||
- **Skill Execution:** Adhere strictly to the review dimensions, duplication analysis, and report scaffolding defined in the `python-code-reviewer` skill.
|
- **Verify Recommendations, Not Just Findings:** Before recommending a change to a shared symbol, enumerate its consumers and confirm the fix is safe for each. See the skill's consumer-tracing step and `Blast Radius` field.
|
||||||
- **Report Target:** Output all complete review reports as Markdown files written to `./docs`.
|
- **Skill Is Canonical:** The `python-code-reviewer` skill defines the review workflow, deterministic checks, severity and reachability rubrics, report location, and report template. Follow it exactly. Where this file and the skill disagree, the skill wins — do not restate its specifics here.
|
||||||
|
- **Read-Only Scope:** Do not modify source, tests, docs, instructions, or configuration. The review report is the only artifact you produce.
|
||||||
@@ -7,6 +7,11 @@ applyTo: 'src/transcription/**/*.py'
|
|||||||
|
|
||||||
Keep docs in sync in the same change whenever implementation alters a documented contract, behavior, or roadmap decision.
|
Keep docs in sync in the same change whenever implementation alters a documented contract, behavior, or roadmap decision.
|
||||||
|
|
||||||
|
Documentation targets below always refer to the **current** baseline. `docs/index.md` states which
|
||||||
|
baseline that is; resolve any version-specific document from there. Never cite a superseded version
|
||||||
|
tree by name in this file or in the docs you update — retired revision trees are not authority, and
|
||||||
|
`tests/test_meta_contract_guards.py` fails active contract files that route authority through them.
|
||||||
|
|
||||||
## Update documentation when any of these change
|
## Update documentation when any of these change
|
||||||
|
|
||||||
1. **Schema/Data contract**
|
1. **Schema/Data contract**
|
||||||
@@ -15,7 +20,7 @@ Keep docs in sync in the same change whenever implementation alters a documented
|
|||||||
|
|
||||||
2. **Configuration contract**
|
2. **Configuration contract**
|
||||||
- `Settings` keys, defaults, required/optional environment values.
|
- `Settings` keys, defaults, required/optional environment values.
|
||||||
- **Required doc update:** `.env.example` and any directly related setup docs.
|
- **Required doc update:** `.env.production.example` and any directly related setup docs.
|
||||||
|
|
||||||
3. **User-visible UI behavior**
|
3. **User-visible UI behavior**
|
||||||
- Page flow, routes, button/action behavior, labels, status wording, empty/error states.
|
- Page flow, routes, button/action behavior, labels, status wording, empty/error states.
|
||||||
@@ -27,7 +32,8 @@ Keep docs in sync in the same change whenever implementation alters a documented
|
|||||||
|
|
||||||
5. **Roadmap/scope decisions**
|
5. **Roadmap/scope decisions**
|
||||||
- Version targets, sequencing, deferrals, and accepted alternatives.
|
- Version targets, sequencing, deferrals, and accepted alternatives.
|
||||||
- **Required doc update:** `docs/roadmap_plan.md` and related backlog docs (for example `docs/ver4.8/feature_backlog_v4_8.md`).
|
- **Required doc update:** `docs/roadmap_plan.md`, plus any backlog or feature document for the
|
||||||
|
current baseline. Locate it through `docs/index.md` rather than assuming a version-named path.
|
||||||
|
|
||||||
## Working rule
|
## Working rule
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,30 @@ Required internal -> canonical mapping:
|
|||||||
- Include actionable remediation guidance aligned to category.
|
- Include actionable remediation guidance aligned to category.
|
||||||
- Keep envelope structure consistent across API endpoints.
|
- Keep envelope structure consistent across API endpoints.
|
||||||
|
|
||||||
|
### `AppError.message` vs `AppError.detail`
|
||||||
|
|
||||||
|
`AppError` carries two texts with different audiences, and they must not be collapsed. Getting this
|
||||||
|
wrong has already caused a real defect in this repository, in both directions.
|
||||||
|
|
||||||
|
| Attribute | Audience | Reaches | Rule |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `message` | User and API clients | `ErrorEnvelope.message`, UI notifications | Stays generic. Never embed exception text, provider payloads, or filesystem paths. |
|
||||||
|
| `detail` | Internal only | Logs, and `format_error_detail` -> `ExecutionAttempt.error_detail` and `MaintenanceRun.error_detail` | Carries the root cause. Never rendered to users or serialized into an envelope without a sanitizing projection. |
|
||||||
|
|
||||||
|
- Putting root-cause data in `message` leaks infrastructure detail to users.
|
||||||
|
- Omitting it from `detail` silently degrades the provenance record this system exists to preserve —
|
||||||
|
a failed attempt whose `error_detail` says nothing is an attempt that cannot be diagnosed later.
|
||||||
|
- Any render boundary that displays persisted `error_detail` must apply the same no-local-path rule
|
||||||
|
as `message`: sanitize machine-local absolute paths before the text becomes user-visible.
|
||||||
|
- When you raise from a caught exception, populate **both**: a generic `message` and a `detail`
|
||||||
|
carrying `type(exc).__name__` and the exception text, with `raise ... from exc`.
|
||||||
|
- `detail` is optional (`None`). A read path that assumes it is populated must handle its absence.
|
||||||
|
- Before changing either attribute, or any helper that formats them, enumerate every consumer —
|
||||||
|
evidence writes, maintenance runs, logging, API envelopes, and UI presentation all read these
|
||||||
|
fields, and tests assert on the persisted text.
|
||||||
|
|
||||||
|
Canonical definitions live in `src/transcription/errors.py`; see also `docs/error_handling.md`.
|
||||||
|
|
||||||
## Logging and Diagnostics
|
## Logging and Diagnostics
|
||||||
|
|
||||||
- Log operation identifiers and error IDs where available.
|
- Log operation identifiers and error IDs where available.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
description: Provider adapter rules for evidence capture, secret safety, and client lifecycle.
|
||||||
|
applyTo: 'src/transcription/providers/**/*.py'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Provider Adapters
|
||||||
|
|
||||||
|
Primary references:
|
||||||
|
|
||||||
|
- `docs/invariant/ai_evidence_and_provenance.md` (canonical; provider adapters own provider-boundary evidence capture)
|
||||||
|
- `docs/architecture.md`
|
||||||
|
- `docs/schema.md`
|
||||||
|
|
||||||
|
The provider layer is where an external API becomes application data. It is also the only place
|
||||||
|
that can capture what actually crossed the wire — once a response reaches a service, the evidence
|
||||||
|
it did not preserve is gone permanently. Treat capture correctness as the primary job of this
|
||||||
|
layer and text extraction as secondary.
|
||||||
|
|
||||||
|
## Layer Boundary
|
||||||
|
|
||||||
|
- Adapters may depend on `transcription.config`, `transcription.providers.*`, the HTTP client, and
|
||||||
|
the provider SDK. They must not import `services`, `db`, `ui`, or `api`.
|
||||||
|
- Provider specifics — headers, model slugs, payload shapes, SDK types, error classes — stop here.
|
||||||
|
Callers receive `TranscriptionResult` and `ProviderError` subclasses only.
|
||||||
|
- Adapters raise provider/domain exceptions. They must not emit user-facing text, notifications,
|
||||||
|
or remediation wording; that translation belongs to services and UI. See
|
||||||
|
[error-handling instructions](./error-handling.instructions.md).
|
||||||
|
- Adapters do not persist. They return evidence; services decide what is written and when.
|
||||||
|
|
||||||
|
Enforced by `tests/test_provider_boundaries.py`.
|
||||||
|
|
||||||
|
## Contract Surface
|
||||||
|
|
||||||
|
- Every adapter satisfies the `TranscriptionProvider` protocol in `base.py`. Failed-call evidence is
|
||||||
|
returned through the caller-owned `ProviderCallEvidence` sink passed to `transcribe()`, so
|
||||||
|
evidence stays scoped to one invocation instead of living on mutable adapter instance state.
|
||||||
|
- `TranscriptionResult`, `RequestManifest`, and `TransportEvidence` are `extra="forbid"` and frozen.
|
||||||
|
Add a field to the contract rather than smuggling data through an untyped dict.
|
||||||
|
- Evidence contracts in `evidence.py` are versioned (`schema_name` + `schema_version`). A change to
|
||||||
|
the meaning or shape of a captured field requires a version bump, not a silent redefinition —
|
||||||
|
stored evidence must keep its original meaning.
|
||||||
|
|
||||||
|
## Transport Evidence
|
||||||
|
|
||||||
|
The rules below implement `docs/invariant/ai_evidence_and_provenance.md` §3.4-3.5. That document
|
||||||
|
wins if this file drifts from it.
|
||||||
|
|
||||||
|
- Capture the response body **at the HTTP boundary, before SDK parsing**, so fields the SDK does
|
||||||
|
not model are not lost. `_CapturingAsyncClient` exists for this; do not replace it with a
|
||||||
|
post-parse `model_dump()` and call the result transport evidence.
|
||||||
|
- Reset per-call capture state at the start of every call. Without it, a connection failure can
|
||||||
|
attach the *previous* call's response as evidence for this one. Guarded by
|
||||||
|
`tests/test_evidence_provenance.py::test_openrouter_does_not_reuse_prior_response_on_connection_failure`.
|
||||||
|
- Keep transport capture scoped to the call, not the adapter instance. Concurrent `transcribe()`
|
||||||
|
calls on one adapter must not be able to overwrite each other's response evidence.
|
||||||
|
- Handle the streamed-body case (`httpx.ResponseNotRead`) rather than assuming `response.content`
|
||||||
|
is always available.
|
||||||
|
- When no response arrives — timeout, DNS, connection reset — emit
|
||||||
|
`TransportEvidence(response_received=False)`. Absence of a response is itself evidence and must
|
||||||
|
be explicit, never an empty body or a missing record.
|
||||||
|
- Preserve safe response evidence for **unsuccessful** calls too, whenever a response was received.
|
||||||
|
- Never relabel an SDK snapshot or normalized metadata as transport evidence, and never backfill
|
||||||
|
it into an execution that predates capture.
|
||||||
|
|
||||||
|
## Secret Safety
|
||||||
|
|
||||||
|
- Persist response headers only through `filter_safe_response_headers` and the
|
||||||
|
`SAFE_RESPONSE_HEADERS` allowlist. Allowlist, never denylist: capture-then-redact is prohibited,
|
||||||
|
because an unknown header is unsafe by default.
|
||||||
|
- Adding a header to the allowlist is a deliberate evidence decision. Confirm it carries no
|
||||||
|
credential, cookie, or session material, and state why it is needed for correlation, content
|
||||||
|
interpretation, rate-limit diagnosis, or audit.
|
||||||
|
- API keys, `Authorization`, and cookies must never appear in a manifest, evidence record, log
|
||||||
|
line, or exception message.
|
||||||
|
- The request manifest references source content by identity (digest, size, media type, page).
|
||||||
|
Do not duplicate base64 source bytes into it — `_replace_embedded_media` exists for this.
|
||||||
|
|
||||||
|
## Execution Specification
|
||||||
|
|
||||||
|
The manifest must let a reader reconstruct what was asked, per invariant §3.3:
|
||||||
|
|
||||||
|
- Provider, requested model, full effective prompt text, and prompt digest.
|
||||||
|
- Every explicitly supplied parameter, and — separately — which optional parameters were
|
||||||
|
**omitted**. Omission is not the same as a null value or an assumed provider default; the
|
||||||
|
`optional_parameter_states` distinction between `omitted`, `null`, and `value` is deliberate.
|
||||||
|
- Timeout budget, retry policy, source reference, and `SoftwareContext` versions.
|
||||||
|
- Manifest digests use `canonical_json_bytes`. Do not hash a plain `json.dumps()`; key order and
|
||||||
|
separators must stay deterministic or digests become uncomparable.
|
||||||
|
|
||||||
|
## Client Lifecycle and Async Safety
|
||||||
|
|
||||||
|
- Reuse one pooled `AsyncClient` per adapter instance; do not construct a client per request.
|
||||||
|
- Accept an injected client so tests can drive the adapter without network access.
|
||||||
|
- Derive timeouts from `Settings` (`worker_provider_timeout_seconds`) rather than hard-coding, and
|
||||||
|
keep the client timeout aligned with the configured budget so the SDK cannot expire first and
|
||||||
|
hide the real failure.
|
||||||
|
- Implement `aclose()` and release pooled resources. An adapter that creates a client owns closing
|
||||||
|
it; one given a client must not close a caller-owned resource it did not create.
|
||||||
|
- Never block the event loop. Offload CPU-bound work (hashing large payloads, image encoding) with
|
||||||
|
`asyncio.to_thread`.
|
||||||
|
- Propagate `asyncio.CancelledError` untouched — do not convert cancellation into a provider error.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
- Raise `ProviderAuthError` for authentication, `ProviderResponseError` for malformed or unusable
|
||||||
|
responses, and `ProviderError` otherwise.
|
||||||
|
- Always attach `request_manifest`, `transport_evidence`, and an accurate `failure_phase` to raised
|
||||||
|
errors. `failure_phase` must distinguish a received-but-failed response from a call that never
|
||||||
|
reached the provider.
|
||||||
|
- Validate responses with Pydantic rather than indexing into raw dicts.
|
||||||
|
- Invalid *optional* metadata (for example unparsable token counts) must not discard an otherwise
|
||||||
|
valid transcript. Degrade the metadata, not the result.
|
||||||
|
|
||||||
|
## Contract Sync Rule
|
||||||
|
|
||||||
|
If capture behavior, evidence schema, or the header allowlist changes:
|
||||||
|
|
||||||
|
1. Update `docs/invariant/ai_evidence_and_provenance.md` only if the durable preservation contract
|
||||||
|
itself is changing — that revision is deliberate and reviewed, not incidental.
|
||||||
|
2. Update `docs/schema.md` when persisted evidence fields change.
|
||||||
|
3. Update or add tests in the same change (`tests/providers/`, `tests/test_evidence_provenance.py`).
|
||||||
|
4. Bump the affected evidence `schema_version` when a field's meaning changes.
|
||||||
@@ -17,9 +17,17 @@ applyTo: 'src/transcription/services/*.py'
|
|||||||
- **A service module must not import another service module.** This is enforced by
|
- **A service module must not import another service module.** This is enforced by
|
||||||
[test_service_boundaries](../../tests/test_service_boundaries.py). Shared types go in a
|
[test_service_boundaries](../../tests/test_service_boundaries.py). Shared types go in a
|
||||||
neutral module that defines no service class (see [errors](../../src/transcription/services/errors.py)).
|
neutral module that defines no service class (see [errors](../../src/transcription/services/errors.py)).
|
||||||
- Not every module in this package is a service. Helper modules that define no `*Service`
|
- Not every module in this package is a service. Modules fall into three kinds:
|
||||||
class (`base`, `errors`, `normalization`, `prompts`, `quality`, `media_storage`,
|
- **Aggregate services** own models and define a `*Service` class: `documents.py`, `sources.py`,
|
||||||
`source_media`) are free-function modules and are exempt from the service rules below.
|
`jobs.py`, `people.py`, `photos.py`, `maintenance.py`, and `evidence.py` (read/projection only,
|
||||||
|
owns nothing).
|
||||||
|
- **Orchestration modules** define no service class and compose writes across aggregates:
|
||||||
|
`store.py`, `workflows.py`. They are the sanctioned place to create or delete rows owned by more
|
||||||
|
than one service — see [Service Composition](#service-composition).
|
||||||
|
- **Shared infrastructure and free-function helpers** are exempt from the service rules below:
|
||||||
|
`base.py` (`ServiceBase`), `registry.py` (`RegistryService`, a generic base for lookup tables —
|
||||||
|
not an aggregate owner itself), `unit_of_work.py`, `errors.py`, `normalization.py`, `prompts.py`,
|
||||||
|
`quality.py`, `media_storage.py`, `source_media.py`. `__init__.py` exposes `ServiceBundle`.
|
||||||
- Cross-cutting error behavior must follow
|
- Cross-cutting error behavior must follow
|
||||||
[error-handling instructions](./error-handling.instructions.md).
|
[error-handling instructions](./error-handling.instructions.md).
|
||||||
|
|
||||||
@@ -30,11 +38,38 @@ is the only service that may **create or delete** its rows.
|
|||||||
|
|
||||||
| Model | Owner |
|
| Model | Owner |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `Document`, `DocumentType` | `DocumentService` |
|
| `Document`, `DocumentType`, `DocumentTag` | `DocumentService` |
|
||||||
| `Source`, `JobSource` | `SourceService` |
|
| `Source`, `JobSource` | `SourceService` |
|
||||||
| `Job` | `JobService` |
|
| `Job` | `JobService` |
|
||||||
| `Person`, `PersonRole`, `DocumentPerson` | `PeopleService` |
|
| `Person`, `PersonRole`, `DocumentPerson`, `PersonTag` | `PeopleService` |
|
||||||
|
| `GenealogyPerson`, `GenealogyFamily`, `GenealogyFamilyChild`, `GenealogyCitation` | `MaintenanceService` |
|
||||||
|
| `Photo` | `PhotosService` |
|
||||||
|
| `MaintenanceRun` | `MaintenanceService` |
|
||||||
| `ExecutionAttempt` | `SourceService` |
|
| `ExecutionAttempt` | `SourceService` |
|
||||||
|
| `Tag` | shared — see below |
|
||||||
|
|
||||||
|
Keep this table complete: every table in `src/transcription/db/models.py` appears exactly once,
|
||||||
|
except `Tag`. When you add a model, add its owner here in the same change.
|
||||||
|
|
||||||
|
### `Tag` is deliberately shared
|
||||||
|
|
||||||
|
`Tag` is one table reached through two `RegistryService[Tag]` facades that differ only in the
|
||||||
|
reference model they count usage through: `TagRegistry` (`documents.py`, via `DocumentTag`) and
|
||||||
|
`PersonTagRegistry` (`people.py`, via `PersonTag`). Both create and delete `Tag` rows through the
|
||||||
|
generic registry. This is the single sanctioned exception to one-owner-per-model — do not "fix" it by
|
||||||
|
assigning `Tag` to one service, because the other facade would then be creating rows it does not own.
|
||||||
|
Any change to `Tag` semantics, labels, or normalization must be validated against **both** facades
|
||||||
|
and the junction table each one counts.
|
||||||
|
|
||||||
|
`DocumentTag` and `PersonTag` follow the junction rule below: each is created and deleted only by the
|
||||||
|
service on its own side.
|
||||||
|
|
||||||
|
### Registries
|
||||||
|
|
||||||
|
`DocumentTypeRegistry`, `TagRegistry`, `PersonRoleRegistry`, and `PersonTagRegistry` are
|
||||||
|
`RegistryService` subclasses, not independent services. A registry belongs to the aggregate service
|
||||||
|
whose module declares it and shares that service's ownership. Registry CRUD uses `<operation>_entry`
|
||||||
|
naming (see [CRUD Methods](#crud-methods)).
|
||||||
|
|
||||||
### Junction tables
|
### Junction tables
|
||||||
|
|
||||||
@@ -50,8 +85,14 @@ lifecycle owner. The service on the other side may read through the junction (vi
|
|||||||
Two consequences follow, and both are deliberate:
|
Two consequences follow, and both are deliberate:
|
||||||
|
|
||||||
- **Cascade deletion is not a violation.** A service deleting the aggregate root it owns
|
- **Cascade deletion is not a violation.** A service deleting the aggregate root it owns
|
||||||
may delete junction rows referencing that root, because they cannot outlive it
|
may delete rows referencing that root which cannot outlive it
|
||||||
(`JobService.delete_job_with_guardrails`).
|
(`JobService.delete_job_with_guardrails` deletes the job's `job_source` rows).
|
||||||
|
- **Evidence deletion is an explicit workflow, not a runtime path.** `JobService.delete_job_and_evidence`
|
||||||
|
deletes `ExecutionAttempt` rows owned by `SourceService`. That is sanctioned because it is the
|
||||||
|
named retention workflow that `delete_job_with_guardrails` refuses to perform implicitly — that
|
||||||
|
method *blocks* deletion when attempts exist. Append-only means runtime code never rewrites or
|
||||||
|
removes history to represent a new outcome; it does not forbid a deliberate, operator-invoked
|
||||||
|
retention operation. Do not add a second path that deletes attempts.
|
||||||
- **Ownership governs creation and deletion, not every state transition.** `job_source` is
|
- **Ownership governs creation and deletion, not every state transition.** `job_source` is
|
||||||
both a link and the transcription work queue. `JobService.cancel_job` and
|
both a link and the transcription work queue. `JobService.cancel_job` and
|
||||||
`resubmit_failed_sources` transition `job_source.status` across a whole job, because that
|
`resubmit_failed_sources` transition `job_source.status` across a whole job, because that
|
||||||
@@ -133,6 +174,16 @@ pages before it. Enforced by `tests/integration/test_pipeline_atomicity.py`; per
|
|||||||
durability is separately enforced by
|
durability is separately enforced by
|
||||||
`tests/services/test_workflows_reliability.py::TestWorkflowReliability::test_transcribed_page_is_committed_before_next_provider_call_finishes`.
|
`tests/services/test_workflows_reliability.py::TestWorkflowReliability::test_transcribed_page_is_committed_before_next_provider_call_finishes`.
|
||||||
|
|
||||||
|
### Stale-reclaim safety
|
||||||
|
|
||||||
|
- `WORKER_STALE_JOB_SECONDS` must remain **greater than** `WORKER_PROVIDER_TIMEOUT_SECONDS`; stale
|
||||||
|
recovery must not be able to fire before one provider call can legitimately finish.
|
||||||
|
- Long-running multi-page orchestration must refresh job liveness explicitly between intermediate
|
||||||
|
page commits. Do not rely on incidental row updates or provider metadata writes to keep
|
||||||
|
`Job.date_updated` fresh.
|
||||||
|
- Enforced by `tests/test_config.py` and
|
||||||
|
`tests/services/test_workflows_reliability.py::TestWorkflowReliability::test_intermediate_page_commit_advances_job_liveness_timestamp`.
|
||||||
|
|
||||||
## Contract Alignment
|
## Contract Alignment
|
||||||
|
|
||||||
- Treat `docs/` as the active architecture and requirements baseline.
|
- Treat `docs/` as the active architecture and requirements baseline.
|
||||||
@@ -145,7 +196,7 @@ durability is separately enforced by
|
|||||||
- `Source.raw_transcription` is a projection, not authoritative history.
|
- `Source.raw_transcription` is a projection, not authoritative history.
|
||||||
- Service/UI read paths that touch relationships must be eager-loaded for `lazy="raise"` compatibility.
|
- Service/UI read paths that touch relationships must be eager-loaded for `lazy="raise"` compatibility.
|
||||||
- If model fields, enums, constraints, indexes, or relationship-loading semantics change, update `docs/schema.md` in the same change.
|
- If model fields, enums, constraints, indexes, or relationship-loading semantics change, update `docs/schema.md` in the same change.
|
||||||
- If `Settings` fields or defaults change in `src/transcription/config.py`, update `.env.example` in the same change so keys/defaults remain synchronized and no stale settings remain documented.
|
- If `Settings` fields or defaults change in `src/transcription/config.py`, update `.env.production.example` in the same change so keys/defaults remain synchronized and no stale settings remain documented.
|
||||||
|
|
||||||
## Schema Drift and Legacy Compatibility Policy
|
## Schema Drift and Legacy Compatibility Policy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
description: Authoring rules for the test suite, including markers, async discipline, and guard-test design.
|
||||||
|
applyTo: 'tests/**/*.py'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
|
||||||
|
Primary references:
|
||||||
|
|
||||||
|
- `AGENTS.md` (Change Protocol — failing test first)
|
||||||
|
- `docs/index.md` and `docs/invariant/*`
|
||||||
|
- `.github/skills/test-effectiveness-auditor/skill.md` (periodic audit of this suite)
|
||||||
|
|
||||||
|
The suite is not only regression protection here — it is where several architectural rules are
|
||||||
|
*defined*. `tests/test_service_boundaries.py`, `tests/test_ui_boundaries.py`,
|
||||||
|
`tests/test_provider_boundaries.py`, `tests/test_model_contract_guards.py`, and
|
||||||
|
`tests/test_meta_contract_guards.py` are the enforcement layer named in the `AGENTS.md` authority
|
||||||
|
order. A weak test in this repository does not merely fail to catch a bug; it can silently repeal a
|
||||||
|
documented invariant.
|
||||||
|
|
||||||
|
The baseline is green. `uv run pytest -q -m "not external"` must report zero failures and zero
|
||||||
|
errors, and there is no tolerated set of known-failing tests.
|
||||||
|
|
||||||
|
## Write the Failing Test First
|
||||||
|
|
||||||
|
For any behavioral fix, write the test before the fix and confirm it fails *for the reason you
|
||||||
|
expect*. A test that passes against the broken code proves nothing, and several defects in this
|
||||||
|
repository were subtle enough that a test written afterward would have done exactly that. If the
|
||||||
|
new test passes immediately, you have not reproduced the defect yet.
|
||||||
|
|
||||||
|
## Runner Configuration
|
||||||
|
|
||||||
|
Configured in `pyproject.toml`; do not work around these:
|
||||||
|
|
||||||
|
- `--strict-markers` — an unregistered marker is an error. Register new markers in
|
||||||
|
`[tool.pytest.ini_options] markers` with a description rather than inventing one at the call site.
|
||||||
|
- `asyncio_mode = "strict"` — every async test needs an explicit `@pytest.mark.asyncio`, and async
|
||||||
|
fixtures use `@pytest_asyncio.fixture`. There is no implicit promotion.
|
||||||
|
- `filterwarnings = ["error:coroutine .* was never awaited:RuntimeWarning"]` — an un-awaited
|
||||||
|
coroutine is an error, not a warning. This usually means a mock replaced an async callable with a
|
||||||
|
sync one, or an `await` was dropped. Fix the call; never silence the warning.
|
||||||
|
|
||||||
|
## Markers and Layout
|
||||||
|
|
||||||
|
- `unit` — pure logic, no framework or database.
|
||||||
|
- `integration` — touches framework, database, or multi-component contracts.
|
||||||
|
- `external` — calls live services; slow and credential-dependent.
|
||||||
|
|
||||||
|
`external` tests must also carry their own `skipif` so the suite stays green without credentials
|
||||||
|
(see `tests/services/test_transcription_external.py`). Local and documented runs use
|
||||||
|
`-m "not external"`; CI intentionally runs unfiltered, which is equivalent because those tests skip
|
||||||
|
themselves. Never let an unmarked test reach the network.
|
||||||
|
|
||||||
|
Place tests by the layer under test: `tests/services/`, `tests/ui/`, `tests/api/`,
|
||||||
|
`tests/providers/`, `tests/integration/`, with cross-cutting guards at the top level.
|
||||||
|
|
||||||
|
## Fixtures and Isolation
|
||||||
|
|
||||||
|
- Prefer the shared fixtures in `tests/conftest.py` (`default_settings`, `async_session`,
|
||||||
|
`default_session_factory`, and the per-aggregate service fixtures) over building settings or
|
||||||
|
engines by hand.
|
||||||
|
- `Settings` is isolated suite-wide by the session-scoped autouse fixture in `conftest.py`, because
|
||||||
|
`env_file` resolves against the working directory. Tests that need env-file loading pass
|
||||||
|
`_env_file=` explicitly; tests asserting declared defaults need nothing. Do not reintroduce
|
||||||
|
reliance on a developer's local env file. Guarded by `tests/test_config_isolation.py`.
|
||||||
|
- Database fixtures refuse to run against anything but the per-test path, and that refusal is
|
||||||
|
deliberate. Never relax it to point a destructive fixture at a real database.
|
||||||
|
- Tests must not leave artifacts outside `tmp_path`.
|
||||||
|
|
||||||
|
## Assertion Strength
|
||||||
|
|
||||||
|
Assert on the domain effect, not on the fact that code ran.
|
||||||
|
|
||||||
|
- Prefer persisted state, status transitions, error categories, and evidence records over
|
||||||
|
"no exception raised", "not None", or a bare status code.
|
||||||
|
- **Read committed state through a separate session.** Asserting against the same session that
|
||||||
|
performed the write can pass on unflushed in-memory state and prove nothing about durability.
|
||||||
|
This is how the atomicity guarantees in `tests/services/test_workflows_reliability.py` and
|
||||||
|
`tests/integration/test_pipeline_atomicity.py` are made real.
|
||||||
|
- Critical paths need negative-path coverage — timeouts, provider failures, validation errors,
|
||||||
|
cancellation. Happy-path-only coverage of a critical module is a gap, not a suite.
|
||||||
|
- Avoid count-threshold assertions as a proxy for correctness. A test asserting "at least N items
|
||||||
|
were discovered" passes indefinitely while the thing it was meant to protect rots; assert on a
|
||||||
|
specific known member instead.
|
||||||
|
|
||||||
|
## Guard Tests
|
||||||
|
|
||||||
|
Structural guards carry extra obligations, because they are cited as proof that a rule holds.
|
||||||
|
|
||||||
|
- **Guard the guard.** Every scanning guard needs a companion assertion that the scan actually found
|
||||||
|
something, following the existing `test_*_are_discovered` pattern. A guard that silently scans an
|
||||||
|
empty set passes forever.
|
||||||
|
- **Scope must match the claim.** A guard's name and docstring must describe only what it actually
|
||||||
|
verifies. A test covering one function while appearing to enforce a repo-wide rule is worse than
|
||||||
|
no test, because it stops anyone from writing the real one.
|
||||||
|
- **Prove non-vacuity by injected fault.** Temporarily introduce the violation, confirm the guard
|
||||||
|
fails with a comprehensible message, then revert. Do this whenever you add or materially change a
|
||||||
|
guard. Revert with an explicit edit if the file has uncommitted changes — `git checkout --` will
|
||||||
|
discard them.
|
||||||
|
- **Prefer structural analysis to substring matching.** AST inspection of imports and definitions is
|
||||||
|
resistant to false negatives; a bare-name search across the repository is not, since an unrelated
|
||||||
|
mention anywhere makes dead code look reachable.
|
||||||
|
- Failure messages should name the offending file, symbol, and the remedy. These fire for people who
|
||||||
|
did not write the guard.
|
||||||
|
- Any new file under `.github/**` must be added to `ACTIVE_CONTRACT_FILES` in
|
||||||
|
`tests/test_meta_contract_guards.py`, or the completeness guard fails by design.
|
||||||
|
|
||||||
|
## Redundancy
|
||||||
|
|
||||||
|
Duplicate coverage across layers costs runtime and dilutes signal. Pick the canonical layer for a
|
||||||
|
behavior — unit for logic, integration for wiring — and let the other layer assert only what is
|
||||||
|
unique to it. Retire tests superseded by a stronger guard instead of accumulating both, and record
|
||||||
|
deliberate retentions with a rationale rather than leaving them unexplained.
|
||||||
|
|
||||||
|
## Contract Sync Rule
|
||||||
|
|
||||||
|
When a test encodes or relaxes a documented rule, update the corresponding instruction file or
|
||||||
|
`docs/*` page in the same change. When a guard test is the enforcement for a rule stated in
|
||||||
|
`AGENTS.md` or an instruction file, cite the test by name there so the link survives refactoring.
|
||||||
@@ -9,7 +9,7 @@ agent: Python Architect Reviewer
|
|||||||
Execute a comprehensive, evidence-based code review of the target codebase.
|
Execute a comprehensive, evidence-based code review of the target codebase.
|
||||||
|
|
||||||
## Target Scope
|
## Target Scope
|
||||||
- **Review Target:** ${{input:target_path:./}}
|
- **Review Target:** the repository root, unless the invoker names a narrower path; review that path instead.
|
||||||
- **Source Root:** `src/`
|
- **Source Root:** `src/`
|
||||||
- **Docs Root:** `docs/`
|
- **Docs Root:** `docs/`
|
||||||
- **Focus Areas:** FastAPI endpoints, NiceGUI components, SQLModel persistence, asyncio workers, Pydantic V2 models, and OpenRouter provider adapters.
|
- **Focus Areas:** FastAPI endpoints, NiceGUI components, SQLModel persistence, asyncio workers, Pydantic V2 models, and OpenRouter provider adapters.
|
||||||
@@ -17,7 +17,7 @@ Execute a comprehensive, evidence-based code review of the target codebase.
|
|||||||
## Execution Rules
|
## Execution Rules
|
||||||
1. Map repository layout, dependency manifests, and configuration files from the project root before inspecting modules.
|
1. Map repository layout, dependency manifests, and configuration files from the project root before inspecting modules.
|
||||||
2. Read real code modules under `src/` (or the specified target path); cite exact file paths and line ranges for every finding.
|
2. Read real code modules under `src/` (or the specified target path); cite exact file paths and line ranges for every finding.
|
||||||
3. Validate issues using terminal tools (`ruff check`, `pytest`, `ty`) where appropriate.
|
3. Validate issues by running `uv run ruff check .`, `uv run ty check`, and `uv run pytest -q -m "not external"`. Nothing is on `PATH` in this `uv` project, so bare `ruff`/`ty`/`pytest` will fail.
|
||||||
4. Check for duplication, divergent implementations, and extractable helpers.
|
4. Check for duplication, divergent implementations, and extractable helpers.
|
||||||
5. Format the entire review following the standardized 6-section template defined in the `python-code-reviewer` skill.
|
5. Format the entire review following the standardized 10-section template defined in the `python-code-reviewer` skill.
|
||||||
6. Write the final report as a Markdown file to `./docs/code-review-${{current_date}}.md`.
|
6. Write the final report to `./docs/reviews/<YYYY-MM-DD>-code-review.md`, using today's date. This path is defined by the skill; do not write the report anywhere else.
|
||||||
@@ -31,7 +31,7 @@ upgrade policy") — do not report it as a defect or recommend widening it.
|
|||||||
|
|
||||||
## Review Workflow
|
## Review Workflow
|
||||||
|
|
||||||
1. **Map the Repository First:** Inspect entry points, package layout, configurations, dependency manifests, and any project-specific rule files (`AGENTS.md`, `CLAUDE.md`, `.github/instructions/`). Project-specific conventions override generic advice.
|
1. **Map the Repository First:** Inspect entry points, package layout, configurations, dependency manifests, and any project-specific rule files (`AGENTS.md`, `.github/instructions/`, `.github/skills/`). Project-specific conventions override generic advice.
|
||||||
2. **Establish Canonical Authority First:** Read architecture/contracts (`docs/*`, `docs/invariant/*`, UI docs) and active instructions/skills before evaluating source behavior.
|
2. **Establish Canonical Authority First:** Read architecture/contracts (`docs/*`, `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.
|
||||||
@@ -39,13 +39,30 @@ upgrade policy") — do not report it as a defect or recommend widening it.
|
|||||||
6. **Assess Boundary and Coupling Health:** Evaluate UI/service/persistence/provider dependency flow, identify circular dependencies, leaky abstractions, and transaction ownership ambiguity.
|
6. **Assess Boundary and Coupling Health:** Evaluate UI/service/persistence/provider dependency flow, identify circular dependencies, leaky abstractions, and transaction ownership ambiguity.
|
||||||
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).
|
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. **Verify Claims:** This is a `uv` project (`uv.lock`, root `ruff.toml`). Run `uv run ruff check .`, `uv run ty check`, and `uv run pytest -q -m "not external"` rather than guessing, and record the exact commands and their outcomes in the report.
|
8. **Verify Claims:** This is a `uv` project (`uv.lock`, root `ruff.toml`). Run `uv run ruff check .`, `uv run ty check`, and `uv run pytest -q -m "not external"` rather than guessing, and record the exact commands and their outcomes in the report.
|
||||||
9. **Prioritize Hot Paths:** Focus deeply on request handling, database sessions, background workers, and external API calls.
|
9. **Validate Recommendations Against Consumers:** A recommendation is a claim about the future and must be verified like any other. Before recommending a change to a shared symbol — a model field, an exception attribute, a helper's return value, a function signature — enumerate **every** consumer of that symbol (`grep` the whole repo, including tests) and confirm the fix is safe for each one. Record the consumers in the finding's **Blast Radius**. A fix that is correct for the path that produced the finding can silently break a second consumer, and evidence/provenance and logging paths are the usual casualties because they read the same fields the UI does.
|
||||||
10. **Enforce Read-Only Safety:** Do not modify code unless explicitly instructed.
|
10. **Prioritize Hot Paths:** Focus deeply on request handling, database sessions, background workers, and external API calls.
|
||||||
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.
|
11. **Enforce Read-Only Safety:** Do not modify code unless explicitly instructed.
|
||||||
12. **Escalate Test-Suite Audits:** When findings touch test coverage, redundancy, or assertion strength, apply `.github/skills/test-effectiveness-auditor/skill.md` and include its outcomes alongside the provenance results.
|
12. **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.
|
||||||
|
13. **Escalate Test-Suite Audits:** When findings touch test coverage, redundancy, or assertion strength, apply `.github/skills/test-effectiveness-auditor/skill.md` and include its outcomes alongside the provenance results.
|
||||||
|
|
||||||
|
### Worked example: why step 9 exists
|
||||||
|
|
||||||
|
The 2026-08-23 review recommended fixing a filesystem-path leak in
|
||||||
|
`classify_unexpected_error` by making `AppError.message` generic and logging the exception
|
||||||
|
detail instead. The analysis of the leak was correct, and the fix was implemented as written.
|
||||||
|
|
||||||
|
It was wrong. `AppError.message` had a second consumer the review never traced:
|
||||||
|
`format_error_detail`, which writes `ExecutionAttempt.error_detail` — a **provenance record**.
|
||||||
|
The recommended fix closed a privacy leak by silently stripping root-cause data from the
|
||||||
|
evidence history this system exists to preserve. It was caught only because an unrelated
|
||||||
|
integration test asserted on the persisted error text.
|
||||||
|
|
||||||
|
The correct fix separated the audiences — a user-safe `message` and an internal-only `detail`
|
||||||
|
that still reaches evidence and logs. One `grep` for consumers of `.message` during the review
|
||||||
|
would have found this. Treat any recommendation that changes a widely-read field as unverified
|
||||||
|
until its consumers are enumerated.
|
||||||
|
|
||||||
## Repo-Specific Deterministic Checks (Transcription)
|
## Repo-Specific Deterministic Checks (Transcription)
|
||||||
|
|
||||||
When reviewing this repository, always include explicit pass/fail checks for the following.
|
When reviewing this repository, always include explicit pass/fail checks for the following.
|
||||||
Where **Enforced by** reads *unenforced*, recommending a deterministic test is itself a finding.
|
Where **Enforced by** reads *unenforced*, recommending a deterministic test is itself a finding.
|
||||||
|
|
||||||
@@ -54,7 +71,7 @@ Where **Enforced by** reads *unenforced*, recommending a deterministic test is i
|
|||||||
| 1 | **Service boundary rule:** no service-to-service imports | `tests/test_service_boundaries.py` |
|
| 1 | **Service boundary rule:** no service-to-service imports | `tests/test_service_boundaries.py` |
|
||||||
| 2 | **UI boundary rule:** pages/components do not perform persistence access | `tests/test_ui_boundaries.py` |
|
| 2 | **UI boundary rule:** pages/components do not perform persistence access | `tests/test_ui_boundaries.py` |
|
||||||
| 3 | **Status vocabulary conformance:** `JobStatus`/`JobSourceStatus`/`JobPurpose` usage matches current enums in `src/transcription/db/models.py`; no stringly-typed status literals | `tests/test_model_contract_guards.py` |
|
| 3 | **Status vocabulary conformance:** `JobStatus`/`JobSourceStatus`/`JobPurpose` usage matches current enums in `src/transcription/db/models.py`; no stringly-typed status literals | `tests/test_model_contract_guards.py` |
|
||||||
| 4 | **Evidence ownership conformance:** append-only attempt history is preserved and projection writes are not mistaken for history mutation (`src/transcription/services/sources.py`, `src/transcription/services/evidence.py`) | `tests/test_v42_evidence.py::test_attempts_are_append_only_and_exported_with_integrity` |
|
| 4 | **Evidence ownership conformance:** append-only attempt history is preserved and projection writes are not mistaken for history mutation (`src/transcription/services/sources.py`, `src/transcription/services/evidence.py`) | `tests/test_evidence_provenance.py::test_attempts_are_append_only_and_exported_with_integrity` |
|
||||||
| 5 | **Canonical authority:** findings must resolve against `docs/*` first | `tests/test_meta_contract_guards.py::test_canonical_authority_references_are_present` |
|
| 5 | **Canonical authority:** findings must resolve against `docs/*` first | `tests/test_meta_contract_guards.py::test_canonical_authority_references_are_present` |
|
||||||
| 6 | **Schema contract fidelity:** when model/persistence behavior changes, `docs/schema.md` remains field-accurate with `src/transcription/db/models.py` | `tests/test_model_contract_guards.py` (field names, ordering, enum members, table coverage), `tests/test_meta_contract_guards.py` (presence and references) |
|
| 6 | **Schema contract fidelity:** when model/persistence behavior changes, `docs/schema.md` remains field-accurate with `src/transcription/db/models.py` | `tests/test_model_contract_guards.py` (field names, ordering, enum members, table coverage), `tests/test_meta_contract_guards.py` (presence and references) |
|
||||||
| 7 | **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths | `tests/test_media_path_safety.py`, `tests/ui/test_media_urls.py` |
|
| 7 | **Media boundary conformance:** print/export media is record-validated and UI media URL generation uses controlled resolver paths | `tests/test_media_path_safety.py`, `tests/ui/test_media_urls.py` |
|
||||||
@@ -127,6 +144,30 @@ Severity reflects concrete consequence, never style preference or effort to fix.
|
|||||||
- **Medium:** Correctness risk under load or edge conditions (N+1, missing eager load, leaked task, missing timeout); drift between docs and code with no immediate runtime impact.
|
- **Medium:** Correctness risk under load or edge conditions (N+1, missing eager load, leaked task, missing timeout); drift between docs and code with no immediate runtime impact.
|
||||||
- **Low:** Maintainability, typing completeness, duplication, naming, or dead code with no behavioral risk.
|
- **Low:** Maintainability, typing completeness, duplication, naming, or dead code with no behavioral risk.
|
||||||
|
|
||||||
|
### Reachability
|
||||||
|
|
||||||
|
Severity states how bad the consequence is; **Reachability** states whether it can happen today.
|
||||||
|
They are independent, and a finding is not complete without both. Record one of:
|
||||||
|
|
||||||
|
- **Live:** reachable in the current configuration and deployment.
|
||||||
|
- **Latent:** the defective code is present but unreachable because of a current setting, single-
|
||||||
|
instance deployment, or absent caller. **State the exact condition that unblocks it.**
|
||||||
|
- **Theoretical:** requires a combination the project has explicitly ruled out.
|
||||||
|
|
||||||
|
Latent findings carry a scheduling constraint that severity alone cannot express: a latent defect
|
||||||
|
must usually be fixed *before* the change that makes it live, not after. Say so explicitly in the
|
||||||
|
finding and reflect the ordering in the §9 action plan — for example, "fix the retry-category gate
|
||||||
|
before raising `worker_max_retries` above 0," or "handle this `IntegrityError` before deploying a
|
||||||
|
second worker replica." Do not downgrade severity merely because a finding is latent.
|
||||||
|
|
||||||
|
### Conflicting invariants
|
||||||
|
|
||||||
|
When a fix sits between two invariants that pull in opposite directions, say so in the
|
||||||
|
**Recommendation** and name both, along with the test that guards each. Flag explicitly what the
|
||||||
|
over-correction would be, because the simplest-looking fix usually satisfies one invariant by
|
||||||
|
silently destroying the other. A recommendation that resolves one side without naming the other is
|
||||||
|
incomplete and will be implemented incorrectly.
|
||||||
|
|
||||||
## Output Report Structure & Template
|
## Output Report Structure & Template
|
||||||
|
|
||||||
Generate Markdown reports at `./docs/reviews/<YYYY-MM-DD>-code-review.md` following this exact
|
Generate Markdown reports at `./docs/reviews/<YYYY-MM-DD>-code-review.md` following this exact
|
||||||
@@ -157,8 +198,13 @@ template structure. Reports are dated, non-canonical artifacts: `docs/reviews/**
|
|||||||
### Critical Severity
|
### Critical Severity
|
||||||
#### [CRIT-01] Title
|
#### [CRIT-01] Title
|
||||||
- **Location:** `path/to/file.py:lines`
|
- **Location:** `path/to/file.py:lines`
|
||||||
|
- **Reachability:** Live / Latent (state the exact condition that unblocks it) / Theoretical
|
||||||
- **Problem & Consequence:** Concrete consequence, not a style opinion.
|
- **Problem & Consequence:** Concrete consequence, not a style opinion.
|
||||||
- **Recommendation:** Fix with before/after sketch.
|
- **Blast Radius:** Every consumer of the symbols the recommendation changes, each confirmed
|
||||||
|
safe. Write `None — change is local` only after actually searching. If the fix touches a
|
||||||
|
shared field or helper, list the call sites (including tests and evidence/logging paths).
|
||||||
|
- **Recommendation:** Fix with before/after sketch. If two invariants conflict here, name both,
|
||||||
|
name the test guarding each, and state what the over-correction would be.
|
||||||
- **Effort:** S / M / L
|
- **Effort:** S / M / L
|
||||||
|
|
||||||
### High Severity
|
### High Severity
|
||||||
@@ -213,7 +259,7 @@ template structure. Reports are dated, non-canonical artifacts: `docs/reviews/**
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 8. Meta-Tooling & Instruction Update Recommendations
|
## 8. Meta-Tooling & Instruction Update Recommendations
|
||||||
- Required updates to docs/instructions/skills/tests to keep enforcement current.
|
- Required updates to docs, instructions, skills, or tests to keep enforcement current.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ Run a deterministic audit of test usefulness. Focus on whether tests catch real
|
|||||||
|
|
||||||
## Output Format
|
## Output Format
|
||||||
|
|
||||||
Produce a Markdown report in `docs/`:
|
Produce a Markdown report at `docs/reviews/<YYYY-MM-DD>-test-effectiveness.md`, using today's date.
|
||||||
|
Like code review reports, it is a dated, non-canonical artifact: `docs/reviews/**` is not part of the
|
||||||
|
canonical authority set.
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Test Effectiveness Audit Report
|
# Test Effectiveness Audit Report
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Quality Gate
|
name: Quality Gate
|
||||||
|
|
||||||
# V4.7 Phase 6 / review log [40]. Before this, ruff, ty and pytest were enforced
|
# Repository quality gate. Before this workflow existed, ruff, ty, and pytest were
|
||||||
# only by .pre-commit-config.yaml, and only for developers who had actually run
|
# enforced only by .pre-commit-config.yaml for developers who had run
|
||||||
# `pre-commit install`.
|
# `pre-commit install`.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -27,13 +27,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Write placeholder configuration
|
- name: Write placeholder configuration
|
||||||
# Settings requires openrouter_api_key and 115 tests cannot construct
|
# Settings requires openrouter_api_key and 115 tests cannot construct
|
||||||
# Settings without it. This is written to a .env file rather than exported
|
# Settings without it. This is written to .env.production rather than exported
|
||||||
# as an environment variable on purpose: the external tests guard on
|
# as an environment variable on purpose: the external tests guard on
|
||||||
# os.getenv("OPENROUTER_API_KEY"), which reads the process environment and
|
# os.getenv("OPENROUTER_API_KEY"), which reads the process environment and
|
||||||
# not the file, so writing the file reproduces the local result exactly -
|
# not the file, so writing the file reproduces the local result exactly -
|
||||||
# the 4 external tests skip instead of running against a fake key and
|
# the 4 external tests skip instead of running against a fake key and
|
||||||
# failing. Exporting it instead produces 3 failures.
|
# failing. Exporting it instead produces 3 failures.
|
||||||
run: echo "OPENROUTER_API_KEY=ci-placeholder-not-a-real-key" > .env
|
run: echo "OPENROUTER_API_KEY=ci-placeholder-not-a-real-key" > .env.production
|
||||||
|
|
||||||
- name: Lint and type check
|
- name: Lint and type check
|
||||||
# Runs the hooks defined in .pre-commit-config.yaml instead of repeating
|
# Runs the hooks defined in .pre-commit-config.yaml instead of repeating
|
||||||
@@ -42,4 +42,9 @@ jobs:
|
|||||||
run: uv run pre-commit run --all-files --show-diff-on-failure
|
run: uv run pre-commit run --all-files --show-diff-on-failure
|
||||||
|
|
||||||
- name: Tests
|
- name: Tests
|
||||||
|
# Deliberately unfiltered, unlike the "-m 'not external'" form the guidance files
|
||||||
|
# use for local runs. Tests marked "external" skip themselves when live-service
|
||||||
|
# credentials are absent, so CI gets the same effective set plus a real run of any
|
||||||
|
# external test whose credentials are configured. Not drift -- do not "fix" this to
|
||||||
|
# match the local command without also giving those tests a way to run.
|
||||||
run: uv run pytest
|
run: uv run pytest
|
||||||
|
|||||||
+14
-8
@@ -11,19 +11,25 @@ wheels/
|
|||||||
|
|
||||||
# Environment secrets
|
# Environment secrets
|
||||||
.env
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
# SQLite database
|
# SQLite database
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# Document images
|
# All data including db, backups, document images, photos, and logs:
|
||||||
uploads/*
|
|
||||||
data/*
|
data/*
|
||||||
|
data/backups/*
|
||||||
|
data/documents/*
|
||||||
|
data/logs/*
|
||||||
|
data/photos/*
|
||||||
|
data-local/*
|
||||||
|
|
||||||
# Local destructive-test backups
|
|
||||||
.test-backups/
|
|
||||||
|
|
||||||
# Temporary migration files
|
# Migration tests
|
||||||
.migration-bundle-v51
|
data-migration-test/*
|
||||||
data.pre-v50-20260823/*
|
.migration-bundle/*
|
||||||
data.pre-v51-20260823-120434/*
|
|
||||||
|
|
||||||
|
# Cloudflare tunnel local runtime files
|
||||||
|
deploy/cloudflared/config.yml
|
||||||
|
deploy/cloudflared/config.yaml
|
||||||
|
deploy/cloudflared/credentials.json
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Quality gate for V4.6 [HIGH-06]. `ruff check`, `ruff format --check`, and `ty check`
|
# Quality gate: `ruff check`, `ruff format --check`, and `ty check`
|
||||||
# are blocking once known `ty` false positives are suppressed inline with rationale.
|
# are blocking once known `ty` false positives are suppressed inline with rationale.
|
||||||
#
|
#
|
||||||
# Both tools are uv-managed dev dependencies and are not on PATH, so each entry must
|
# Both tools are uv-managed dev dependencies and are not on PATH, so each entry must
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Orientation for AI agents working in this repository. This file is a **router**, not a spec:
|
||||||
|
it points at canonical authority and flags the traps that are expensive to discover by trial.
|
||||||
|
Where this file and `docs/*` disagree, `docs/*` wins.
|
||||||
|
|
||||||
|
## What This Is
|
||||||
|
|
||||||
|
A document transcription system that preserves durable archival records (Documents, Sources,
|
||||||
|
People) and executes page transcription asynchronously through vision/LLM providers. Its
|
||||||
|
defining constraint is **evidence**: every machine attempt is recorded append-only with
|
||||||
|
request/response provenance. Features that would lose, mutate, or obscure that history are
|
||||||
|
wrong regardless of how convenient they are.
|
||||||
|
|
||||||
|
Stack: Python 3.12+ · FastAPI + NiceGUI · SQLModel/SQLAlchemy (SQLite-first, PostgreSQL-
|
||||||
|
compatible) · Pydantic V2 · asyncio worker · OpenRouter adapter.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
This is a `uv` project. **Nothing is on `PATH`** — `ruff`, `ty`, and `pytest` all require
|
||||||
|
`uv run`. Bare invocations fail with command-not-found.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff check . # lint (blocking in pre-commit)
|
||||||
|
uv run ruff format --check . # format (blocking in pre-commit)
|
||||||
|
uv run ty check # types (blocking in pre-commit)
|
||||||
|
uv run pytest -q -m "not external" # default verification run
|
||||||
|
```
|
||||||
|
|
||||||
|
`external` marks tests that hit live services; always exclude it unless explicitly asked.
|
||||||
|
All four commands are expected to pass clean — there is no tolerated baseline of failures.
|
||||||
|
If `ty` reports something, fix it or suppress it inline *with a rationale comment*; a bare
|
||||||
|
`ignore` will not survive review.
|
||||||
|
|
||||||
|
## Authority Order
|
||||||
|
|
||||||
|
Resolve every question in this order, and stop at the first that answers it:
|
||||||
|
|
||||||
|
1. **`docs/*`** — canonical. Start at [`docs/index.md`](docs/index.md), which defines the
|
||||||
|
reading order. `docs/invariant/*` holds cross-version rules that outlive any release.
|
||||||
|
2. **`.github/instructions/*.md`** — active steering, auto-attached when you edit matching
|
||||||
|
paths. Covers services, UI, providers, tests, error handling, and documentation sync.
|
||||||
|
3. **`.github/skills/*`** — periodic audit procedures (code review, provenance, test
|
||||||
|
effectiveness).
|
||||||
|
4. **`tests/`** — deterministic enforcement. A guard test is the ground truth for whatever
|
||||||
|
rule it encodes.
|
||||||
|
|
||||||
|
`.github/agents/` and `.github/prompts/` hold named workflows that are loaded only when
|
||||||
|
invoked explicitly, so they never override the order above. They are how a review or audit
|
||||||
|
is *started*, not a source of rules.
|
||||||
|
|
||||||
|
`docs/reviews/**` is **not** canonical. Those are dated, opinionated snapshots that were
|
||||||
|
accurate when written and may since have been fixed, superseded, or found wrong.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `src/transcription/ui/**`, `api/**` | Interface. No direct persistence access. |
|
||||||
|
| `src/transcription/services/**` | Domain logic and transaction ownership. |
|
||||||
|
| `src/transcription/db/**` | Models and persistence. |
|
||||||
|
| `src/transcription/providers/**` | Provider adapters; provider details stop here. |
|
||||||
|
| `src/transcription/worker.py` | Asyncio worker loop. |
|
||||||
|
| `tests/` | Includes boundary/contract guards, not just behavior tests. |
|
||||||
|
|
||||||
|
## Enforced Boundaries
|
||||||
|
|
||||||
|
These are not conventions — a test fails if you break them:
|
||||||
|
|
||||||
|
- **No service-to-service imports** (`test_service_boundaries.py`). Compose in the caller.
|
||||||
|
- **No persistence access from pages/components** (`test_ui_boundaries.py`, allowlist-based).
|
||||||
|
- **No hand-rolled error notifications in UI** — use the shared error presenter.
|
||||||
|
- **No stringly-typed status literals** — use the enums (`test_model_contract_guards.py`).
|
||||||
|
- **Attempt history is append-only** (`test_evidence_provenance.py`).
|
||||||
|
- **`docs/schema.md` stays field-accurate** with `db/models.py`.
|
||||||
|
- **Orphans are tracked, not tolerated** — `test_orphan_sweep.py` records each retained
|
||||||
|
orphan with rationale in `KNOWN_ORPHANS`.
|
||||||
|
|
||||||
|
## Traps
|
||||||
|
|
||||||
|
Non-obvious things that have already caused real bugs here:
|
||||||
|
|
||||||
|
- **`AppError.message` vs `AppError.detail`.** `message` is user/API-facing and must stay
|
||||||
|
generic — never put exception text or filesystem paths in it. `detail` is internal-only and
|
||||||
|
is what reaches logs and `ExecutionAttempt.error_detail`. Putting root-cause data in
|
||||||
|
`message` leaks; removing it from `detail` silently degrades provenance. See
|
||||||
|
`docs/error_handling.md`.
|
||||||
|
- **Two competing atomicity invariants in `services/workflows.py`.** Intermediate pages must
|
||||||
|
commit individually (durability across a long multi-page job); the *final* page must commit
|
||||||
|
atomically with the terminal job status. Collapsing the batch into one transaction satisfies
|
||||||
|
the second and destroys the first. Both are guarded — `test_workflows_reliability.py` and
|
||||||
|
`tests/integration/test_pipeline_atomicity.py`.
|
||||||
|
- **Shared symbols have more consumers than the obvious one.** Before changing a model field,
|
||||||
|
exception attribute, or helper return value, grep for every consumer including tests.
|
||||||
|
Evidence and logging paths frequently read the same fields the UI does.
|
||||||
|
- **`Tag` is owned by two services, on purpose.** Every other model has exactly one owning
|
||||||
|
service, so the ownership rule reads as absolute — it isn't. `Tag` is a single table reached
|
||||||
|
through two `RegistryService[Tag]` facades, `TagRegistry` (documents) and `PersonTagRegistry`
|
||||||
|
(people), which count usage through `DocumentTag` and `PersonTag` respectively. Changing tag
|
||||||
|
semantics through one facade silently changes the other. Consolidating them under one service
|
||||||
|
is not a cleanup; it makes the other side a cross-aggregate writer.
|
||||||
|
- **Import style:** ruff `isort` runs with `force-single-line = true`. One import per line.
|
||||||
|
- **Latent defects have ordering constraints.** Some code is unreachable only because of a
|
||||||
|
current setting or single-instance deployment. Fix it *before* the change that unblocks it,
|
||||||
|
not after.
|
||||||
|
|
||||||
|
## Change Protocol
|
||||||
|
|
||||||
|
- **Write the failing test first** for behavioral fixes, and confirm it actually fails for the
|
||||||
|
reason you think. Several bugs here were subtle enough that a test written afterward would
|
||||||
|
have passed against the broken code.
|
||||||
|
- **Update docs in the same change** when you alter a contract, behavior, or scope — see
|
||||||
|
`.github/instructions/documentation-sync.instructions.md`.
|
||||||
|
- **Do not commit unless asked.** Making a requested change is not consent to commit it.
|
||||||
|
- **Do not push or open PRs on your own initiative.**
|
||||||
|
- **Scope discipline:** fix what was asked plus what your change genuinely breaks. Pre-existing
|
||||||
|
unrelated issues are a separate conversation.
|
||||||
@@ -13,6 +13,8 @@ RUN uv sync --frozen --no-dev --no-install-project
|
|||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY prompts ./prompts
|
COPY prompts ./prompts
|
||||||
|
COPY tools ./tools
|
||||||
|
COPY deploy ./deploy
|
||||||
RUN uv sync --frozen --no-dev
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
@@ -27,12 +29,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends postgresql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 appgroup \
|
RUN groupadd --system --gid 1001 appgroup \
|
||||||
&& useradd --system --uid 1001 --gid appgroup --create-home appuser
|
&& useradd --system --uid 1001 --gid appgroup --create-home appuser
|
||||||
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
COPY --from=builder /app/src /app/src
|
COPY --from=builder /app/src /app/src
|
||||||
COPY --from=builder /app/prompts /app/prompts
|
COPY --from=builder /app/prompts /app/prompts
|
||||||
|
COPY --from=builder /app/tools /app/tools
|
||||||
|
COPY --from=builder /app/deploy /app/deploy
|
||||||
|
|
||||||
RUN mkdir -p /app/uploads /app/data \
|
RUN mkdir -p /app/uploads /app/data \
|
||||||
&& chown -R appuser:appgroup /app
|
&& chown -R appuser:appgroup /app
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ uv sync
|
|||||||
|
|
||||||
### 2) Configure environment
|
### 2) Configure environment
|
||||||
|
|
||||||
Create a `.env` file in the project root with the required OpenRouter API key:
|
Create a `.env.production` file in the project root with the required OpenRouter API key:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
OPENROUTER_API_KEY=your_openrouter_api_key
|
OPENROUTER_API_KEY=your_openrouter_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
|
Settings are read from CLI arguments first, then environment variables, then `.env.production`, then the defaults below.
|
||||||
|
|
||||||
### Configuration Source Precedence
|
### Configuration Source Precedence
|
||||||
|
|
||||||
@@ -37,13 +37,13 @@ When the same setting is provided in multiple places, the value is chosen in thi
|
|||||||
1. CLI arguments (for example `--port 8000`)
|
1. CLI arguments (for example `--port 8000`)
|
||||||
2. Settings constructor arguments (used mainly in tests)
|
2. Settings constructor arguments (used mainly in tests)
|
||||||
3. Environment variables
|
3. Environment variables
|
||||||
4. `.env` file values
|
4. `.env.production` file values
|
||||||
5. Model defaults in code
|
5. Model defaults in code
|
||||||
|
|
||||||
Practical examples:
|
Practical examples:
|
||||||
|
|
||||||
- `--port 8000` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
|
- `--port 8000` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env.production`.
|
||||||
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
|
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env.production`.
|
||||||
|
|
||||||
#### Server and runtime
|
#### Server and runtime
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ Practical examples:
|
|||||||
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
|
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
|
||||||
| `RELOAD` | `false` | Restart the development server when source files change. |
|
| `RELOAD` | `false` | Restart the development server when source files change. |
|
||||||
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
|
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
|
||||||
|
| `RUN_EMBEDDED_WORKER` | `true` | Run worker loop inside web app process. Set `false` when using a dedicated worker service. |
|
||||||
|
|
||||||
#### Provider
|
#### Provider
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ DATABASE__USER=postgres
|
|||||||
DATABASE__PASSWORD=change-me
|
DATABASE__PASSWORD=change-me
|
||||||
```
|
```
|
||||||
|
|
||||||
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in `.env`. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
|
This uses Pydantic nested settings (`env_nested_delimiter='__'`) and avoids JSON blobs in env files. A top-level `DATABASE={...}` JSON value is still supported as a fallback, and nested keys such as `DATABASE__PATH` take precedence over conflicting JSON keys.
|
||||||
|
|
||||||
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
|
`BOOTSTRAP_SCHEMA_ON_STARTUP` creates missing tables when the app starts. When unset, it is enabled in `development` and `test`, and disabled in `production`; set it explicitly to override that policy. `SQLITE_CHECK_SAME_THREAD` defaults to `false`.
|
||||||
|
|
||||||
@@ -122,6 +123,33 @@ This starts the development server with SQLite, creates missing tables, and enab
|
|||||||
|
|
||||||
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
|
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
|
||||||
|
|
||||||
|
## Production stack (Phase 1)
|
||||||
|
|
||||||
|
Use the production compose profile for split app/worker deployment with PostgreSQL and Cloudflare Tunnel:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy .env.production.example .env.production
|
||||||
|
docker compose -f docker-compose.production.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Services:
|
||||||
|
|
||||||
|
- `app`: FastAPI + NiceGUI runtime (`RUN_EMBEDDED_WORKER=false`)
|
||||||
|
- `worker`: standalone queue processor (`python -m transcription.worker_service`)
|
||||||
|
- `postgres`: primary datastore
|
||||||
|
- `cloudflared`: tunnel client using mounted ingress config + `CLOUDFLARE_TUNNEL_TOKEN`
|
||||||
|
|
||||||
|
Operational defaults in the production compose file:
|
||||||
|
|
||||||
|
- worker healthcheck is disabled (the worker process has no HTTP `/healthz` endpoint)
|
||||||
|
- cloudflared is pinned to HTTP/2 with explicit DNS resolvers (`1.1.1.1`, `1.0.0.1`) for restricted LXC/container DNS environments
|
||||||
|
|
||||||
|
Cloudflare setup files:
|
||||||
|
|
||||||
|
1. `copy deploy\cloudflared\config.yml.example deploy\cloudflared\config.yml`
|
||||||
|
2. set `CLOUDFLARE_TUNNEL_TOKEN` in `.env.production`
|
||||||
|
3. update ingress hostnames in `deploy\cloudflared\config.yml`
|
||||||
|
|
||||||
## How to navigate the GUI
|
## How to navigate the GUI
|
||||||
|
|
||||||
- **Upload page** (`/ui`)
|
- **Upload page** (`/ui`)
|
||||||
@@ -152,6 +180,10 @@ The canonical MVP prompt is:
|
|||||||
Schema upgrades use an explicit export/import rebuild flow (no runtime legacy write compatibility).
|
Schema upgrades use an explicit export/import rebuild flow (no runtime legacy write compatibility).
|
||||||
See `docs/data_migration.md` for commands and cutover steps.
|
See `docs/data_migration.md` for commands and cutover steps.
|
||||||
|
|
||||||
|
## Backup and restore workflow
|
||||||
|
|
||||||
|
Production backup/restore (PostgreSQL + uploads + deployment config) steps are documented in `docs/backup_restore.md`.
|
||||||
|
|
||||||
## Destructive test procedure (with data backup)
|
## Destructive test procedure (with data backup)
|
||||||
|
|
||||||
AI execution policy: before the first unit-test run in a test/fix cycle, create one backup of `./data`. Reuse that same backup for every subsequent test run in the cycle. After tests succeed, always pause and ask whether to restore now.
|
AI execution policy: before the first unit-test run in a test/fix cycle, create one backup of `./data`. Reuse that same backup for every subsequent test run in the cycle. After tests succeed, always pause and ask whether to restore now.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-./data/backups}"
|
||||||
|
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}"
|
||||||
|
UPLOAD_DIR="${UPLOAD_DIR:-/app/uploads}"
|
||||||
|
PROMPT_DIR="${PROMPT_DIR:-/app/prompts}"
|
||||||
|
DATABASE_DRIVER="${DATABASE__DRIVER:-postgres}"
|
||||||
|
DATABASE_HOST="${DATABASE__HOST:-postgres}"
|
||||||
|
DATABASE_PORT="${DATABASE__PORT:-5432}"
|
||||||
|
DATABASE_NAME="${DATABASE__DATABASE:-}"
|
||||||
|
DATABASE_USER="${DATABASE__USER:-}"
|
||||||
|
DATABASE_PASSWORD="${DATABASE__PASSWORD:-}"
|
||||||
|
|
||||||
|
timestamp="$(date -u +%Y%m%d-%H%M%S)"
|
||||||
|
postgres_file="postgres-${timestamp}.dump"
|
||||||
|
manifest_file="backup-${timestamp}.manifest"
|
||||||
|
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
if [ "${DATABASE_DRIVER}" != "postgres" ]; then
|
||||||
|
echo "create_postgres_backup.sh requires DATABASE__DRIVER=postgres." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${DATABASE_NAME}" ] || [ -z "${DATABASE_USER}" ] || [ -z "${DATABASE_PASSWORD}" ]; then
|
||||||
|
echo "DATABASE__DATABASE, DATABASE__USER, and DATABASE__PASSWORD must be set." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v pg_dump >/dev/null 2>&1; then
|
||||||
|
echo "pg_dump is not installed in this environment." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PGPASSWORD="${DATABASE_PASSWORD}" pg_dump \
|
||||||
|
-h "${DATABASE_HOST}" \
|
||||||
|
-p "${DATABASE_PORT}" \
|
||||||
|
-U "${DATABASE_USER}" \
|
||||||
|
-d "${DATABASE_NAME}" \
|
||||||
|
-Fc \
|
||||||
|
> "${BACKUP_DIR}/${postgres_file}"
|
||||||
|
|
||||||
|
cat > "${BACKUP_DIR}/${manifest_file}" <<EOF
|
||||||
|
created_at_utc=${timestamp}
|
||||||
|
postgres_dump=${postgres_file}
|
||||||
|
backup_dir=${BACKUP_DIR}
|
||||||
|
uploads_backup_dir=${BACKUP_DIR}/uploads
|
||||||
|
prompts_backup_dir=${BACKUP_DIR}/prompts
|
||||||
|
EOF
|
||||||
|
|
||||||
|
find "${BACKUP_DIR}" -type f \( \
|
||||||
|
-name 'postgres-*.dump' -o \
|
||||||
|
-name 'backup-*.manifest' \
|
||||||
|
\) -mtime +"${RETENTION_DAYS}" -delete
|
||||||
|
|
||||||
|
uploads_backup_dir="${BACKUP_DIR}/uploads"
|
||||||
|
mkdir -p "${uploads_backup_dir}"
|
||||||
|
if [ -d "${UPLOAD_DIR}" ]; then
|
||||||
|
cp -an "${UPLOAD_DIR}/." "${uploads_backup_dir}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
prompts_backup_dir="${BACKUP_DIR}/prompts"
|
||||||
|
mkdir -p "${prompts_backup_dir}"
|
||||||
|
if [ -d "${PROMPT_DIR}" ]; then
|
||||||
|
cp -a "${PROMPT_DIR}/." "${prompts_backup_dir}/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Created backup set:"
|
||||||
|
echo " ${BACKUP_DIR}/${postgres_file}"
|
||||||
|
echo " ${BACKUP_DIR}/${manifest_file}"
|
||||||
|
echo " ${uploads_backup_dir}/ (incremental uploads mirror)"
|
||||||
|
echo " ${prompts_backup_dir}/ (prompts mirror)"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Example only. Copy to a local script and replace placeholder values.
|
||||||
|
# Do NOT commit secrets.
|
||||||
|
|
||||||
|
SHARE="//nas-host-or-ip/share-name"
|
||||||
|
MOUNT_POINT="/mnt/nas-backups"
|
||||||
|
CREDENTIALS_FILE="/etc/samba/credentials/nas-share-credentials"
|
||||||
|
USERNAME="replace-with-nas-user"
|
||||||
|
PASSWORD="replace-with-nas-password"
|
||||||
|
|
||||||
|
if [ "${USERNAME}" = "replace-with-nas-user" ] || [ "${PASSWORD}" = "replace-with-nas-password" ]; then
|
||||||
|
echo "Edit USERNAME and PASSWORD placeholders before running this script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${MOUNT_POINT}"
|
||||||
|
mkdir -p "$(dirname "${CREDENTIALS_FILE}")"
|
||||||
|
|
||||||
|
cat > "${CREDENTIALS_FILE}" <<'EOF'
|
||||||
|
username=__USERNAME__
|
||||||
|
password=__PASSWORD__
|
||||||
|
EOF
|
||||||
|
sed -i "s|__USERNAME__|${USERNAME}|g" "${CREDENTIALS_FILE}"
|
||||||
|
sed -i "s|__PASSWORD__|${PASSWORD}|g" "${CREDENTIALS_FILE}"
|
||||||
|
chmod 600 "${CREDENTIALS_FILE}"
|
||||||
|
|
||||||
|
mount -t cifs "${SHARE}" "${MOUNT_POINT}" \
|
||||||
|
-o "credentials=${CREDENTIALS_FILE},vers=3.0,iocharset=utf8,uid=0,gid=0,file_mode=0600,dir_mode=0700"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Mounted ${SHARE} at ${MOUNT_POINT}"
|
||||||
|
echo ""
|
||||||
|
echo "To persist across reboot, add this line to /etc/fstab:"
|
||||||
|
echo "${SHARE} ${MOUNT_POINT} cifs credentials=${CREDENTIALS_FILE},vers=3.0,iocharset=utf8,uid=0,gid=0,file_mode=0600,dir_mode=0700,_netdev,nofail,x-systemd.automount 0 0"
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -lt 1 ]; then
|
||||||
|
echo "Usage: $0 <path-to-postgres-dump>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dump_file="$1"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.production.yml}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env.production}"
|
||||||
|
SYNOLOGY_BACKUP_DIR="${SYNOLOGY_BACKUP_DIR:-}"
|
||||||
|
|
||||||
|
if [ ! -f "${dump_file}" ]; then
|
||||||
|
echo "Backup file not found: ${dump_file}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_dir="$(dirname "${dump_file}")"
|
||||||
|
backup_name="$(basename "${dump_file}")"
|
||||||
|
timestamp="$(printf '%s' "${backup_name}" | sed -n 's/^postgres-\([0-9]\{8\}-[0-9]\{6\}\)\.dump$/\1/p')"
|
||||||
|
uploads_file=""
|
||||||
|
config_file=""
|
||||||
|
|
||||||
|
# If SYNOLOGY_BACKUP_DIR wasn't exported in the shell, read it from ENV_FILE.
|
||||||
|
if [ -z "${SYNOLOGY_BACKUP_DIR}" ] && [ -f "${ENV_FILE}" ]; then
|
||||||
|
SYNOLOGY_BACKUP_DIR="$(
|
||||||
|
sed -n 's/^SYNOLOGY_BACKUP_DIR=//p' "${ENV_FILE}" | tail -n 1
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${timestamp}" ]; then
|
||||||
|
# Legacy local full-archive naming.
|
||||||
|
candidate_uploads="${backup_dir}/uploads-${timestamp}.tar.gz"
|
||||||
|
candidate_config="${backup_dir}/config-${timestamp}.tar.gz"
|
||||||
|
if [ -f "${candidate_uploads}" ]; then
|
||||||
|
uploads_file="${candidate_uploads}"
|
||||||
|
fi
|
||||||
|
if [ -f "${candidate_config}" ]; then
|
||||||
|
config_file="${candidate_config}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" exec -T postgres sh -lc \
|
||||||
|
"PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -U \"\$POSTGRES_USER\" -d postgres -c \"DROP DATABASE IF EXISTS \\\"\$POSTGRES_DB\\\";\""
|
||||||
|
|
||||||
|
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" exec -T postgres sh -lc \
|
||||||
|
"PGPASSWORD=\"\$POSTGRES_PASSWORD\" psql -U \"\$POSTGRES_USER\" -d postgres -c \"CREATE DATABASE \\\"\$POSTGRES_DB\\\";\""
|
||||||
|
|
||||||
|
cat "${dump_file}" | docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" exec -T postgres sh -lc \
|
||||||
|
"PGPASSWORD=\"\$POSTGRES_PASSWORD\" pg_restore -U \"\$POSTGRES_USER\" -d \"\$POSTGRES_DB\" --clean --if-exists --no-owner --no-privileges"
|
||||||
|
|
||||||
|
if [ -n "${SYNOLOGY_BACKUP_DIR}" ] && [ -d "${SYNOLOGY_BACKUP_DIR}/uploads" ]; then
|
||||||
|
docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" run --rm --no-deps \
|
||||||
|
-v "${SYNOLOGY_BACKUP_DIR}:/backup" \
|
||||||
|
--entrypoint sh app -lc \
|
||||||
|
"mkdir -p /app/uploads/documents /app/uploads/photos && \
|
||||||
|
find /app/uploads/documents -mindepth 1 -delete && \
|
||||||
|
find /app/uploads/photos -mindepth 1 -delete && \
|
||||||
|
if [ -d /backup/uploads/documents ]; then cp -a /backup/uploads/documents/. /app/uploads/documents/; fi && \
|
||||||
|
if [ -d /backup/uploads/photos ]; then cp -a /backup/uploads/photos/. /app/uploads/photos/; fi && \
|
||||||
|
if [ -f /backup/uploads/homepage.md ]; then cp /backup/uploads/homepage.md /app/uploads/homepage.md; else rm -f /app/uploads/homepage.md; fi"
|
||||||
|
elif [ -n "${uploads_file}" ]; then
|
||||||
|
# Legacy local full-archive restore.
|
||||||
|
cat "${uploads_file}" | docker compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" run --rm --no-deps --entrypoint sh app -lc \
|
||||||
|
"mkdir -p /app/uploads && find /app/uploads -mindepth 1 -delete && tar -xzf - -C /app/uploads"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${SYNOLOGY_BACKUP_DIR}" ] && [ -n "${timestamp}" ] && [ -f "${SYNOLOGY_BACKUP_DIR}/config-${timestamp}.tar.gz" ]; then
|
||||||
|
tar -xzf "${SYNOLOGY_BACKUP_DIR}/config-${timestamp}.tar.gz" -C .
|
||||||
|
elif [ -n "${config_file}" ]; then
|
||||||
|
# Legacy local full-archive restore.
|
||||||
|
tar -xzf "${config_file}" -C .
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Restore complete from: ${dump_file}"
|
||||||
|
if [ -n "${SYNOLOGY_BACKUP_DIR}" ] && [ -d "${SYNOLOGY_BACKUP_DIR}/uploads" ]; then
|
||||||
|
echo "Restored uploads mirror from: ${SYNOLOGY_BACKUP_DIR}/uploads"
|
||||||
|
elif [ -n "${uploads_file}" ]; then
|
||||||
|
echo "Restored uploads archive: ${uploads_file}"
|
||||||
|
fi
|
||||||
|
if [ -n "${SYNOLOGY_BACKUP_DIR}" ] && [ -n "${timestamp}" ] && [ -f "${SYNOLOGY_BACKUP_DIR}/config-${timestamp}.tar.gz" ]; then
|
||||||
|
echo "Restored config archive: ${SYNOLOGY_BACKUP_DIR}/config-${timestamp}.tar.gz"
|
||||||
|
elif [ -n "${config_file}" ]; then
|
||||||
|
echo "Restored config archive: ${config_file}"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ingress:
|
||||||
|
# Primary transcription app endpoint.
|
||||||
|
- hostname: transcription.example.com
|
||||||
|
service: http://app:8000
|
||||||
|
|
||||||
|
# Optional: generic remote access endpoints for other internal services.
|
||||||
|
# Replace hostnames and targets for your LAN.
|
||||||
|
- hostname: homeassistant.example.com
|
||||||
|
service: http://192.168.1.50:8123
|
||||||
|
- hostname: pihole.example.com
|
||||||
|
service: http://192.168.1.60:80
|
||||||
|
|
||||||
|
# Required catch-all.
|
||||||
|
- service: http_status:404
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: transcription:prod
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
environment:
|
||||||
|
RUN_EMBEDDED_WORKER: "false"
|
||||||
|
RUNTIME_SETTINGS_ENV_FILE: "/app/.env.production"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- app_uploads:/app/uploads
|
||||||
|
- app_data:/app/data
|
||||||
|
- ./backup:/backup
|
||||||
|
- ./.env.production:/app/.env.production
|
||||||
|
- ./prompts:/app/prompts:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: transcription:prod
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
command: ["python", "-m", "transcription.worker_service"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- app_uploads:/app/uploads
|
||||||
|
- app_data:/app/data
|
||||||
|
- ./backup:/backup
|
||||||
|
- ./.env.production:/app/.env.production:ro
|
||||||
|
- ./prompts:/app/prompts:ro
|
||||||
|
healthcheck:
|
||||||
|
disable: true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DATABASE__DATABASE}
|
||||||
|
POSTGRES_USER: ${DATABASE__USER}
|
||||||
|
POSTGRES_PASSWORD: ${DATABASE__PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
app_uploads:
|
||||||
|
app_data:
|
||||||
+1
-1
@@ -5,7 +5,7 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: transcription-app
|
container_name: transcription-app
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env.production
|
||||||
environment:
|
environment:
|
||||||
# Database configuration uses nested settings names (env_nested_delimiter="__").
|
# Database configuration uses nested settings names (env_nested_delimiter="__").
|
||||||
# DATABASE_URL is NOT read by the application and must not be used here.
|
# DATABASE_URL is NOT read by the application and must not be used here.
|
||||||
|
|||||||
+66
-5
@@ -1,6 +1,6 @@
|
|||||||
# System Architecture (Current Baseline: V5.1)
|
# System Architecture (Current Baseline: V6.1)
|
||||||
|
|
||||||
This document defines the current V5.1 architecture baseline.
|
This document defines the current V6.1 architecture baseline.
|
||||||
|
|
||||||
## Architecture Objectives
|
## Architecture Objectives
|
||||||
|
|
||||||
@@ -13,10 +13,11 @@ This document defines the current V5.1 architecture baseline.
|
|||||||
|
|
||||||
- **Runtime:** Python 3.12+
|
- **Runtime:** Python 3.12+
|
||||||
- **Web application:** FastAPI + NiceGUI
|
- **Web application:** FastAPI + NiceGUI
|
||||||
- **Persistence:** SQLModel / SQLAlchemy (SQLite-first, PostgreSQL-compatible model design)
|
- **Persistence:** SQLModel / SQLAlchemy — PostgreSQL in production, SQLite for local development and tests
|
||||||
- **Validation and settings:** Pydantic V2 + pydantic-settings
|
- **Validation and settings:** Pydantic V2 + pydantic-settings
|
||||||
- **Concurrency:** asyncio worker loop
|
- **Concurrency:** asyncio worker loop
|
||||||
- **Provider integration:** OpenRouter adapter behind provider interface
|
- **Provider integration:** OpenRouter adapter behind provider interface
|
||||||
|
- **Deployment:** Docker Compose (app, worker, PostgreSQL, Cloudflare Tunnel)
|
||||||
- **Quality and tests:** Ruff, ty, pytest, pytest-asyncio
|
- **Quality and tests:** Ruff, ty, pytest, pytest-asyncio
|
||||||
|
|
||||||
## Runtime Topology
|
## Runtime Topology
|
||||||
@@ -25,11 +26,32 @@ This document defines the current V5.1 architecture baseline.
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
U[Browser User] --> A[FastAPI + NiceGUI App]
|
U[Browser User] --> A[FastAPI + NiceGUI App]
|
||||||
A --> W[Asyncio Worker]
|
A --> W[Asyncio Worker]
|
||||||
A --> DB[(SQLite/PostgreSQL Model)]
|
A --> DB[(PostgreSQL / SQLite)]
|
||||||
W --> P[Provider Adapter]
|
W --> P[Provider Adapter]
|
||||||
W --> DB
|
W --> DB
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The worker loop drains two queues in the same pass: queued transcription Jobs and queued
|
||||||
|
`MaintenanceRun` records. When neither has work, it idles.
|
||||||
|
|
||||||
|
### Production deployment
|
||||||
|
|
||||||
|
Production runs as a Docker Compose stack with the app and worker as separate services, so the app
|
||||||
|
process runs with `RUN_EMBEDDED_WORKER=false` and the worker process owns queue draining. Local
|
||||||
|
development runs a single process with the worker embedded.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
I[Internet] --> CF[cloudflared tunnel + Access]
|
||||||
|
CF --> APP[app service]
|
||||||
|
APP --> PG[(postgres service)]
|
||||||
|
WK[worker service] --> PG
|
||||||
|
WK --> PROV[OpenRouter]
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment, rollback, and recovery procedures are in [Production Runbook](production-runbook.md);
|
||||||
|
backup configuration and restore are in [Backup and Restore](backup_restore.md).
|
||||||
|
|
||||||
## Layered Boundaries
|
## Layered Boundaries
|
||||||
|
|
||||||
### Interface Layer
|
### Interface Layer
|
||||||
@@ -45,11 +67,18 @@ Responsibilities:
|
|||||||
|
|
||||||
### Service and Orchestration Layer
|
### Service and Orchestration Layer
|
||||||
|
|
||||||
|
Aggregate services:
|
||||||
|
|
||||||
- `src/transcription/services/documents.py`
|
- `src/transcription/services/documents.py`
|
||||||
- `src/transcription/services/people.py`
|
- `src/transcription/services/people.py`
|
||||||
- `src/transcription/services/jobs.py`
|
- `src/transcription/services/jobs.py`
|
||||||
- `src/transcription/services/sources.py`
|
- `src/transcription/services/sources.py`
|
||||||
- `src/transcription/services/evidence.py`
|
- `src/transcription/services/photos.py`
|
||||||
|
- `src/transcription/services/maintenance.py`
|
||||||
|
- `src/transcription/services/evidence.py` (read/projection only)
|
||||||
|
|
||||||
|
Orchestration modules:
|
||||||
|
|
||||||
- `src/transcription/services/store.py`
|
- `src/transcription/services/store.py`
|
||||||
- `src/transcription/services/workflows.py`
|
- `src/transcription/services/workflows.py`
|
||||||
|
|
||||||
@@ -58,6 +87,11 @@ Responsibilities:
|
|||||||
- Aggregate ownership and invariants.
|
- Aggregate ownership and invariants.
|
||||||
- Transaction-aware write helpers.
|
- Transaction-aware write helpers.
|
||||||
- Cross-service workflows in orchestration modules (`store.py`, `workflows.py`).
|
- Cross-service workflows in orchestration modules (`store.py`, `workflows.py`).
|
||||||
|
- Lookup-table CRUD through the generic `RegistryService` base (`registry.py`), which is not an
|
||||||
|
aggregate owner itself.
|
||||||
|
|
||||||
|
Module classification and per-model ownership are defined in
|
||||||
|
[services instructions](../.github/instructions/services.instructions.md).
|
||||||
|
|
||||||
### Persistence Layer
|
### Persistence Layer
|
||||||
|
|
||||||
@@ -85,7 +119,13 @@ Responsibilities:
|
|||||||
- `Job` is an aggregate processing run with status and frozen prompt/runtime settings.
|
- `Job` is an aggregate processing run with status and frozen prompt/runtime settings.
|
||||||
- `JobSource` is queue/membership state for one `(job, source)` pair.
|
- `JobSource` is queue/membership state for one `(job, source)` pair.
|
||||||
- `ExecutionAttempt` is append-only evidence for each provider call.
|
- `ExecutionAttempt` is append-only evidence for each provider call.
|
||||||
|
- `Photo` is person imagery owned by `PhotosService`.
|
||||||
|
- `MaintenanceRun` is one queued or executed operational maintenance run.
|
||||||
|
- `GenealogyPerson`, `GenealogyFamily`, `GenealogyFamilyChild`, and `GenealogyCitation` store
|
||||||
|
imported GEDCOM genealogy data and citation provenance.
|
||||||
- `DocumentType` and `PersonRole` are UUID-backed registries with optional protected `semantic_key`.
|
- `DocumentType` and `PersonRole` are UUID-backed registries with optional protected `semantic_key`.
|
||||||
|
- `Tag` is a shared registry reached through both document and person tagging, linked by
|
||||||
|
`DocumentTag` and `PersonTag`.
|
||||||
|
|
||||||
## Processing and Evidence Workflow
|
## Processing and Evidence Workflow
|
||||||
|
|
||||||
@@ -106,6 +146,25 @@ Responsibilities:
|
|||||||
- **Job statuses:** `queued`, `processing`, `transcribed`, `partial_success`, `failed`
|
- **Job statuses:** `queued`, `processing`, `transcribed`, `partial_success`, `failed`
|
||||||
- Operational success path resolves to `transcribed`.
|
- Operational success path resolves to `transcribed`.
|
||||||
- **JobSource statuses:** `pending`, `transcribed`, `failed`, `cancelled`
|
- **JobSource statuses:** `pending`, `transcribed`, `failed`, `cancelled`
|
||||||
|
- **MaintenanceRun statuses:** `queued`, `processing`, `succeeded`, `failed`
|
||||||
|
- Maintenance uses `succeeded` rather than `transcribed`; the transcription vocabulary does not
|
||||||
|
apply to operational runs.
|
||||||
|
- **Maintenance job types:** `backup`, `storage_reconciliation`, `gedcom_import`
|
||||||
|
|
||||||
|
## Maintenance Execution
|
||||||
|
|
||||||
|
Operational maintenance is queue-backed rather than run inline from the UI, so it survives request
|
||||||
|
lifetime and is recorded:
|
||||||
|
|
||||||
|
1. Settings enqueues a `MaintenanceRun` with `status=queued` and a `triggered_by` marker.
|
||||||
|
2. The worker claims the oldest queued run with a conditional update, moving it to `processing`.
|
||||||
|
3. `backup` runs the deploy backup script; `storage_reconciliation` compares stored media against
|
||||||
|
`Document`/`Source` records; `gedcom_import` parses the latest uploaded `.ged` file and upserts
|
||||||
|
genealogy records.
|
||||||
|
4. The run finalizes to `succeeded` or `failed` with summary, timing, log path, and `error_detail`.
|
||||||
|
|
||||||
|
`MaintenanceRun` records operational history and is not evidence in the `ExecutionAttempt` sense;
|
||||||
|
append-only guarantees apply to transcription attempts.
|
||||||
|
|
||||||
## Security and Path Handling Boundaries
|
## Security and Path Handling Boundaries
|
||||||
|
|
||||||
@@ -165,5 +224,7 @@ Current architecture rules live in `docs/*`.
|
|||||||
- [System Requirements](requirements.md)
|
- [System Requirements](requirements.md)
|
||||||
- [Data Model](schema.md)
|
- [Data Model](schema.md)
|
||||||
- [Error Handling Policy](error_handling.md)
|
- [Error Handling Policy](error_handling.md)
|
||||||
|
- [Production Runbook](production-runbook.md)
|
||||||
|
- [Backup and Restore](backup_restore.md)
|
||||||
- [Error Handling invariant](./invariant/error_handling.md)
|
- [Error Handling invariant](./invariant/error_handling.md)
|
||||||
- [AI evidence invariant](./invariant/ai_evidence_and_provenance.md)
|
- [AI evidence invariant](./invariant/ai_evidence_and_provenance.md)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Backup and Restore (V6.1)
|
||||||
|
|
||||||
|
This guide defines operational backup/restore for clean-slate recovery of the Docker runtime using a host-visible backup folder.
|
||||||
|
|
||||||
|
## 1. Backup artifacts
|
||||||
|
|
||||||
|
- Backup target root: `BACKUP_DIR` (recommended production value: `/backup`)
|
||||||
|
- Database artifact per run:
|
||||||
|
- `postgres-YYYYMMDD-HHMMSS.dump` (PostgreSQL custom dump via `pg_dump -Fc`)
|
||||||
|
- `backup-YYYYMMDD-HHMMSS.manifest` (run manifest)
|
||||||
|
- Media/config mirrors under `BACKUP_DIR`:
|
||||||
|
- `uploads/**` (incremental copy: new files only)
|
||||||
|
- `prompts/**` (prompt directory mirror)
|
||||||
|
|
||||||
|
Retention:
|
||||||
|
|
||||||
|
- `BACKUP_RETENTION_DAYS` applies to `postgres-*.dump` and `backup-*.manifest` files.
|
||||||
|
|
||||||
|
## 2. Creating backups
|
||||||
|
|
||||||
|
Run from repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh deploy/backup/create_postgres_backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables used by the backup script:
|
||||||
|
|
||||||
|
- `BACKUP_DIR` (default `./data/backups`)
|
||||||
|
- `BACKUP_RETENTION_DAYS` (default `14`)
|
||||||
|
- `UPLOAD_DIR` (default `/app/uploads`)
|
||||||
|
- `PROMPT_DIR` (default `/app/prompts`)
|
||||||
|
- `DATABASE__DRIVER` (must be `postgres`)
|
||||||
|
- `DATABASE__HOST` (default `postgres`)
|
||||||
|
- `DATABASE__PORT` (default `5432`)
|
||||||
|
- `DATABASE__DATABASE` (required)
|
||||||
|
- `DATABASE__USER` (required)
|
||||||
|
- `DATABASE__PASSWORD` (required)
|
||||||
|
|
||||||
|
Recommended production setup:
|
||||||
|
|
||||||
|
- Mount a host-visible folder into `/backup` for both `app` and `worker`.
|
||||||
|
- Set `BACKUP_DIR=/backup` in `.env.production`.
|
||||||
|
- Use host-level tooling (for example Synology Drive Client on the host) to replicate that folder externally.
|
||||||
|
|
||||||
|
## 3. Restoring from backup
|
||||||
|
|
||||||
|
Restore requires downtime for app + worker writes.
|
||||||
|
|
||||||
|
1. Stop app and worker:
|
||||||
|
- `docker compose --env-file .env.production -f docker-compose.production.yml stop app worker`
|
||||||
|
2. Restore database:
|
||||||
|
- `sh deploy/backup/restore_postgres_backup.sh /backup/postgres-YYYYMMDD-HHMMSS.dump`
|
||||||
|
3. Start app and worker:
|
||||||
|
- `docker compose --env-file .env.production -f docker-compose.production.yml start app worker`
|
||||||
|
4. Validate `/healthz` and run one smoke workflow.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `restore_postgres_backup.sh` still supports legacy archive restore paths for older backup sets.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Cloudflare Tunnel and Access Setup
|
||||||
|
|
||||||
|
This guide defines the repository-supported setup for exposing app and selected LAN services through Cloudflare Tunnel with Cloudflare Access protection.
|
||||||
|
|
||||||
|
## 1. Files used by this deployment
|
||||||
|
|
||||||
|
1. `deploy/cloudflared/config.yml` (local copy from `config.yml.example`)
|
||||||
|
2. `.env.production` (`CLOUDFLARE_TUNNEL_TOKEN`)
|
||||||
|
3. `docker-compose.production.yml` (`cloudflared` service reads token + mounts config)
|
||||||
|
|
||||||
|
Do not commit `config.yml` or `.env.production`.
|
||||||
|
|
||||||
|
## 2. Configure cloudflared
|
||||||
|
|
||||||
|
1. Copy `deploy/cloudflared/config.yml.example` to `deploy/cloudflared/config.yml`.
|
||||||
|
2. Update hostname -> service mappings in `ingress`.
|
||||||
|
3. Keep the final catch-all ingress `http_status:404`.
|
||||||
|
4. Set `CLOUDFLARE_TUNNEL_TOKEN` in `.env.production`.
|
||||||
|
|
||||||
|
Example app route:
|
||||||
|
|
||||||
|
- `transcription.example.com` -> `http://app:8000`
|
||||||
|
|
||||||
|
Optional generic remote-access routes:
|
||||||
|
|
||||||
|
- `homeassistant.example.com` -> `http://<home-assistant-lan-ip>:8123`
|
||||||
|
- `pihole.example.com` -> `http://<pihole-lan-ip>:80`
|
||||||
|
|
||||||
|
## 3. Cloudflare Access policy baseline
|
||||||
|
|
||||||
|
Create one Access app policy per exposed hostname:
|
||||||
|
|
||||||
|
1. Include: your allowed identities/groups only.
|
||||||
|
2. Exclude: none by default.
|
||||||
|
3. Require: identity provider login (and MFA if available).
|
||||||
|
|
||||||
|
Recommended baseline:
|
||||||
|
|
||||||
|
- App endpoint (`transcription.*`): your admin identity set.
|
||||||
|
- Other internal endpoints (`homeassistant.*`, `pihole.*`, etc.): explicit least-privilege groups.
|
||||||
|
|
||||||
|
## 4. Startup
|
||||||
|
|
||||||
|
Start production stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f docker-compose.production.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate tunnel container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.production -f docker-compose.production.yml logs cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
LXC/proxied-network note:
|
||||||
|
|
||||||
|
- The `cloudflared` service is pinned to `--protocol http2` with explicit DNS resolvers (`1.1.1.1`, `1.0.0.1`) in `docker-compose.production.yml`.
|
||||||
|
- This avoids environments where Docker's embedded resolver (`127.0.0.11`) cannot resolve `region*.v2.argotunnel.com`, which causes connector precheck failure and tunnel shutdown.
|
||||||
|
- If tunnel status is still down, verify host/container egress for DNS and TCP 443 to `api.cloudflare.com` and `*.argotunnel.com`.
|
||||||
|
|
||||||
|
## 5. Security notes
|
||||||
|
|
||||||
|
- Keep `postgres` and other internal-only services off public hostnames unless required.
|
||||||
|
- Use distinct hostnames per service; avoid path-based multiplexing for unrelated admin surfaces.
|
||||||
|
- Rotate `CLOUDFLARE_TUNNEL_TOKEN` and Access policy memberships on a regular schedule.
|
||||||
+42
-6
@@ -19,13 +19,36 @@ Optional source overrides:
|
|||||||
- `--source-db <path-or-sqlalchemy-url>`
|
- `--source-db <path-or-sqlalchemy-url>`
|
||||||
- `--source-upload-dir <path>`
|
- `--source-upload-dir <path>`
|
||||||
|
|
||||||
### 2) Import bundle into a fresh DB + uploads root
|
### 2) Import bundle into a fresh target (SQLite or PostgreSQL)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
|
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3) One-shot export+import
|
PostgreSQL target example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/export_import_migration.py import --bundle-dir .migration-bundle --target-db postgresql://transcription:change-me@localhost:5432/transcription --target-upload-dir .\data-new
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Verify migration parity and integrity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python tools/export_import_migration.py verify --source-db .\data\transcription.db --target-db postgresql://transcription:change-me@localhost:5432/transcription
|
||||||
|
```
|
||||||
|
|
||||||
|
The verify command checks:
|
||||||
|
|
||||||
|
- row-count parity across migration tables
|
||||||
|
- orphan-reference checks for `source`, `job`, `job_source`, and `execution_attempt`
|
||||||
|
- duplicate `(job_id, source_id, attempt_number)` in `execution_attempt`
|
||||||
|
|
||||||
|
Exit code:
|
||||||
|
|
||||||
|
- `0` when counts and integrity checks pass
|
||||||
|
- `1` when mismatches or integrity violations are detected
|
||||||
|
|
||||||
|
### 4) One-shot export+import
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run python tools/export_import_migration.py migrate --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
|
uv run python tools/export_import_migration.py migrate --bundle-dir .migration-bundle --target-db .\data\transcription-new.db --target-upload-dir .\data-new
|
||||||
@@ -50,9 +73,22 @@ Legacy V4.x portrait/homepage backfill in the export step:
|
|||||||
- Legacy homepage markdown is relocated from `UPLOAD_DIR/homepage/homepage.md` to `UPLOAD_DIR/homepage.md`.
|
- Legacy homepage markdown is relocated from `UPLOAD_DIR/homepage/homepage.md` to `UPLOAD_DIR/homepage.md`.
|
||||||
- Legacy `person.full_name` values are split into `given_names` + `last_name` for V5.1 schema compatibility.
|
- Legacy `person.full_name` values are split into `given_names` + `last_name` for V5.1 schema compatibility.
|
||||||
|
|
||||||
## Cutover
|
## Cutover (SQLite -> PostgreSQL)
|
||||||
|
|
||||||
After importing to a fresh target:
|
After importing to a fresh target:
|
||||||
1. Stop the app.
|
1. Stop app and worker services to freeze writes.
|
||||||
2. Point `DATABASE__*` and `UPLOAD_DIR` to the new targets.
|
2. Export a migration bundle from the last SQLite state.
|
||||||
3. Start the app and run smoke checks (`/healthz`, create/upload/process one job).
|
3. Import bundle to PostgreSQL target.
|
||||||
|
4. Run `verify` against source and target before switching runtime.
|
||||||
|
5. Switch runtime config to PostgreSQL (`DATABASE__DRIVER=postgres` and related `DATABASE__*` values).
|
||||||
|
6. Start app and worker services.
|
||||||
|
7. Run smoke checks (`/healthz`, create/upload/process one job).
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If verify or smoke checks fail:
|
||||||
|
|
||||||
|
1. Stop app and worker services.
|
||||||
|
2. Revert runtime config to SQLite.
|
||||||
|
3. Start app and worker against pre-cutover SQLite database.
|
||||||
|
4. Preserve failed migration bundle and logs for analysis.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Error Handling Policy (Current Baseline: V5.1)
|
# Error Handling Policy (Current Baseline: V6.1)
|
||||||
|
|
||||||
This policy defines the active V5.1 error taxonomy, translation boundaries, and retry semantics.
|
This policy defines the active V6.1 error taxonomy, translation boundaries, and retry semantics.
|
||||||
|
|
||||||
## Error Categories
|
## Error Categories
|
||||||
|
|
||||||
@@ -107,12 +107,16 @@ user-facing envelopes must not carry it. `AppError` therefore separates the two
|
|||||||
| Field | Audience | Carries root cause | Surfaces |
|
| Field | Audience | Carries root cause | Surfaces |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `message` | User-facing and API-facing | No | `show_error`, `build_error_envelope` |
|
| `message` | User-facing and API-facing | No | `show_error`, `build_error_envelope` |
|
||||||
| `detail` | Internal only | Yes | `format_error_detail` (evidence), logs |
|
| `detail` | Internal only | Yes | `format_error_detail` (evidence), logs, sanitized UI projection only |
|
||||||
|
|
||||||
`classify_unexpected_error` builds a generic `message` and puts the exception type and
|
`classify_unexpected_error` builds a generic `message` and puts the exception type and
|
||||||
text on `detail`. Anything rendered to a user or serialized into an API envelope must
|
text on `detail`. Anything rendered to a user or serialized into an API envelope must
|
||||||
read `message`; anything persisted as provenance or logged may read `detail`.
|
read `message`; anything persisted as provenance or logged may read `detail`. When a UI
|
||||||
Enforced by `tests/test_errors.py::test_unexpected_error_does_not_leak_filesystem_paths`.
|
surface needs to show persisted `error_detail`, it must route through a sanitizing
|
||||||
|
projection that preserves the category, suggestion, and error reference while reducing
|
||||||
|
machine-local absolute paths to basenames only.
|
||||||
|
Enforced by `tests/test_errors.py::test_unexpected_error_does_not_leak_filesystem_paths`
|
||||||
|
and `tests/test_error_message_safety.py`.
|
||||||
|
|
||||||
## Operator Recovery Guidance
|
## Operator Recovery Guidance
|
||||||
|
|
||||||
|
|||||||
+16
-3
@@ -1,6 +1,6 @@
|
|||||||
# Document Transcription System Overview (Current Baseline: V5.1)
|
# Document Transcription System Overview (Current Baseline: V6.1)
|
||||||
|
|
||||||
This directory is the single source of truth for current V5.1 behavior and architecture.
|
This directory is the single source of truth for current V6.1 behavior and architecture.
|
||||||
|
|
||||||
## Canonical Reading Order
|
## Canonical Reading Order
|
||||||
|
|
||||||
@@ -17,7 +17,20 @@ This directory is the single source of truth for current V5.1 behavior and archi
|
|||||||
- [Digital Evidence and AI Processing Provenance](./invariant/ai_evidence_and_provenance.md)
|
- [Digital Evidence and AI Processing Provenance](./invariant/ai_evidence_and_provenance.md)
|
||||||
- [UI Style Guide](./invariant/ui_style_guide.md)
|
- [UI Style Guide](./invariant/ui_style_guide.md)
|
||||||
|
|
||||||
|
## Deployment and Operations
|
||||||
|
|
||||||
|
- [Production Runbook](production-runbook.md) for deploy, rollback, and recovery.
|
||||||
|
- [Backup and Restore](backup_restore.md) for backup configuration and restore procedure.
|
||||||
|
- [Data Migration](data_migration.md) for the SQLite to PostgreSQL migration path.
|
||||||
|
- [Cloudflare Tunnel and Access](cloudflare_tunnel_access.md) for remote exposure and access control.
|
||||||
|
|
||||||
## Baseline Statement
|
## Baseline Statement
|
||||||
|
|
||||||
The current V5.1 baseline includes behavior delivered through the architectural cleanup phases and person-schema redesign.
|
The current V6.1 baseline includes the architectural cleanup, person-schema redesign,
|
||||||
|
containerized PostgreSQL deployment, and the navigation, Document Detail, and worker-backed
|
||||||
|
maintenance refinements reflected across this canonical document set.
|
||||||
Use this `docs/*` canonical set for active design and implementation decisions.
|
Use this `docs/*` canonical set for active design and implementation decisions.
|
||||||
|
|
||||||
|
Every canonical document above states this same baseline; `tests/test_meta_contract_guards.py`
|
||||||
|
fails if one of them falls behind. Forward-looking work is tracked in
|
||||||
|
[`roadmap_plan.md`](roadmap_plan.md) and is not part of the baseline.
|
||||||
|
|||||||
@@ -123,11 +123,15 @@ Evaluation should:
|
|||||||
5. Preserve the exact model, endpoint or route, parameters, prompt, source digest, and scoring method for every comparison.
|
5. Preserve the exact model, endpoint or route, parameters, prompt, source digest, and scoring method for every comparison.
|
||||||
6. Treat model rankings as corpus- and version-specific, not permanent declarations of a universal “best” model.
|
6. Treat model rankings as corpus- and version-specific, not permanent declarations of a universal “best” model.
|
||||||
|
|
||||||
|
The deterministic scorer for these comparisons lives in `src/transcription/benchmarking.py`; it is
|
||||||
|
retained as evaluation-policy infrastructure even though application runtime paths do not call it
|
||||||
|
directly.
|
||||||
|
|
||||||
Benchmark material containing family records remains private application data unless explicitly approved for publication.
|
Benchmark material containing family records remains private application data unless explicitly approved for publication.
|
||||||
|
|
||||||
## 6. Ownership and Change Policy
|
## 6. Ownership and Change Policy
|
||||||
|
|
||||||
1. Canonical V4 architecture, schema, requirements, and error-policy documents define how current behavior satisfies this invariant.
|
1. Canonical V6.1 architecture, schema, requirements, and error-policy documents define how current behavior satisfies this invariant.
|
||||||
2. Provider adapters own the capture of provider-boundary evidence.
|
2. Provider adapters own the capture of provider-boundary evidence.
|
||||||
3. Services own validation, persistence, retention, and export behavior.
|
3. Services own validation, persistence, retention, and export behavior.
|
||||||
4. UI pages may inspect evidence through service contracts but do not define evidence semantics.
|
4. UI pages may inspect evidence through service contracts but do not define evidence semantics.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ This runbook is the operational checklist for releasing and monitoring the trans
|
|||||||
- `OPENROUTER_API_KEY`
|
- `OPENROUTER_API_KEY`
|
||||||
- `DATABASE__*`
|
- `DATABASE__*`
|
||||||
- filesystem paths for data/logs/backups.
|
- filesystem paths for data/logs/backups.
|
||||||
|
- `CLOUDFLARE_TUNNEL_TOKEN`
|
||||||
5. Confirm schema contract alignment is current:
|
5. Confirm schema contract alignment is current:
|
||||||
- `src/transcription/db/models.py`
|
- `src/transcription/db/models.py`
|
||||||
- `docs/schema.md`
|
- `docs/schema.md`
|
||||||
@@ -19,9 +20,17 @@ This runbook is the operational checklist for releasing and monitoring the trans
|
|||||||
## 2. Release execution steps
|
## 2. Release execution steps
|
||||||
|
|
||||||
1. Deploy artifact/config to target environment.
|
1. Deploy artifact/config to target environment.
|
||||||
|
- Production stack: `docker compose -f docker-compose.production.yml up -d --build`
|
||||||
|
- `Settings` loads from explicit `_env_file`, then `ENV_FILE`, then the repository-root `.env.production`; it does not resolve relative to the process working directory.
|
||||||
|
- For Runtime Settings writes in production, mount `.env.production` into the app container and set `RUNTIME_SETTINGS_ENV_FILE=/app/.env.production`.
|
||||||
|
- If deployment uses a non-default env-file location, set both `ENV_FILE` and `RUNTIME_SETTINGS_ENV_FILE` to that absolute path so startup reads and Settings-page writes stay aligned.
|
||||||
|
- For SQLite -> PostgreSQL cutover, run `uv run python tools/export_import_migration.py verify --source-db <sqlite-path-or-url> --target-db <postgres-url>` before switching runtime.
|
||||||
2. Validate service startup:
|
2. Validate service startup:
|
||||||
- `/healthz` responds `200`
|
- `/healthz` responds `200`
|
||||||
- `worker.state` is `running`
|
- if `RUN_EMBEDDED_WORKER=true`, `worker.state` is `running`
|
||||||
|
- if `RUN_EMBEDDED_WORKER=false`, validate `worker` container is running in Compose
|
||||||
|
(worker healthcheck is intentionally disabled because it does not expose `/healthz`)
|
||||||
|
- validate `cloudflared` logs show active tunnel routes and no ingress errors
|
||||||
3. Execute one smoke workflow:
|
3. Execute one smoke workflow:
|
||||||
- create a document/job with at least one source
|
- create a document/job with at least one source
|
||||||
- verify terminal job outcome updates
|
- verify terminal job outcome updates
|
||||||
@@ -29,6 +38,8 @@ This runbook is the operational checklist for releasing and monitoring the trans
|
|||||||
4. Verify log flow:
|
4. Verify log flow:
|
||||||
- stdout aggregation receives events
|
- stdout aggregation receives events
|
||||||
- file logs are written under `./data/logs`
|
- file logs are written under `./data/logs`
|
||||||
|
5. Create a fresh PostgreSQL backup after successful deployment:
|
||||||
|
- `sh deploy/backup/create_postgres_backup.sh` (creates DB dump plus uploads/prompts backups under `BACKUP_DIR`)
|
||||||
|
|
||||||
## 3. Rollback triggers and actions
|
## 3. Rollback triggers and actions
|
||||||
|
|
||||||
@@ -46,6 +57,9 @@ This runbook is the operational checklist for releasing and monitoring the trans
|
|||||||
4. Preserve incident evidence:
|
4. Preserve incident evidence:
|
||||||
- `./data/logs`
|
- `./data/logs`
|
||||||
- relevant DB rows (`job`, `job_source`, `execution_attempt`)
|
- relevant DB rows (`job`, `job_source`, `execution_attempt`)
|
||||||
|
5. If persistence regression is confirmed, restore the latest valid DB dump:
|
||||||
|
- `sh deploy/backup/restore_postgres_backup.sh <dump-file>`
|
||||||
|
- Synology media mirror and paired config snapshot (same timestamp) are restored automatically when present.
|
||||||
|
|
||||||
## 4. Post-release monitoring checklist
|
## 4. Post-release monitoring checklist
|
||||||
|
|
||||||
@@ -83,6 +97,23 @@ This runbook is the operational checklist for releasing and monitoring the trans
|
|||||||
2. Confirm failures are category-aligned (`external`/`timeout`/`internal`).
|
2. Confirm failures are category-aligned (`external`/`timeout`/`internal`).
|
||||||
3. Triage whether issue is source quality, provider, or runtime regression.
|
3. Triage whether issue is source quality, provider, or runtime regression.
|
||||||
|
|
||||||
|
### Cloudflare ingress/access failure
|
||||||
|
|
||||||
|
1. Check `cloudflared` container logs for ingress parse, DNS, or auth failures.
|
||||||
|
2. Confirm `deploy/cloudflared/config.yml` hostname mappings are correct.
|
||||||
|
3. Confirm `CLOUDFLARE_TUNNEL_TOKEN` in `.env.production` matches the tunnel configured in Cloudflare.
|
||||||
|
4. Confirm Cloudflare Access app policy includes the intended identity/group for that hostname.
|
||||||
|
5. If logs show SRV/DNS failures via `127.0.0.11`, use the compose-defined resolver override
|
||||||
|
(`dns: 1.1.1.1, 1.0.0.1`) and ensure outbound TCP 443 is allowed.
|
||||||
|
|
||||||
|
### Backup or restore failure
|
||||||
|
|
||||||
|
1. Verify `postgres` container is healthy and accepting connections.
|
||||||
|
2. Confirm dump file exists and is non-zero size.
|
||||||
|
3. Re-run backup/restore scripts with explicit `ENV_FILE` and `COMPOSE_FILE` if using non-default paths.
|
||||||
|
4. If direct Synology copy fails, keep local backup and resolve mount/network before next backup cycle.
|
||||||
|
- For LXC setups, use `deploy/backup/mount_synology_cifs.example.sh` as the persistent mount template.
|
||||||
|
|
||||||
## 6. Dependency upgrade policy
|
## 6. Dependency upgrade policy
|
||||||
|
|
||||||
Dependencies are declared in `pyproject.toml` and resolved through the committed
|
Dependencies are declared in `pyproject.toml` and resolved through the committed
|
||||||
|
|||||||
+35
-2
@@ -1,6 +1,9 @@
|
|||||||
# System Requirements (Current Baseline: V5.1)
|
# System Requirements (Current Baseline: V6.1)
|
||||||
|
|
||||||
These requirements define the active V5.1 contract and align to current implementation.
|
These requirements define the active V6.1 contract and align to current implementation.
|
||||||
|
|
||||||
|
Requirement IDs encode the baseline that introduced them (`REQ-4-*` from V4, `REQ-6-*` from V6) and
|
||||||
|
are stable. Never renumber an existing ID; retire it explicitly instead.
|
||||||
|
|
||||||
## Functional Requirements
|
## Functional Requirements
|
||||||
|
|
||||||
@@ -41,6 +44,31 @@ These requirements define the active V5.1 contract and align to current implemen
|
|||||||
- **REQ-4-041 Partial Failure Visibility:** Mixed page outcomes must be visible at job and page level.
|
- **REQ-4-041 Partial Failure Visibility:** Mixed page outcomes must be visible at job and page level.
|
||||||
- **REQ-4-042 Retry Support:** Failed and cancelled pages must support targeted retranscription without requiring full document recreation.
|
- **REQ-4-042 Retry Support:** Failed and cancelled pages must support targeted retranscription without requiring full document recreation.
|
||||||
|
|
||||||
|
### Person Imagery
|
||||||
|
|
||||||
|
- **REQ-6-001 Photo Records:** The system must store reusable `Photo` records that are either owned by a `Person` or unowned for homepage gallery use.
|
||||||
|
- **REQ-6-002 Primary Photo:** At most one photo per owning `Person` may be marked `is_primary`; setting a new primary must clear the previous one.
|
||||||
|
- **REQ-6-003 Primary Reassignment:** Deleting an owner's primary photo must promote a remaining photo of that owner rather than leaving the owner without a primary.
|
||||||
|
|
||||||
|
### Operational Maintenance
|
||||||
|
|
||||||
|
- **REQ-6-010 Queued Maintenance Runs:** Settings-initiated maintenance must persist a `MaintenanceRun` and execute in the worker, not inline in the request that started it.
|
||||||
|
- **REQ-6-011 Maintenance Run Types:** `MaintenanceRun.job_type` must use one of `backup`, `storage_reconciliation`, `gedcom_import`.
|
||||||
|
- **REQ-6-012 Maintenance Status Lifecycle:** `MaintenanceRun.status` must use one of `queued`, `processing`, `succeeded`, `failed`.
|
||||||
|
- **REQ-6-013 Single Claim:** A queued run must be claimed by at most one worker, using a conditional status update rather than read-then-write.
|
||||||
|
- **REQ-6-014 Run History:** Completed runs must retain status, timing, summary, log reference, and error detail, and expose the log for viewing and download.
|
||||||
|
- **REQ-6-015 GEDCOM Upload Import:** Settings must support manual `.ged` upload and queue-backed import into genealogy tables.
|
||||||
|
- **REQ-6-016 GEDCOM Idempotent Upsert:** GEDCOM import must upsert `GenealogyPerson` and `GenealogyFamily` by FamilySearch IDs and avoid duplicate imported citations on re-run.
|
||||||
|
|
||||||
|
### Deployment and Runtime Configuration
|
||||||
|
|
||||||
|
- **REQ-6-020 Production Persistence:** Production must run against PostgreSQL; SQLite remains supported for local development and tests.
|
||||||
|
- **REQ-6-021 Split Worker Deployment:** Production must support running the worker as its own process with the app started at `RUN_EMBEDDED_WORKER=false`.
|
||||||
|
- **REQ-6-022 Runtime Settings Persistence:** Runtime settings edits must persist to the mounted production environment file and survive container restart.
|
||||||
|
- **REQ-6-023 Configuration Contract Sync:** `.env.production.example` must stay synchronized with `Settings` keys and production-safe defaults.
|
||||||
|
- **REQ-6-024 Health Reporting:** The deployed stack must report app and worker health through `/healthz`.
|
||||||
|
- **REQ-6-025 Backup and Restore:** Database and media/config backups must be produced on a host-visible path with a tested restore procedure.
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
- **REQ-4-100 Boundary Integrity:** UI pages/components must not access persistence directly and must call service APIs.
|
- **REQ-4-100 Boundary Integrity:** UI pages/components must not access persistence directly and must call service APIs.
|
||||||
@@ -73,6 +101,11 @@ These requirements define the active V5.1 contract and align to current implemen
|
|||||||
- UI boundary enforcement: `tests/test_ui_boundaries.py`
|
- UI boundary enforcement: `tests/test_ui_boundaries.py`
|
||||||
- Job lifecycle reliability and terminal status behavior: `tests/services/test_workflows_reliability.py`
|
- Job lifecycle reliability and terminal status behavior: `tests/services/test_workflows_reliability.py`
|
||||||
- Evidence append-only and projection behavior: `tests/services/test_store.py`, `tests/services/test_transcription_service.py`
|
- Evidence append-only and projection behavior: `tests/services/test_store.py`, `tests/services/test_transcription_service.py`
|
||||||
|
- Person photo ownership and primary selection: `tests/services/test_photo_service.py`
|
||||||
|
- Maintenance run lifecycle and worker execution: `tests/services/test_maintenance_service.py`
|
||||||
|
- Runtime settings persistence: `tests/ui/test_runtime_settings_store.py`, `tests/services/test_settings_services.py`
|
||||||
|
- Configuration contract synchronization: `tests/test_meta_contract_guards.py`
|
||||||
|
- Deployment health reporting: `tests/api/test_health.py`
|
||||||
|
|
||||||
## Traceability Notes
|
## Traceability Notes
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
**Escalations applied:** `.github/skills/evidence-provenance-auditor/skill.md`, `.github/skills/test-effectiveness-auditor/skill.md`
|
**Escalations applied:** `.github/skills/evidence-provenance-auditor/skill.md`, `.github/skills/test-effectiveness-auditor/skill.md`
|
||||||
**Scope:** 77 Python modules / ~13k LOC under `src/transcription`, 57 test files (377 collected non-external tests), 23 documents under `docs/`, 9 active rule files.
|
**Scope:** 77 Python modules / ~13k LOC under `src/transcription`, 57 test files (377 collected non-external tests), 23 documents under `docs/`, 9 active rule files.
|
||||||
|
|
||||||
|
> **Status: closed.** Every finding below was remediated in the phases following this
|
||||||
|
> review. This document is retained as a record of the reasoning, **not** as a list of
|
||||||
|
> open work, and it is not canonical authority.
|
||||||
|
>
|
||||||
|
> Two recommendations were wrong on contact and were corrected during implementation:
|
||||||
|
> the HIGH-03 fix as written would have stripped root-cause data from `ExecutionAttempt`
|
||||||
|
> provenance, and the HIGH-01 fix needed to preserve per-page durability that the report
|
||||||
|
> did not mention. Where this text and the current code or guard tests disagree, the code
|
||||||
|
> and tests are correct.
|
||||||
|
|
||||||
### Verification commands and outcomes
|
### Verification commands and outcomes
|
||||||
|
|
||||||
| Command | Outcome |
|
| Command | Outcome |
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
# Handoff Brief — Phases 2-5 of the 2026-08-23 Code Review
|
|
||||||
|
|
||||||
**Repo:** `C:\Github\transcription` · **Branch:** `traumatized` · **Baseline commit:** `de18c2e`
|
|
||||||
**Source of truth:** `docs/reviews/2026-08-23-code-review.md` (§9 Prioritized Action Plan)
|
|
||||||
|
|
||||||
Phase 1 is **done and committed**. This brief covers everything after it.
|
|
||||||
|
|
||||||
> **Status note.** This is a dated, non-canonical artifact, like everything under
|
|
||||||
> `docs/reviews/**`. It records a plan, not a contract. Where it disagrees with
|
|
||||||
> `.github/instructions/**` or the canonical docs, **they win**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Environment — read this first
|
|
||||||
|
|
||||||
- `uv` project on **Windows / PowerShell**. `ruff`, `ty`, and `pytest` are **not on PATH**. Always prefix with `uv run`.
|
|
||||||
- PowerShell has **no heredoc**. Don't write `python - <<'PY'`. Use `python -c "..."` or pipe a single-quoted here-string (`@'` … `'@ | python -`).
|
|
||||||
- `&&` only chains *external* commands in PowerShell. Use `;` before PowerShell keywords.
|
|
||||||
- Ruff config (`ruff.toml`): line length **120**, `force-single-line = true` — **one import per line**. Never combine imports.
|
|
||||||
- `# noqa: PLR0915` / `PLR1702` is established repo convention; don't strip existing ones.
|
|
||||||
|
|
||||||
## 2. Mandatory reading before editing `src/transcription/**`
|
|
||||||
|
|
||||||
The repo's instruction table requires these before source edits. They are contracts, not suggestions:
|
|
||||||
|
|
||||||
- `.github/instructions/services.instructions.md` — transaction boundaries, model ownership, no service-to-service imports
|
|
||||||
- `.github/instructions/error-handling.instructions.md` — error categories, retriability, user-safe messaging
|
|
||||||
- `.github/instructions/ui.instructions.md` — page/component boundaries
|
|
||||||
- `.github/instructions/documentation-sync.instructions.md` — **docs must be updated in the same change** when contracts or behavior change
|
|
||||||
|
|
||||||
## 3. Verification commands
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
uv run ruff check . # must be clean
|
|
||||||
uv run pytest -q -m "not external" # must be 381+ passing
|
|
||||||
uv run ty check # baseline is exactly 10 diagnostics
|
|
||||||
```
|
|
||||||
|
|
||||||
**The `ty` baseline is 10, and all 10 are false positives** — SQLModel/SQLAlchemy column
|
|
||||||
descriptors typed as `UUID`/`datetime`/`bool`, so `.is_()`, `.asc()`, `func.count()`, and
|
|
||||||
`group_by()` appear invalid. They are in `services/photos.py` (8) and
|
|
||||||
`tests/test_storage_reconciliation.py` (2). **Do not "fix" these by changing code.** Handling
|
|
||||||
them is task P2-1 below, and the fix is suppression comments, not code edits.
|
|
||||||
|
|
||||||
Hard-won gotcha: `typing.Mapping` trips ruff's `deprecated-import`, and `collections.abc.Mapping`
|
|
||||||
doesn't satisfy `ty` for SQLAlchemy row results. `Sequence[RowMapping]` + `RowMapping` is the
|
|
||||||
only spelling that satisfies both. Don't rediscover this.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. What Phase 1 changed (context you need)
|
|
||||||
|
|
||||||
Commit `de18c2e`. Four things, all with tests:
|
|
||||||
|
|
||||||
1. **`workflows.py` commit boundary.** `process_queued_job` now commits every page except the
|
|
||||||
last individually, then defers the final page's write into `_finalize_batch_outcome` so it
|
|
||||||
shares the terminal-status transaction.
|
|
||||||
- **`_finalize_batch_outcome` gained a `final_page` kwarg.** If you touch this function, that
|
|
||||||
parameter is load-bearing.
|
|
||||||
- **Two invariants are in tension here — preserve both.** Intermediate pages must stay
|
|
||||||
individually durable (guarded by
|
|
||||||
`test_workflows_reliability.py::...::test_transcribed_page_is_committed_before_next_provider_call_finishes`),
|
|
||||||
and the final page must be atomic with the terminal status (guarded by
|
|
||||||
`tests/integration/test_pipeline_atomicity.py`). **Do not collapse the whole batch into one
|
|
||||||
transaction** to simplify things — that breaks multi-page durability.
|
|
||||||
|
|
||||||
2. **`AppError` gained an internal-only `detail` field.** `message` is user/API-facing and must
|
|
||||||
stay generic; `detail` carries the root cause and flows into evidence records via
|
|
||||||
`format_error_detail` and into logs. Documented in `docs/error_handling.md` §"Message vs
|
|
||||||
detail split". **When adding error paths: never put exception text into `message`.**
|
|
||||||
|
|
||||||
3. **8 `ui.notify` error sites replaced with `show_error`** in `home_page.py` / `people_page.py`.
|
|
||||||
|
|
||||||
4. **New AST guard** `test_ui_boundaries.py::test_no_page_hand_rolls_error_notifications`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Phase 2 — Enforcement hardening
|
|
||||||
|
|
||||||
### P2-1 · `ty` suppression policy, then make the hook blocking
|
|
||||||
**Report ref:** LOW-05 · **Effort:** M
|
|
||||||
|
|
||||||
`.pre-commit-config.yaml` currently runs `ty` in **advisory** mode (a Python subprocess wrapper
|
|
||||||
that forces `sys.exit(0)`) because of the 10 known false positives. Net effect: a genuine new
|
|
||||||
type error prints alongside the known 10 and **blocks nothing**.
|
|
||||||
|
|
||||||
1. Add a targeted `# ty: ignore[<rule>]` at each of the 10 sites, each with a one-line comment
|
|
||||||
explaining it's a SQLAlchemy descriptor false positive.
|
|
||||||
2. Confirm `uv run ty check` reports **0**.
|
|
||||||
3. Flip the pre-commit hook to blocking (drop the `sys.exit(0)` wrapper).
|
|
||||||
4. Document the policy in `docs/` — when a suppression is acceptable and what the comment must say.
|
|
||||||
|
|
||||||
**Acceptance:** `uv run ty check` → 0 diagnostics; introducing a deliberate type error fails
|
|
||||||
`git commit`; revert the deliberate error afterward.
|
|
||||||
|
|
||||||
### P2-2 · `ruff format` enforcement
|
|
||||||
**Report ref:** LOW-06 · **Effort:** S
|
|
||||||
|
|
||||||
~35 files have formatting drift. **Two separate commits, in this order:**
|
|
||||||
1. `uv run ruff format .` — formatting only, **no other changes in this commit**.
|
|
||||||
2. Add `ruff format --check` to `.pre-commit-config.yaml`.
|
|
||||||
|
|
||||||
Keeping these separate matters: a mixed commit makes the formatting noise unreviewable.
|
|
||||||
|
|
||||||
**Acceptance:** `uv run ruff format --check .` clean; full suite still 381+.
|
|
||||||
|
|
||||||
### P2-3 · Enable ruff ruleset `G` (flake8-logging-format)
|
|
||||||
**Report ref:** LOW-09 · **Effort:** S
|
|
||||||
|
|
||||||
f-strings in logging calls format eagerly regardless of level and break template grouping in
|
|
||||||
structured backends. Known instance: `workflows.py:193`. Enable `G` in `ruff.toml`, then convert
|
|
||||||
offenders to `%s` lazy args: `logger.error("Job %s failed.", job.id)`.
|
|
||||||
|
|
||||||
**Acceptance:** `uv run ruff check .` clean with `G` enabled.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Phase 3 — Reliability & concurrency
|
|
||||||
|
|
||||||
### P3-1 · Periodic stale-job recovery
|
|
||||||
**Report ref:** MED-01 · **Effort:** M
|
|
||||||
|
|
||||||
`requeue_stale_processing_jobs` has exactly one caller — `app.py:79`, in the lifespan startup
|
|
||||||
handler. There is no runtime re-check. The threshold reuses `worker_provider_timeout_seconds`
|
|
||||||
(**30.0s**, `config.py:116`).
|
|
||||||
|
|
||||||
The failure mode: a job orphaned <30s before a fast restart fails the staleness predicate at the
|
|
||||||
only moment recovery runs, so it stays `PROCESSING` forever (the worker only claims `QUEUED`).
|
|
||||||
Restarts are exactly when orphans are created, so the recovery window is systematically
|
|
||||||
misaligned with the failure it exists to handle.
|
|
||||||
|
|
||||||
1. Add `worker_stale_job_seconds` to `Settings` (don't keep overloading the provider timeout —
|
|
||||||
they need independent tuning). Update `.env.example` in the same change (required by
|
|
||||||
`services.instructions.md`).
|
|
||||||
2. Run the sweep periodically in the worker loop, **in addition to** the startup call.
|
|
||||||
3. Test: a job left `PROCESSING` past the threshold is requeued **without** a restart.
|
|
||||||
|
|
||||||
### P3-2 · Gate retries on error category
|
|
||||||
**Report ref:** MED-02 · **Effort:** S
|
|
||||||
|
|
||||||
`workflows.py:184-194` gates only on `job.retry_count < settings.worker_max_retries`. It never
|
|
||||||
consults `error_category` or `AppError.retriable`, so `validation` / `not_found` / `conflict`
|
|
||||||
failures would retry to exhaustion, burning provider quota on calls that cannot succeed.
|
|
||||||
|
|
||||||
**Latent today** because `worker_max_retries` defaults to `0` — which is exactly why this must be
|
|
||||||
fixed *before* anyone raises that value. Add backoff too; retries currently requeue immediately.
|
|
||||||
|
|
||||||
**Acceptance:** a `validation`-category failure is not requeued even with `worker_max_retries=1`.
|
|
||||||
|
|
||||||
### P3-3 · Shutdown budget derived from provider timeout
|
|
||||||
**Report ref:** MED-04 · **Effort:** S
|
|
||||||
|
|
||||||
`worker.py:146` waits `2.0s` for the worker task, but an in-flight provider call may run 30s and
|
|
||||||
the stop event is only checked *between* jobs. Derive the budget from
|
|
||||||
`worker_provider_timeout_seconds` plus a small grace. Document the relationship to the container
|
|
||||||
termination grace period in `docs/production-runbook.md`.
|
|
||||||
|
|
||||||
### P3-4 · Handle `IntegrityError` on the attempt-number flush
|
|
||||||
**Report ref:** MED-03 · **Effort:** M
|
|
||||||
|
|
||||||
`sources.py:540-546` computes `MAX(attempt_number) + 1`; `uq_execution_attempt_number` enforces
|
|
||||||
uniqueness. The sibling `JobSource` insert catches `IntegrityError` at `sources.py:531-534`, but
|
|
||||||
the attempt `flush()` at `sources.py:587` does **not** — a race loses an evidence row.
|
|
||||||
|
|
||||||
Not reachable today (single worker, sequential sources). **It becomes reachable the moment a
|
|
||||||
second worker replica is deployed** — treat this as a hard precondition for horizontal scaling
|
|
||||||
and note that in `docs/production-runbook.md`.
|
|
||||||
|
|
||||||
Mirror the `JobSource` handling: catch, recompute, retry bounded, raise a domain error on
|
|
||||||
exhaustion.
|
|
||||||
|
|
||||||
### P3-5 · Move blocking work off the event loop
|
|
||||||
**Report ref:** LOW-01, LOW-02, LOW-03 · **Effort:** S
|
|
||||||
|
|
||||||
- `store.py:401` — `hashlib.sha256(file_bytes)` is CPU-bound on the loop. Wrap in `asyncio.to_thread`.
|
|
||||||
- `homepage_store.py:25,32` — sync file I/O called from async page handlers. Same fix.
|
|
||||||
- `app.py:62` — `poll_interval_seconds=1.0` hardcoded. Move to `Settings`; update `.env.example`.
|
|
||||||
|
|
||||||
Every other I/O path already uses `to_thread` (`media_storage.py:43`, `normalization.py:117`,
|
|
||||||
`photos.py:176`, `sources.py:740,753`) — follow those.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Phase 4 — Consolidation
|
|
||||||
|
|
||||||
### P4-1 · Single transaction entry point
|
|
||||||
**Report ref:** MED-05 · **Effort:** M
|
|
||||||
|
|
||||||
`workflows.py` opens transactions via `services.jobs._session_scope()` and
|
|
||||||
`services.sources._session_scope()` — **private members of two different services**. This is the
|
|
||||||
mechanism that made HIGH-01 easy to introduce: nothing in the design signals that two scopes are
|
|
||||||
being opened for one logical unit of work.
|
|
||||||
|
|
||||||
Introduce `unit_of_work(services, session)` (proposed signature in review §7), migrate
|
|
||||||
`workflows.py` onto it, then add an AST guard to `test_service_boundaries.py` forbidding
|
|
||||||
`_session_scope` access outside its owning module. Note the existing test checks *imports*, not
|
|
||||||
attribute access, so it can't currently see this.
|
|
||||||
|
|
||||||
**Do not attempt this before Phase 1's tests are green in your working tree** — it touches the
|
|
||||||
same functions.
|
|
||||||
|
|
||||||
### P4-2 · Extract shared helpers
|
|
||||||
**Report ref:** §7 · **Effort:** M
|
|
||||||
|
|
||||||
`run_blocking` and `insert_with_sequence_retry`, per the review's proposed signatures. Do this
|
|
||||||
*after* P3-4 and P3-5, so the call sites exist.
|
|
||||||
|
|
||||||
### P4-3 · Prune low-signal tests
|
|
||||||
**Report ref:** LOW-10, LOW-11 · **Effort:** S
|
|
||||||
|
|
||||||
Full table in review §6 "Prune/strengthen backlog". Highlights:
|
|
||||||
- `test_traceability.py:54-57` — asserts properties of dict literals in the same file.
|
|
||||||
- `test_pipeline_flow.py:135-140,446-452` — `assert processed is True` is unfalsifiable
|
|
||||||
(`read_job` raises rather than returning `None`).
|
|
||||||
- `test_orphan_sweep.py:119` — `>= 200` snapshot threshold tolerates ±40 drift.
|
|
||||||
- `test_workflows_reliability.py:157-196` — real `time.sleep(0.40)` with only 10% slack; flaky
|
|
||||||
under CI load.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Phase 5 — Governance
|
|
||||||
|
|
||||||
- **P5-1** — Extend `test_orphan_sweep.py` beyond module-level definitions to public methods
|
|
||||||
(LOW-08); seed `KNOWN_ORPHANS` with current results to keep it non-breaking.
|
|
||||||
- **P5-2** — Resolve the 4 "uncertain" orphans (LOW-07). Note `summarize_error` should now be
|
|
||||||
reachable — consider using it, or delete it.
|
|
||||||
- **P5-3** — Log incomplete request manifests instead of silently returning `None`
|
|
||||||
(`openrouter.py:347`, LOW-04).
|
|
||||||
- **P5-4** — Consider inverting the UI boundary check from a forbidden-list to an allowlist; a
|
|
||||||
future persistence helper under a new name currently escapes it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Ground rules
|
|
||||||
|
|
||||||
1. **Red first.** For any behavioral fix, write the test, *run it, observe it fail*, then fix.
|
|
||||||
That's how Phase 1 caught that its own first HIGH-03 attempt was wrong.
|
|
||||||
2. **One phase per commit series.** Don't mix P2-2's formatting sweep with logic changes.
|
|
||||||
3. **Docs in the same change.** Required by `documentation-sync.instructions.md` whenever
|
|
||||||
contracts, behavior, or `Settings` change. `Settings` changes additionally require
|
|
||||||
`.env.example` updates in the same commit.
|
|
||||||
4. **Don't widen the NiceGUI pin.** `nicegui==3.13.0` is a deliberate release-stability decision
|
|
||||||
recorded in `docs/production-runbook.md`. It is explicitly **not** a defect.
|
|
||||||
5. **`docs/reviews/**` is not canonical.** It's a dated artifact; don't treat it as a contract
|
|
||||||
the way `docs/schema.md` or the instruction files are.
|
|
||||||
6. **Ask before scope-expanding.** If a fix seems to require restructuring beyond its task,
|
|
||||||
stop and confirm — that's the signal a Phase boundary is being crossed.
|
|
||||||
|
|
||||||
## 10. Suggested first command
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd C:\Github\transcription
|
|
||||||
git log --oneline -3
|
|
||||||
uv run ruff check . ; uv run pytest -q -m "not external" ; uv run ty check
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm the baseline (clean ruff, 381+ passing, exactly 10 `ty` diagnostics) before changing
|
|
||||||
anything. If that doesn't reproduce, stop and report rather than proceeding.
|
|
||||||
@@ -0,0 +1,896 @@
|
|||||||
|
# Architecture & Code Review Report
|
||||||
|
|
||||||
|
**Repository Target:** `C:\Github\transcription\`
|
||||||
|
**Target Stack:** Python 3.12+ | FastAPI | NiceGUI | SQLModel/SQLAlchemy | Pydantic V2 | asyncio | OpenRouter
|
||||||
|
**Review Date:** 2026-09-02
|
||||||
|
**Canonical Baseline:** V6.1 (`docs/index.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Verification Commands and Outcomes
|
||||||
|
|
||||||
|
All four commands were executed in this checkout before any finding was written. This report
|
||||||
|
records the exact outcomes rather than assuming them.
|
||||||
|
|
||||||
|
| Command | Outcome |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `uv run pytest -q -m "not external"` | **410 passed, 0 failed, 0 errors** (exit 0) |
|
||||||
|
| `uv run ruff check .` | **All checks passed!** |
|
||||||
|
| `uv run ruff format --check .` | **191 files already formatted** |
|
||||||
|
| `uv run ty check` | **All checks passed!** |
|
||||||
|
|
||||||
|
The stated green baseline is real. No finding below is a test failure; every finding is a
|
||||||
|
behavior, contract, or guard-coverage defect that the passing suite does not detect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
- **The system's core evidence guarantees hold.** `ExecutionAttempt` is genuinely append-only,
|
||||||
|
attempt numbering is allocated with bounded conflict retry, transport evidence is captured at
|
||||||
|
the HTTP boundary before SDK parsing, and header persistence uses a true allowlist. Provenance
|
||||||
|
invariant families A–E and G pass.
|
||||||
|
- **Both competing atomicity invariants in `services/workflows.py` are real and both guards
|
||||||
|
genuinely enforce them.** I injected-fault-verified the tests rather than trusting the
|
||||||
|
docstrings: `test_pipeline_atomicity.py` fails on a split final-page commit, and
|
||||||
|
`test_workflows_reliability.py:318` reads intermediate attempts through a *separate session*,
|
||||||
|
so it would fail if intermediate pages stopped committing individually.
|
||||||
|
- **The most significant defect is a privacy leak that a prior review believed it had closed.**
|
||||||
|
The 2026-08-23 review moved root-cause text out of `AppError.message` into `AppError.detail`
|
||||||
|
to keep filesystem paths away from users. That text now reaches users anyway, because the UI
|
||||||
|
renders `ExecutionAttempt.error_detail` verbatim (HIGH-01). The leak was relocated, not closed.
|
||||||
|
- **A second, independent path leak exists in five explicit `raise` sites** that the existing
|
||||||
|
guard never covered — it tests only `classify_unexpected_error` (HIGH-02).
|
||||||
|
- **Provenance invariant family F (path safety) fails**, and it fails *inconsistently within one
|
||||||
|
file*: `sources_page.py:443` carefully sanitizes a stored path through
|
||||||
|
`public_media_path_label`, then `sources_page.py:484` dumps raw `error_detail` forty lines later.
|
||||||
|
- **The orphan sweep does not do what its docstring claims.** It matches definitions by bare name,
|
||||||
|
so an entirely dead *module* passes whenever its function names collide with live ones.
|
||||||
|
`ui/pages/tags_page.py` is the proof: 93 lines never imported by anything (MED-01/LOW-01).
|
||||||
|
- **On the three flagged open items:** the V4/V6.1 doc drift is confirmed (MED-02); the
|
||||||
|
`.env.production` coupling is real but currently correct and loud-failing, so Medium not High
|
||||||
|
(MED-03); and the Tags roadmap is **right** — the route is genuinely not registered, so the
|
||||||
|
module is dead code rather than a live retired route.
|
||||||
|
- **Two latent concurrency defects carry ordering constraints** and must be fixed *before* the
|
||||||
|
changes that would make them live (MED-04, MED-05), not after.
|
||||||
|
- **Guidance-file accuracy:** the recently revised `.github/instructions/*` files were verified
|
||||||
|
against code rather than trusted. They are accurate as written; the code is what diverges from
|
||||||
|
them. The one exception is that `error-handling.instructions.md` states a `detail` rule the UI
|
||||||
|
layer has never followed, which makes it an unenforced claim rather than a wrong one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Executive Architecture Assessment
|
||||||
|
|
||||||
|
**Verdict: architecturally sound, with a concentrated failure in the *last mile* of error
|
||||||
|
presentation.**
|
||||||
|
|
||||||
|
Domain cohesion and dependency direction are good and, unusually, mechanically enforced.
|
||||||
|
`test_service_boundaries.py` and `test_ui_boundaries.py` AST-scan for violations using
|
||||||
|
*allowlists* rather than blocklists, which is the correct choice — a newly added persistence
|
||||||
|
helper cannot slip through under an unlisted name. `workflows.py` imports only the abstract
|
||||||
|
`providers` types and never `openrouter`, so provider details genuinely stop at the adapter.
|
||||||
|
Transaction ownership is explicit and well-reasoned: `ServiceBase._finalize` commits for
|
||||||
|
service-owned sessions and flushes for caller-owned ones, which is what lets orchestration
|
||||||
|
modules compose multi-aggregate writes without services importing each other.
|
||||||
|
|
||||||
|
The evidence layer is the strongest part of the system and shows real care. The distinction
|
||||||
|
between transport response, SDK-parsed response, and normalized metadata is maintained in code,
|
||||||
|
not just in prose — `_CapturingAsyncClient` exists specifically to retain the exact wire body
|
||||||
|
before the SDK can discard unknown fields, and `TransportEvidence(response_received=False)`
|
||||||
|
explicitly represents "no response was received" rather than conflating it with an empty one.
|
||||||
|
|
||||||
|
The weakness is at the boundary where internal diagnostic text becomes pixels. Every layer
|
||||||
|
*below* the UI respects the message/detail split; the UI layer reads the internal field directly
|
||||||
|
and renders it. The architecture defines the contract correctly and then has no enforcement at
|
||||||
|
the one layer that violates it.
|
||||||
|
|
||||||
|
**Top systemic risks:**
|
||||||
|
|
||||||
|
1. **Internal diagnostic text reaches users through the evidence display path** (HIGH-01). The
|
||||||
|
rule is documented in three places and enforced in none of them at the UI boundary.
|
||||||
|
2. **Path-safety discipline is applied per-call-site rather than structurally** (HIGH-02, HIGH-01).
|
||||||
|
It is correct wherever someone remembered; there is no guard that makes forgetting fail.
|
||||||
|
3. **Guard coverage is narrower than guard docstrings claim.** Two guards
|
||||||
|
(`test_orphan_sweep.py`, `test_errors.py`) assert something meaningfully weaker than the
|
||||||
|
invariant they are named for, which converts them into a false sense of enforcement.
|
||||||
|
4. **Worker safety currently rests on single-process sequential execution, not on configuration**
|
||||||
|
(MED-04, MED-05). Nothing is wrong today; two plausible future changes each make something wrong.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Findings by Severity
|
||||||
|
|
||||||
|
### Critical Severity
|
||||||
|
|
||||||
|
*None.* No evidence loss, append-only violation, secret leakage, or silent-wrong-output defect
|
||||||
|
was found. The candidates in this class (provider evidence mis-attribution, stale-job double
|
||||||
|
processing) are latent and are reported at High/Medium with their unblocking conditions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### High Severity
|
||||||
|
|
||||||
|
#### [HIGH-01] Internal-only `error_detail` is rendered directly to users, reopening the leak the 2026-08-23 fix was meant to close
|
||||||
|
|
||||||
|
- **Location:**
|
||||||
|
- Write side: `src/transcription/errors.py:99-139` (`classify_unexpected_error` → `detail`, `format_error_detail` → persisted text)
|
||||||
|
- Persist: `src/transcription/services/workflows.py:720` (`error_detail=format_error_detail(page.error)`)
|
||||||
|
- **Render (Source Detail):** `src/transcription/ui/pages/sources_page.py:481-484`
|
||||||
|
- **Render (Sources list):** `src/transcription/ui/pages/sources_page.py:121` → `src/transcription/ui/components/table/sources.py:39,90-95` ("Error Detail" column)
|
||||||
|
- **Render (Maintenance):** `src/transcription/ui/pages/settings_page.py:562`, written by `src/transcription/services/maintenance.py:206`
|
||||||
|
- Contract violated: `docs/error_handling.md:107-114`; `.github/instructions/error-handling.instructions.md:86`; `docs/invariant/error_handling.md:59`; `docs/invariant/ai_evidence_and_provenance.md:103`
|
||||||
|
|
||||||
|
- **Reachability:** **Live.** Concrete path, no configuration required: a page fails with any
|
||||||
|
non-`AppError` exception → `workflows.py:388` calls `classify_unexpected_error(exc)` →
|
||||||
|
`errors.py:118` sets `detail=f"{type(exc).__name__}: {exc}"` → `format_error_detail`
|
||||||
|
(`errors.py:135-139`) emits `... | detail=OSError: [Errno 13] Permission denied: '/app/uploads/documents/<uuid>/page-1.jpg' | ...`
|
||||||
|
→ persisted to `ExecutionAttempt.error_detail` → rendered verbatim at
|
||||||
|
`sources_page.py:484` and in the `/sources` table column. A SQLAlchemy `OperationalError`
|
||||||
|
carries the database path by the same route.
|
||||||
|
|
||||||
|
- **Problem & Consequence:** `docs/error_handling.md:110` states `detail` is *"Internal only"* and
|
||||||
|
that its only surfaces are `format_error_detail` (evidence) and logs;
|
||||||
|
`error-handling.instructions.md:86` says *"Never rendered to users or serialized into an
|
||||||
|
envelope."* The UI reads it anyway. The consequence is not hypothetical drift — it is the
|
||||||
|
precise defect the previous review's fix existed to prevent. That fix made `message` generic and
|
||||||
|
moved the root cause to `detail` on the stated grounds that `detail` never reaches users. That
|
||||||
|
premise was never true: `error_detail` had a UI consumer the whole time. The result is that the
|
||||||
|
filesystem-path leak was relocated from the notification banner to the Source Detail card and
|
||||||
|
the Sources table, while the test suite records the leak as fixed
|
||||||
|
(`tests/test_errors.py:56-78`).
|
||||||
|
|
||||||
|
The inconsistency is visible inside a single file: `sources_page.py:443` deliberately routes a
|
||||||
|
stored path through `public_media_path_label` (`ui/components/media_urls.py:58-72`), which
|
||||||
|
correctly degrades an absolute path to its bare filename — and then `sources_page.py:484`
|
||||||
|
renders unsanitized text that may contain an absolute path.
|
||||||
|
|
||||||
|
- **Blast Radius:** Enumerated by grepping every reader of `.detail` and `error_detail`:
|
||||||
|
- `errors.py:137` — `format_error_detail`, the only reader of `AppError.detail`. **Must keep the root cause.**
|
||||||
|
- `services/workflows.py:720` — the only writer of `ExecutionAttempt.error_detail`.
|
||||||
|
- `services/maintenance.py:206` — the only writer of `MaintenanceRun.error_detail`.
|
||||||
|
- `services/evidence.py:195` — `build_evidence_export` emits `error_detail`. Export is an
|
||||||
|
operator-initiated evidence artifact; per invariant 3.7.1 it **must** retain it.
|
||||||
|
- `db/models.py:508-522` — `Source.latest_error_detail` projection, consumed only by `sources_page.py:121`.
|
||||||
|
- `ui/pages/sources_page.py:481-484`, `ui/components/table/sources.py`, `ui/pages/settings_page.py:562` — the three render sites.
|
||||||
|
- Tests asserting on persisted text: `tests/test_v42_evidence.py:284`,
|
||||||
|
`tests/services/test_workflows_reliability.py` (timeout detail),
|
||||||
|
`tests/services/test_maintenance_service.py`. A fix that changes *what is stored* breaks these;
|
||||||
|
a fix that changes *what is displayed* does not.
|
||||||
|
|
||||||
|
- **Recommendation — two invariants conflict here; both must be named.**
|
||||||
|
|
||||||
|
**Invariant 1 (evidence):** `ExecutionAttempt.error_detail` must retain the root cause.
|
||||||
|
`docs/requirements.md:30` (REQ-4-021) and `docs/invariant/ai_evidence_and_provenance.md:33`
|
||||||
|
require it; guarded by `tests/test_v42_evidence.py::test_attempts_are_append_only_and_exported_with_integrity`
|
||||||
|
and `tests/services/test_workflows_reliability.py`.
|
||||||
|
|
||||||
|
**Invariant 2 (privacy):** user-facing surfaces must not expose local filesystem details.
|
||||||
|
`docs/invariant/error_handling.md:59`; guarded (partially) by
|
||||||
|
`tests/test_errors.py::test_unexpected_error_does_not_leak_filesystem_paths`.
|
||||||
|
|
||||||
|
**The over-correction to avoid is stripping root-cause text out of `detail` or
|
||||||
|
`format_error_detail` to make the UI safe.** That is exactly the mistake documented in the
|
||||||
|
reviewer skill's worked example, and it would silently destroy the provenance record this
|
||||||
|
system exists to preserve while making every guard still pass.
|
||||||
|
|
||||||
|
Fix at the **render** boundary, not the write boundary. Add a presentation-layer projection and
|
||||||
|
route all three UI sites through it, leaving the persisted evidence untouched:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/transcription/ui/components/error_presenter.py (new)
|
||||||
|
def display_failure_detail(error_detail: str | None) -> str | None:
|
||||||
|
"""Render persisted failure detail without machine-local paths.
|
||||||
|
|
||||||
|
`ExecutionAttempt.error_detail` is provenance and keeps the full root cause
|
||||||
|
(docs/error_handling.md). This projection is the only thing a page may show.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
It should preserve the `[category]`, `suggestion=`, and `error_id=` segments (which are what
|
||||||
|
make the display actionable) and reduce any absolute path inside `detail=` to its basename,
|
||||||
|
mirroring `public_media_path_label`. The operator keeps diagnosability — required by
|
||||||
|
`docs/ui/pages/sources.md:43` and `docs/requirements.md:59` (REQ-6-014) — without the container
|
||||||
|
filesystem layout being published to the browser.
|
||||||
|
|
||||||
|
Then decide and record which resolution was chosen: either the UI shows the sanitized
|
||||||
|
projection (recommended), or `docs/error_handling.md:107-114` and
|
||||||
|
`error-handling.instructions.md:86` are revised to state that operator-facing evidence displays
|
||||||
|
may render `error_detail` **and** that the guarantee moves to "no machine-local detail ever
|
||||||
|
enters `detail`" — which would be a much harder guarantee to keep. Do not leave the current
|
||||||
|
state, where the docs claim one thing and three pages do another.
|
||||||
|
|
||||||
|
- **Effort:** M
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### [HIGH-02] Absolute filesystem paths are embedded in user-facing `AppError.message` at five explicit raise sites
|
||||||
|
|
||||||
|
- **Location:**
|
||||||
|
- `src/transcription/services/sources.py:856` — `f"Prompt file not found: {prompt_path}"`
|
||||||
|
- `src/transcription/services/sources.py:864` — `f"Prompt file is empty: {prompt_path}"`
|
||||||
|
- `src/transcription/services/sources.py:914` — `f"Source file not found: {path}"`
|
||||||
|
- `src/transcription/services/prompts.py:99` — `f"Prompt directory is unavailable: {root}"`
|
||||||
|
- `src/transcription/services/prompts.py:186-191` — `_filesystem_error` builds `f"{message}: {exc}"`
|
||||||
|
- Contract violated: `.github/instructions/error-handling.instructions.md:74,85`; `docs/invariant/error_handling.md:59`
|
||||||
|
|
||||||
|
- **Reachability:** **Live**, on an ordinary user path. `sources.py:845` resolves
|
||||||
|
`prompt_root = runtime_settings.prompt_dir.resolve()`, so `prompt_path` is absolute
|
||||||
|
(`/app/prompts/transcribe_document.md` in the container). `load_prompt_text` is invoked by
|
||||||
|
`build_prompt_execution` (`sources.py:829-831`), which runs on **every document upload** via
|
||||||
|
`services/store.py:94` and `store.py:162`. The resulting `PromptLoadError` is an `AppError`
|
||||||
|
subclass, so it flows through `run_ui_action` → `show_error`
|
||||||
|
(`ui/components/error_presenter.py:51-66`), which renders `error.message` into both a
|
||||||
|
`ui.notify` banner and a card label, and through `build_error_envelope` (`errors.py:88-96`)
|
||||||
|
into API responses.
|
||||||
|
|
||||||
|
- **Problem & Consequence:** `error-handling.instructions.md:85` requires `message` to *"Stay
|
||||||
|
generic. Never embed exception text, provider payloads, or filesystem paths."* These five sites
|
||||||
|
embed exactly that. `prompts.py:186-191` violates the rule in **both** directions at once: it
|
||||||
|
puts `{exc}` — an `OSError` whose `str()` includes the offending filename — into `message`, and
|
||||||
|
it sets **no `detail=`**, so the internal field that is supposed to carry the root cause is
|
||||||
|
empty while the user-facing field carries all of it.
|
||||||
|
|
||||||
|
This is not a new regression; it is coverage that the existing guard never had.
|
||||||
|
`tests/test_errors.py:56-78` verifies only that `classify_unexpected_error` — the *catch-all*
|
||||||
|
path — does not leak. Every deliberate `raise SomeError(f"... {path}")` in the codebase is
|
||||||
|
outside its scope, so the suite reports the invariant as enforced while five live sites violate it.
|
||||||
|
|
||||||
|
- **Blast Radius:** Verified by grepping all consumers of these exception types.
|
||||||
|
`PromptLoadError`/`PromptStoreError`/`TranscriptionError` messages are consumed by:
|
||||||
|
`ui/components/error_presenter.py:55,63` (render), `errors.py:92` (API envelope),
|
||||||
|
`errors.py:135` (`format_error_detail` → evidence). Because the recommended change *adds* a
|
||||||
|
`detail` and *shortens* `message`, `format_error_detail` output still contains the path — so
|
||||||
|
evidence value is preserved, not reduced. Tests asserting on these messages:
|
||||||
|
`tests/test_prompts.py`, `tests/services/test_prompt_store.py`,
|
||||||
|
`tests/services/test_transcription_service.py`. These assert on message prefixes
|
||||||
|
(`"Prompt file not found"`), not on the interpolated path, and were checked to survive the change —
|
||||||
|
but re-run them, since `prompts.py:186` currently produces a message whose suffix some
|
||||||
|
assertion could depend on.
|
||||||
|
|
||||||
|
- **Recommendation:** Apply the pattern `errors.py:113-119` already establishes — generic
|
||||||
|
`message`, root cause on `detail`, `raise ... from exc`. Use `path.name` when a filename is
|
||||||
|
genuinely useful to the user.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# sources.py:855 — before
|
||||||
|
raise PromptLoadError(f"Prompt file not found: {prompt_path}", ...)
|
||||||
|
# after
|
||||||
|
raise PromptLoadError(
|
||||||
|
f"Prompt file not found: {prompt_path.name}",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
|
||||||
|
detail=f"Prompt file missing at {prompt_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# prompts.py:186 — before
|
||||||
|
return PromptStoreError(f"{message}: {exc}", category=..., suggestion=...)
|
||||||
|
# after
|
||||||
|
return PromptStoreError(
|
||||||
|
message,
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Check prompt directory permissions and available disk space, then retry.",
|
||||||
|
detail=f"{type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then widen the guard so this class cannot recur — see MED-07. Note the dependency: HIGH-02 and
|
||||||
|
HIGH-01 must be fixed **together**, because moving the path from `message` to `detail` while the
|
||||||
|
UI still renders `error_detail` relocates the leak instead of closing it. That is the same
|
||||||
|
mistake that produced HIGH-01.
|
||||||
|
|
||||||
|
- **Effort:** S (fix) / M (with the guard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Medium Severity
|
||||||
|
|
||||||
|
#### [MED-01] The orphan sweep matches by bare name and therefore cannot detect a dead module
|
||||||
|
|
||||||
|
- **Location:** `tests/test_orphan_sweep.py:101-163` (`_public_definitions`, `_orphans`)
|
||||||
|
- **Reachability:** **Live** — the guard is running now and reporting a clean sweep that is not clean.
|
||||||
|
- **Problem & Consequence:** `_public_definitions()` keys definitions by bare name
|
||||||
|
(`definitions[node.name]`, line 111) and `_orphans()` marks a definition referenced if that
|
||||||
|
bare name appears **anywhere** in `src/`, `tests/`, or `tools/` (lines 157-162). Two different
|
||||||
|
modules that define the same public name are therefore indistinguishable, and neither can ever
|
||||||
|
be reported as an orphan.
|
||||||
|
|
||||||
|
`src/transcription/ui/pages/tags_page.py` demonstrates the consequence. Its only public
|
||||||
|
definition is `register_page` (line 18). Seven live page modules define a function of the same
|
||||||
|
name and `ui/__init__.py:37-43` calls all seven — so `register_page` is heavily referenced and
|
||||||
|
`tags_page.register_page` is scored as reachable. In fact **nothing imports `tags_page` at all**
|
||||||
|
(verified: the only repo-wide references to the module are the file itself and
|
||||||
|
`tests/ui/test_tags_page.py`, which merely asserts the route 404s). 93 lines of code, including a
|
||||||
|
lazy-load-unsafe relationship traversal at `tags_page.py:71-74`, sit outside the sweep's reach.
|
||||||
|
|
||||||
|
The sweep also never asks whether a *module* is imported, only whether its definitions' names
|
||||||
|
appear somewhere — so this is a structural gap, not a one-off miss.
|
||||||
|
|
||||||
|
- **Blast Radius:** `tests/test_orphan_sweep.py` only; `KNOWN_ORPHANS` entries are keyed by the
|
||||||
|
same bare/dotted names and would need re-keying if qualification is added. Expect the stricter
|
||||||
|
sweep to surface additional true orphans on first run — triage them into `KNOWN_ORPHANS` with
|
||||||
|
rationales rather than weakening the check.
|
||||||
|
- **Recommendation:** Qualify definitions by module (`f"{module_path}:{name}"`) and add a separate,
|
||||||
|
cheap module-reachability pass: a module under `src/transcription/` is reachable if any other
|
||||||
|
module imports it, or it is a declared entrypoint (`app.py`, `__main__.py`, `worker_service.py`).
|
||||||
|
Report unreachable modules as orphans in their own right. Also fix
|
||||||
|
`test_public_definitions_are_discovered` (line 169), whose `>= 420` snapshot threshold is a
|
||||||
|
weak assertion that drifts upward silently — the 2026-08-23 review already flagged the same
|
||||||
|
pattern at the then-current `>= 200` and it was raised rather than replaced.
|
||||||
|
- **Effort:** M
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### [MED-02] Canonical invariant document declares a V4 baseline while the canonical baseline is V6.1
|
||||||
|
|
||||||
|
- **Location:** `docs/invariant/ai_evidence_and_provenance.md:130`
|
||||||
|
- **Reachability:** **Live** (documentation), no runtime impact.
|
||||||
|
- **Problem & Consequence:** Section 6.1 reads *"Canonical V4 architecture, schema, requirements,
|
||||||
|
and error-policy documents define how current behavior satisfies this invariant."*
|
||||||
|
`docs/index.md:1,29-32` establishes V6.1 as the baseline and states that every canonical document
|
||||||
|
asserts the same baseline. This is the **ownership clause of the invariant that governs the
|
||||||
|
entire evidence model** — the clause that tells a reader which documents are authoritative — and
|
||||||
|
it points at a superseded generation. A reader following it lands on stale authority precisely
|
||||||
|
when resolving an evidence question, which is the highest-stakes case.
|
||||||
|
|
||||||
|
A baseline-currency guard **does** exist —
|
||||||
|
`tests/test_meta_contract_guards.py::test_canonical_docs_declare_one_consistent_baseline`
|
||||||
|
(lines 89-112) — and `docs/invariant/ai_evidence_and_provenance.md` is **not** in
|
||||||
|
`BASELINE_SCAN_EXCLUSIONS` (lines 56-64), so the file is scanned. The claim escapes for two
|
||||||
|
independent reasons, either of which alone would be sufficient:
|
||||||
|
1. `_CURRENT_VERSION_CLAIM` (line 67) matches only the words `current` or `active` before a
|
||||||
|
version. This line says "**Canonical** V4", a third phrasing the pattern does not know.
|
||||||
|
2. Both patterns require `V(\d+\.\d+)` — a mandatory minor version. The bare token `V4` cannot
|
||||||
|
match either regex under any phrasing.
|
||||||
|
|
||||||
|
The guard is therefore not absent but *phrase-shaped*: it enforces currency only for the two
|
||||||
|
sentence forms someone thought of, against version strings that carry a minor. That is a weaker
|
||||||
|
property than its docstring implies ("Every canonical doc that names the current baseline must
|
||||||
|
name the same one").
|
||||||
|
- **Blast Radius:** Documentation only; no code reads this string. Widening the guard's patterns
|
||||||
|
will re-scan all canonical docs — expect it to surface further stale mentions on first run
|
||||||
|
(`docs/architecture.md`, `docs/schema.md`, `docs/requirements.md`, and `docs/error_handling.md`
|
||||||
|
each contain 2-3 version tokens), which should be triaged rather than excluded.
|
||||||
|
- **Recommendation:** Two parts, and the second matters more than the first.
|
||||||
|
1. Change "Canonical V4" to "Canonical V6.1" at line 130.
|
||||||
|
2. Fix the guard's shape rather than adding a third phrase to the list. Accept an optional minor
|
||||||
|
(`V(\d+)(?:\.(\d+))?`) and invert the matching: flag **every** `V<n>` token in a scanned
|
||||||
|
canonical doc that is not the declared baseline, rather than only those preceded by an
|
||||||
|
approved adjective. Phrase-list matching fails open — each new phrasing silently reopens the
|
||||||
|
hole — whereas token matching fails closed and forces an explicit exclusion.
|
||||||
|
|
||||||
|
See §8.1 for the alternative the maintainer is considering: dropping version labels from
|
||||||
|
canonical docs entirely, which removes the failure mode instead of guarding it.
|
||||||
|
- **Effort:** S
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### [MED-03] Settings resolve `.env.production` relative to the process working directory, and the isolation fix exists only in the test harness
|
||||||
|
|
||||||
|
- **Location:** `src/transcription/config.py:66-75` (`env_file=".env.production"`);
|
||||||
|
workaround at `tests/conftest.py:27-50`; guarded by `tests/test_config_isolation.py`;
|
||||||
|
depended on by `.github/workflows/quality-gate.yml` and `docker-compose.production.yml`
|
||||||
|
- **Reachability:** **Live but currently correct.** I verified the production path rather than
|
||||||
|
assuming it: `Dockerfile` sets `WORKDIR /app` in the runtime stage, and
|
||||||
|
`docker-compose.production.yml` mounts `./.env.production` to `/app/.env.production` for both the
|
||||||
|
`app` and `worker` services, so the relative path resolves correctly today.
|
||||||
|
- **Problem & Consequence:** Correct configuration loading depends on an **implicit, undocumented
|
||||||
|
contract between `config.py` and the process working directory.** Nothing in `config.py` states
|
||||||
|
it, and nothing tests it. The failure mode is not silent — `openrouter_api_key` is required with
|
||||||
|
no default, so a wrong cwd produces a `ValidationError` at startup rather than a partially
|
||||||
|
configured process — which is why this is Medium rather than High.
|
||||||
|
|
||||||
|
The more telling symptom is what the coupling forced on the test harness. `conftest.py:45-50`
|
||||||
|
cannot escape it by passing an argument; it must **mutate the Pydantic class-level
|
||||||
|
`model_config` dict at runtime** and restore it in a `finally`. That is a global, order-sensitive
|
||||||
|
side effect adopted because the module offers no seam. It also silently repairs a second
|
||||||
|
consumer: `ui/runtime_settings_store.py:402` reads the same `Settings.model_config["env_file"]`
|
||||||
|
to decide where the Settings page writes. Two subsystems are coupled through a mutable class
|
||||||
|
attribute.
|
||||||
|
- **Blast Radius:** Every `Settings` construction. Consumers of `model_config["env_file"]`:
|
||||||
|
`ui/runtime_settings_store.py:402` (write-target resolution, contract documented at
|
||||||
|
`docs/ui/pages/settings.md:27`) and `tests/conftest.py:45-50`. A change must preserve the
|
||||||
|
documented three-step resolution order — explicit override, `RUNTIME_SETTINGS_ENV_FILE`, then the
|
||||||
|
configured default — or `docs/ui/pages/settings.md:27` becomes wrong.
|
||||||
|
- **Recommendation:** Introduce one explicit resolution function that both `Settings` construction
|
||||||
|
and `runtime_settings_store` call, honoring an `ENV_FILE` environment variable and falling back
|
||||||
|
to a path anchored to a known root rather than to `os.getcwd()`. Tests then pass a path instead of
|
||||||
|
mutating class state, and `tests/test_config_isolation.py` can assert against the seam rather
|
||||||
|
than against the monkeypatch. If instead the cwd contract is accepted as deliberate, document it
|
||||||
|
in `config.py` and in `docs/production-runbook.md` and add a guard asserting `WORKDIR`/cwd
|
||||||
|
alignment — an implicit contract with a container image is exactly the kind of rule the invariant
|
||||||
|
routing table exists to place.
|
||||||
|
- **Effort:** M
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### [MED-04] Stale-job reclaim threshold is not derived from maximum job duration; safety currently comes from single-process sequencing
|
||||||
|
|
||||||
|
- **Location:** `src/transcription/config.py:116-117`
|
||||||
|
(`worker_provider_timeout_seconds=30.0`, `worker_stale_job_seconds=30.0`);
|
||||||
|
sweep at `src/transcription/worker.py:222-228`; reclaim at
|
||||||
|
`src/transcription/services/jobs.py:242-268`
|
||||||
|
- **Reachability:** **Latent.** Unblocked by *either* of: (a) running more than one worker replica
|
||||||
|
(adding `deploy.replicas > 1` to the `worker` service in `docker-compose.production.yml`), or
|
||||||
|
(b) setting `RUN_EMBEDDED_WORKER=true` on the `app` service while the standalone `worker`
|
||||||
|
container is also running. It is safe today only because
|
||||||
|
`docker-compose.production.yml` sets `RUN_EMBEDDED_WORKER: "false"` on `app` and defines exactly
|
||||||
|
one `worker`, and because within a single loop `run_worker_loop` awaits
|
||||||
|
`process_next_queued_job` to completion before returning to the stale sweep — so the sweep can
|
||||||
|
never observe a job that this same process is actively working.
|
||||||
|
- **Problem & Consequence:** The stale threshold (30s) **equals** the per-page provider timeout
|
||||||
|
(30s), leaving zero margin even for a single-page job. A multi-page document is legitimately
|
||||||
|
`PROCESSING` for up to N × 30s. `Job.date_updated` carries an `onupdate`
|
||||||
|
(`db/models.py:372-375`), but between the initial claim and the terminal write the only touch
|
||||||
|
is `sources.py:522-523` reassigning `job.provider`/`job.model` to values they usually already
|
||||||
|
hold, which SQLAlchemy resolves to no net change and therefore no `UPDATE`. I did not empirically
|
||||||
|
confirm the no-`UPDATE` behavior, so treat that specific step as unverified — but the finding does
|
||||||
|
not depend on it, because even a per-page refresh leaves only a 30s margin against a 30s timeout.
|
||||||
|
|
||||||
|
With a second concurrent worker, the sweep would requeue a job that is mid-provider-call. Both
|
||||||
|
workers then process the same job, producing duplicate `ExecutionAttempt` rows for the same
|
||||||
|
logical work and racing terminal status writes. Append-only history would be *preserved* but no
|
||||||
|
longer *faithful*: the evidence would show attempts that do not correspond to distinct
|
||||||
|
application decisions.
|
||||||
|
|
||||||
|
This is worth flagging because `jobs.py:191-197` explicitly implements and documents
|
||||||
|
`SKIP LOCKED` row locking "so concurrent workers never contend for the same job." The claim path
|
||||||
|
is built for multi-worker operation; the reclaim path is not. A reader who trusts the claim
|
||||||
|
docstring would reasonably scale the worker.
|
||||||
|
- **Blast Radius:** `requeue_stale_processing_jobs` has one production caller (`worker.py:226`) and
|
||||||
|
tests in `tests/test_worker.py` and `tests/services/test_job_service.py`. Changing the *default*
|
||||||
|
affects `tests/test_config.py` declared-defaults assertions — check those before editing the default.
|
||||||
|
- **Recommendation:** **Fix before adding a second worker replica, not after.** Two parts:
|
||||||
|
(1) Make the threshold a function of the real bound rather than a coincidental peer of the
|
||||||
|
page timeout — at minimum default `worker_stale_job_seconds` to a multiple of
|
||||||
|
`worker_provider_timeout_seconds` with headroom, and add a model validator rejecting a stale
|
||||||
|
threshold at or below the provider timeout.
|
||||||
|
(2) Preferably make reclaim heartbeat-based: have `_persist_page_outcome` bump `Job.date_updated`
|
||||||
|
explicitly so liveness reflects progress rather than elapsed time since claim.
|
||||||
|
Add a guard asserting a multi-page job in flight is not reclaimed by a concurrently-invoked sweep.
|
||||||
|
- **Effort:** M
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### [MED-05] Provider evidence capture is per-instance mutable state, making the adapter non-reentrant by contract
|
||||||
|
|
||||||
|
- **Location:** `src/transcription/providers/openrouter.py:197-199, 264-267, 274-275, 297-298, 397-412`;
|
||||||
|
`_CapturingAsyncClient.last_response`/`last_body` at `openrouter.py:66-94`;
|
||||||
|
contract at `src/transcription/providers/base.py:110-118`
|
||||||
|
(`current_request_manifest`, `current_transport_evidence`)
|
||||||
|
- **Reachability:** **Latent.** Unblocked by any concurrent `transcribe()` on a single adapter
|
||||||
|
instance — most plausibly by processing a job's pages in parallel (`workflows.py:274` is
|
||||||
|
currently a sequential `for` loop) or by any second consumer sharing one
|
||||||
|
`SourceService.provider`. Verified safe today: `workflows.py:272` resolves one provider for the
|
||||||
|
loop and awaits each page; the worker's `ServiceBundle` (`worker.py:206`) is distinct from
|
||||||
|
`app.state.services` (`app.py:43`), so the UI cannot share the worker's adapter instance, and
|
||||||
|
the UI only enqueues jobs (`ui/pages/jobs_page.py:186-208`).
|
||||||
|
- **Problem & Consequence:** The `TranscriptionProvider` protocol defines evidence retrieval as
|
||||||
|
"the most recent call" state read *after* the fact. `workflows.py:369-370` relies on this on the
|
||||||
|
timeout path, reading `provider.current_request_manifest` / `current_transport_evidence` when no
|
||||||
|
result object exists. Under concurrency, page B's response overwrites
|
||||||
|
`_CapturingAsyncClient.last_response` before page A's timeout handler reads it, and page A's
|
||||||
|
`ExecutionAttempt` is written with page B's transport evidence.
|
||||||
|
|
||||||
|
The consequence is **evidence mis-attribution** — a provenance-integrity failure, which this
|
||||||
|
project's own rubric treats as its most serious class. It would also be near-undetectable after
|
||||||
|
the fact: the attempt row would be well-formed, internally consistent, and wrong. The
|
||||||
|
application-level design that makes this safe (sequential pages) is not expressed in the
|
||||||
|
provider contract, so the constraint lives only in `workflows.py`'s loop structure.
|
||||||
|
- **Blast Radius:** Changing the protocol touches `providers/base.py:102-136`,
|
||||||
|
`providers/openrouter.py:221-231`, the two read sites at `workflows.py:369-370`, and the fakes in
|
||||||
|
`tests/providers/test_openrouter.py`, `tests/services/test_workflows_reliability.py`, and
|
||||||
|
`tests/test_provider_boundaries.py`, all of which implement or assert the current property-based
|
||||||
|
contract.
|
||||||
|
- **Recommendation:** **Fix before introducing any intra-job page concurrency.** The durable fix is
|
||||||
|
to stop returning evidence through instance state: attach `request_manifest` and
|
||||||
|
`transport_evidence` to the raised exception on every failure path — which `ProviderError`
|
||||||
|
already supports (`providers/base.py:18-29`) and which the timeout path cannot currently use
|
||||||
|
because `asyncio.wait_for` raises `TimeoutError` from outside the adapter. A narrower option is
|
||||||
|
to have `transcribe()` accept a caller-owned capture sink so evidence is scoped to the call
|
||||||
|
rather than to the adapter. As an immediate, near-zero-cost step, document the non-reentrancy on
|
||||||
|
the protocol in `providers/base.py` so the constraint is visible where it is depended upon.
|
||||||
|
- **Effort:** M
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### [MED-06] Provider error bodies reach user-facing text while three provider failure paths persist no `detail`
|
||||||
|
|
||||||
|
- **Location:** `src/transcription/services/sources.py:923-947` (`handle_transcription_errors`);
|
||||||
|
message construction at `src/transcription/providers/openrouter.py:414-431`
|
||||||
|
(`_transport_error_message`)
|
||||||
|
- **Reachability:** **Live** for the message half (any provider failure during a UI-initiated
|
||||||
|
transcription surfaces through `show_error`).
|
||||||
|
- **Problem & Consequence:** Two mirrored halves of the same rule are broken in one function.
|
||||||
|
- `sources.py:943` builds `f"Provider transcription failed: {exc}"`, and `exc` is a
|
||||||
|
`ProviderError` whose message may embed up to 500 characters of the provider's error body
|
||||||
|
(`openrouter.py:430`). That is a provider payload in `message`, which
|
||||||
|
`error-handling.instructions.md:85` explicitly forbids.
|
||||||
|
- None of the three handlers (lines 929, 935, 942) passes `detail=`. Per
|
||||||
|
`error-handling.instructions.md:89-92`, omitting it degrades the provenance record.
|
||||||
|
|
||||||
|
I checked whether the provenance half is actually harmful before reporting it, and it is
|
||||||
|
**substantially mitigated**: `workflows.py:391` calls `_find_provider_error`, which walks
|
||||||
|
`__cause__`/`__context__` (`workflows.py:806-813`) to recover the original `ProviderError` and
|
||||||
|
persists its `transport_evidence` — status code, safe headers, and the exact response body — onto
|
||||||
|
the attempt. So the root cause is preserved in transport evidence even though `error_detail` is
|
||||||
|
thin. This is why the finding is Medium rather than High. The residual cost is that the
|
||||||
|
human-readable failure summary is uninformative for the two paths (`ProviderAuthError`,
|
||||||
|
`ProviderResponseError`) whose messages are entirely generic.
|
||||||
|
- **Blast Radius:** `handle_transcription_errors` is used on the transcription path in
|
||||||
|
`sources.py`; `TranscriptionError.message` is consumed by `error_presenter.show_error`,
|
||||||
|
`build_error_envelope`, and `format_error_detail`. Assertions on these messages live in
|
||||||
|
`tests/services/test_transcription_service.py` and `tests/providers/test_openrouter.py`.
|
||||||
|
- **Recommendation:** Move the interpolated provider text from `message` to `detail` on all three
|
||||||
|
handlers, keeping the generic message the other two already use:
|
||||||
|
```python
|
||||||
|
except ProviderError as exc:
|
||||||
|
raise TranscriptionError(
|
||||||
|
"Provider transcription failed",
|
||||||
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
|
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
||||||
|
retriable=True,
|
||||||
|
detail=f"{type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
```
|
||||||
|
Apply the same `detail=` addition to the `ProviderAuthError` and `ProviderResponseError`
|
||||||
|
handlers. Note the interaction with HIGH-01: until the render boundary is sanitized, moving text
|
||||||
|
into `detail` still reaches users through the `error_detail` display. Sequence accordingly.
|
||||||
|
- **Effort:** S
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### [MED-07] No deterministic guard covers the `message`/`detail` split at explicit raise sites
|
||||||
|
|
||||||
|
- **Location:** `tests/test_errors.py:56-78`; rule at
|
||||||
|
`.github/instructions/error-handling.instructions.md:78-98`; canonical statement at
|
||||||
|
`docs/error_handling.md:102-115`
|
||||||
|
- **Reachability:** **Live** — this coverage gap is what allowed HIGH-02 and MED-06 to exist in a
|
||||||
|
fully green suite.
|
||||||
|
- **Problem & Consequence:** `docs/error_handling.md:115` names
|
||||||
|
`tests/test_errors.py::test_unexpected_error_does_not_leak_filesystem_paths` as the enforcement
|
||||||
|
for the message/detail split. That test exercises exactly one function,
|
||||||
|
`classify_unexpected_error`. Every direct `raise SomeAppError(...)` in `src/` — roughly 50 sites
|
||||||
|
by grep — is unenforced. The documentation therefore overstates the enforcement, which is worse
|
||||||
|
than having no guard: a contributor reading `error_handling.md:115` reasonably concludes the rule
|
||||||
|
is mechanically protected.
|
||||||
|
|
||||||
|
Per the reviewer skill, where a check is unenforced, recommending the deterministic test is
|
||||||
|
itself a finding.
|
||||||
|
- **Blast Radius:** Tests only.
|
||||||
|
- **Recommendation:** Add an AST guard, `tests/test_error_message_safety.py`, that scans `src/`
|
||||||
|
for `raise <AppError subclass>(...)` and fails when the first positional argument is an f-string
|
||||||
|
containing a formatted value whose name matches a path-like or exception-like identifier
|
||||||
|
(`path`, `_path`, `root`, `dir`, `exc`, `err`, `e`). Model it on the existing AST guards, which
|
||||||
|
are the established pattern here (`test_ui_boundaries.py`, `test_service_boundaries.py`,
|
||||||
|
`test_orphan_sweep.py`). Pair it with a second guard asserting that no UI module reads
|
||||||
|
`error_detail` without routing through the sanitizing projection from HIGH-01 — that one closes
|
||||||
|
the render side, which is where the real leak is.
|
||||||
|
- **Effort:** M
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Low Severity
|
||||||
|
|
||||||
|
#### [LOW-01] `ui/pages/tags_page.py` is dead code; the V6.1 roadmap is correct
|
||||||
|
|
||||||
|
- **Location:** `src/transcription/ui/pages/tags_page.py` (93 lines);
|
||||||
|
registration list at `src/transcription/ui/__init__.py:37-43`
|
||||||
|
- **Reachability:** **Not reachable.** This resolves the flagged open item: the route is genuinely
|
||||||
|
**not** registered. `register_pages` calls seven page registrars and `tags_page` is not among
|
||||||
|
them; nothing anywhere imports the module. `docs/roadmap_plan.md:47` ("Retire the Tags page") is
|
||||||
|
accurate, and `tests/ui/test_tags_page.py` correctly asserts `/ui/tags` returns 404 — though it
|
||||||
|
passes trivially, since an unimported module cannot register anything.
|
||||||
|
- **Problem & Consequence:** No runtime risk; purely stranded code. It is worth noting that if it
|
||||||
|
*were* ever re-registered, `tags_page.py:71-74` traverses `document.document_tags` and
|
||||||
|
`link.tag_ref` inside a page render, and those relationships are configured `lazy="raise"`
|
||||||
|
(`docs/architecture.md:200-203`) — so re-enabling this module without adding eager loads to
|
||||||
|
`list_documents` would raise on first render.
|
||||||
|
- **Recommendation:** Delete `src/transcription/ui/pages/tags_page.py`. Retain
|
||||||
|
`tests/ui/test_tags_page.py` as the retirement guard. Fixing MED-01 first would make this
|
||||||
|
finding reproducible by the suite rather than by manual inspection.
|
||||||
|
- **Effort:** S
|
||||||
|
|
||||||
|
#### [LOW-02] `benchmarking.py` ships in the runtime package but is referenced only by tests
|
||||||
|
|
||||||
|
- **Location:** `src/transcription/benchmarking.py` (69 lines); sole consumers
|
||||||
|
`tests/test_v42_evidence.py:15-16` (`EditorialAssessment`, `score_transcription`)
|
||||||
|
- **Reachability:** Live as importable API; never invoked by application code.
|
||||||
|
- **Problem & Consequence:** No defect. It supports the model-evaluation policy in
|
||||||
|
`docs/invariant/ai_evidence_and_provenance.md:113-126`, which is legitimate, but it currently has
|
||||||
|
no production caller and no tooling entrypoint, so it is indistinguishable from drift.
|
||||||
|
- **Recommendation:** Either move it under `tools/` alongside the other operator utilities, or add
|
||||||
|
a `KNOWN_ORPHANS`-style rationale recording that it is retained as the evaluation-policy
|
||||||
|
implementation. Do not silently keep it unlabeled.
|
||||||
|
- **Effort:** S
|
||||||
|
|
||||||
|
#### [LOW-03] Two overlapping prompt error types split across modules
|
||||||
|
|
||||||
|
- **Location:** `src/transcription/services/errors.py:15-16` (`PromptLoadError`) and
|
||||||
|
`src/transcription/services/prompts.py:19` (`PromptStoreError`)
|
||||||
|
- **Reachability:** Live; no misbehavior observed.
|
||||||
|
- **Problem & Consequence:** `services/errors.py:1-8` documents itself as the neutral home for
|
||||||
|
exceptions raised by more than one service, precisely so a caller's `except` clause does not
|
||||||
|
change when an operation moves. `PromptStoreError` is defined outside that module and covers an
|
||||||
|
overlapping domain (prompt file access), so a caller wanting to handle "any prompt failure" must
|
||||||
|
import from two modules and know which is which. `sources.py:855` raises `PromptLoadError` for a
|
||||||
|
missing prompt file while `prompts.py:132` raises `PromptStoreError` for the same condition
|
||||||
|
reached through the Settings page.
|
||||||
|
- **Recommendation:** Move `PromptStoreError` into `services/errors.py` next to `PromptLoadError`,
|
||||||
|
or make one a subclass of the other so a single `except` covers prompt failures. Low urgency; do
|
||||||
|
it opportunistically when HIGH-02 touches both files anyway.
|
||||||
|
- **Effort:** S
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architectural Drift & Gap Analysis
|
||||||
|
|
||||||
|
| Area / Component | Direction | Documented / Intended Rule | Actual Implementation State | Severity | Recommended Resolution |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| Error presentation | `doc->code` | `docs/error_handling.md:110` — `detail` is internal only, surfaced by `format_error_detail` and logs | `sources_page.py:484`, `table/sources.py:90`, `settings_page.py:562` render `error_detail` verbatim to users | High | Sanitizing render projection (HIGH-01); do **not** strip `detail` |
|
||||||
|
| User-facing messages | `doc->code` | `invariant/error_handling.md:59` — no local filesystem detail in user-facing messages | 5 live sites interpolate absolute paths into `AppError.message` | High | Generic `message`, path on `detail` (HIGH-02) |
|
||||||
|
| Evidence invariant ownership | `doc->doc` | `docs/index.md:1` — baseline is V6.1 | `invariant/ai_evidence_and_provenance.md:130` names "Canonical V4"; the currency guard scans the file but its regexes match neither the phrasing nor a minor-less `V4` | Medium | Update text; make the guard token-based, or drop version labels entirely (MED-02, §8.1) |
|
||||||
|
| Enforcement claim | `doc->code` | `docs/error_handling.md:115` — split "Enforced by `tests/test_errors.py::…`" | That test covers only `classify_unexpected_error`; explicit raises unguarded | Medium | Add AST guard (MED-07) |
|
||||||
|
| Orphan sweep | `doc->code` | `test_orphan_sweep.py:1-13` — sweep is "deterministic" and "conservative" | Bare-name matching; cannot see a dead module (`tags_page.py`) | Medium | Qualify by module + module-reachability pass (MED-01) |
|
||||||
|
| Worker scaling | `code->doc` | `jobs.py:191-197` — `SKIP LOCKED` so "concurrent workers never contend" | Claim path is multi-worker-safe; stale-reclaim path is not | Medium | Derive stale threshold from job duration; document single-worker constraint until fixed (MED-04) |
|
||||||
|
| Provider adapter contract | `code->doc` | `providers/base.py:110-118` — evidence read as "most recent call" state | Contract is silently non-reentrant; safety lives in `workflows.py`'s sequential loop | Medium | Scope evidence to the call; document non-reentrancy (MED-05) |
|
||||||
|
| Settings env file | `code->doc` | `config.py:66-75` — `env_file=".env.production"` | Correctness depends on an undocumented cwd contract with `Dockerfile` `WORKDIR /app` | Medium | Explicit resolver seam, or document + guard the contract (MED-03) |
|
||||||
|
| Tags page | *(no drift)* | `roadmap_plan.md:47` — Tags page retired | Route genuinely unregistered; module is stranded code | Low | Delete the module (LOW-01) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Invariant Inventory & Routing Recommendations
|
||||||
|
|
||||||
|
| Invariant / Constraint | Current Location | Recommended Target Layer | Rationale |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `detail`/`error_detail` never rendered to users | docs + instructions | **Deterministic test** + sanitizing projection | Stated in three documents and violated in three files; prose has demonstrably failed to hold it |
|
||||||
|
| `message` carries no paths or exception text | instructions; partial test | **Deterministic test** (AST, all raise sites) | Existing guard covers one function; the gap produced HIGH-02 |
|
||||||
|
| `ExecutionAttempt.error_detail` retains root cause | docs + `test_v42_evidence.py` | **Keep in tests** — already correct | Counterweight to the above; must be named in any fix so it is not over-corrected |
|
||||||
|
| Intermediate pages commit individually | `workflows.py` docstring + `test_workflows_reliability.py:318` | **Keep in tests** — verified genuine | Cross-session read makes it a real durability assertion |
|
||||||
|
| Final page atomic with terminal status | `services.instructions.md` + `test_pipeline_atomicity.py` | **Keep in tests** — verified genuine | Fault injection makes a split commit fail |
|
||||||
|
| Canonical baseline version consistency | `docs/index.md` + `test_meta_contract_guards.py:89` | **Repair existing test, or remove the labels** | Guard exists but matches by approved phrase and requires a minor version, so it fails open on new phrasings (MED-02) |
|
||||||
|
| Module-level reachability / dead modules | `test_orphan_sweep.py` (ineffective) | **Deterministic test** (repair existing) | Guard exists but cannot detect the case (MED-01) |
|
||||||
|
| Stale threshold > max job duration | *(unenforced)* | **Config validator + test** | Currently a coincidence of two equal defaults (MED-04) |
|
||||||
|
| Provider adapter non-reentrancy | *(unenforced, implicit)* | **Instructions** + protocol docstring | A design constraint callers must know before adding concurrency (MED-05) |
|
||||||
|
| Env-file resolution independent of cwd | `tests/conftest.py` monkeypatch | **Code seam** + `docs/production-runbook.md` | A test-only fix for a production coupling is misrouted enforcement (MED-03) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Stack-Specific Analysis
|
||||||
|
|
||||||
|
**Python 3.12+.** Modern and consistent. PEP 695 generics are used correctly and non-trivially
|
||||||
|
(`RegistryService[ModelT: RegistryEntry]` in `services/registry.py:58`, `UiActionOutcome[T]`,
|
||||||
|
`_get_or_raise[ModelT]`), `type` statements appear in `db/session.py:15,50`, and `X | None` is
|
||||||
|
used throughout. `structural Protocol` bounds (`RegistryEntry`, `WorkerNotifier`,
|
||||||
|
`TranscriptionProvider`) are used to avoid type suppressions rather than to decorate. `ty` passes
|
||||||
|
clean with no suppressions found. The two `# noqa` uses (`workflows.py:228` `PLR0915`,
|
||||||
|
`workflows.py:383` `BLE001`) are both justified in context — the broad catch is a deliberate
|
||||||
|
per-page containment boundary that immediately classifies and re-records.
|
||||||
|
|
||||||
|
**FastAPI.** Lifespan is handled via `@asynccontextmanager` (`app.py:36`), not the deprecated
|
||||||
|
`@app.on_event`. Session factories are injected through `Depends` (`SessionFactoryDep`,
|
||||||
|
`db/session.py:50`) rather than reached as globals from routes. `api/errors.py` centralizes
|
||||||
|
envelope translation. One residual: `get_settings` is `@cache`d and read as a module-level
|
||||||
|
fallback in ~10 modules; this is acceptable given the documented restart-to-apply contract
|
||||||
|
(`docs/ui/pages/settings.md:28`) but means the cache is process-lifetime and unclearable.
|
||||||
|
|
||||||
|
**NiceGUI (pinned `3.13.0`).** The pin is a recorded release-stability decision and is not
|
||||||
|
reported as a defect. Boundaries are enforced structurally: `test_ui_boundaries.py` uses an
|
||||||
|
import **allowlist**, which is the right polarity. Blocking work is dispatched off the event loop
|
||||||
|
via `run_blocking` (`settings_page.py:818,822`). The one boundary that is *not* enforced is
|
||||||
|
presentation of internal fields (HIGH-01) — pages are prevented from touching persistence but not
|
||||||
|
from rendering internal-only text.
|
||||||
|
|
||||||
|
**SQLModel / SQLAlchemy.** Strong. `lazy="raise"` on relationships forces explicit eager loading;
|
||||||
|
read paths declare `selectinload` chains with comments explaining *why* each is needed
|
||||||
|
(`sources.py:309-316` is a good example). `expire_on_commit=False` (`db/session.py:28`) is set
|
||||||
|
deliberately, which is what makes post-commit attribute access in `evidence.py:159-202` safe.
|
||||||
|
`claim_next_queued_job` (`jobs.py:186-241`) branches correctly on dialect — `SKIP LOCKED` on
|
||||||
|
PostgreSQL, conditional `UPDATE ... RETURNING` on SQLite — rather than assuming one engine.
|
||||||
|
Attempt-number allocation uses `begin_nested()` with bounded retry (`sources.py:598-616`), the
|
||||||
|
right pattern for a monotonic per-parent sequence. No N+1 patterns were found in the read paths
|
||||||
|
sampled.
|
||||||
|
|
||||||
|
**Pydantic V2 & Settings.** Fully V2; no `@validator`, `class Config`, `.dict()`, or `parse_obj`
|
||||||
|
anywhere. Evidence contracts use `ConfigDict(extra="forbid", frozen=True)` (`providers/evidence.py:47`),
|
||||||
|
which is exactly right for persisted provenance — an unexpected field fails loudly rather than
|
||||||
|
being silently dropped. `SecretStr` guards the API key. The discriminated
|
||||||
|
`SqliteSettings | PostgresSettings` union is clean. `normalize_provider_models` correctly runs
|
||||||
|
`mode="before"` so the derived tuple is produced by construction rather than by mutating a frozen
|
||||||
|
model — a subtlety that is easy to get wrong. Sole issue: the cwd-coupled `env_file` (MED-03).
|
||||||
|
|
||||||
|
**Asyncio Workers.** Notably careful. `asyncio.shield` wraps both the per-page commit and the
|
||||||
|
terminal commit (`workflows.py:601-614`, `650-663`), with the `except CancelledError: await task;
|
||||||
|
raise` pattern that actually completes the shielded work rather than merely deferring cancellation —
|
||||||
|
a detail most implementations get wrong. `handle_worker_exceptions` (`worker.py:157-182`)
|
||||||
|
distinguishes retriable from non-retriable faults and stops the loop rather than spinning.
|
||||||
|
`_advance_job_with_containment` (`worker.py`/`workflows.py:507-540`) guarantees a claimed job
|
||||||
|
cannot strand in `PROCESSING`. `worker_consumer_lifespan` has a bounded shutdown with escalation to
|
||||||
|
`cancel()`. Gaps are MED-04 and MED-05, both latent and both with stated unblocking conditions.
|
||||||
|
|
||||||
|
**OpenRouter / Adapter Boundary.** Encapsulation holds: `test_provider_boundaries.py` enforces it,
|
||||||
|
and `workflows.py` imports only `providers` abstractions. `_CapturingAsyncClient` is a
|
||||||
|
well-judged design — it captures the exact transport body before SDK parsing without altering what
|
||||||
|
the SDK consumes, including the streamed case. Timeout construction (`openrouter.py:200-206`)
|
||||||
|
correctly overrides httpx's 5s per-phase default that would otherwise silently cap the configured
|
||||||
|
budget. `SAFE_RESPONSE_HEADERS` (`providers/evidence.py:29-41`) was reviewed field-by-field:
|
||||||
|
all nine entries are non-secret correlation, content, or rate-limit headers, and
|
||||||
|
`filter_safe_response_headers` is a true allowlist filter with no redaction-after-capture — this
|
||||||
|
satisfies invariant 3.8.2 exactly. `_replace_embedded_media` correctly substitutes a source
|
||||||
|
reference for base64 payloads, satisfying 3.8.3. The one structural weakness is MED-05.
|
||||||
|
|
||||||
|
**Testing & Quality Tooling.** 410 tests, all green, with genuinely strong contract guards
|
||||||
|
(boundaries, model contract, media path safety, evidence append-only, atomicity). Marker strictness
|
||||||
|
and `asyncio_mode = "strict"` are configured, and no unawaited-coroutine warnings appeared. Two
|
||||||
|
guards, however, assert meaningfully less than their names and docstrings claim
|
||||||
|
(`test_orphan_sweep.py` — MED-01; `test_errors.py` path-leak coverage — MED-07), and the
|
||||||
|
`>= 420` snapshot threshold at `test_orphan_sweep.py:169` repeats a weak-assertion pattern the
|
||||||
|
2026-08-23 review already flagged at `>= 200`; it was raised rather than replaced with
|
||||||
|
set-membership.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Duplication & Consolidation Report
|
||||||
|
|
||||||
|
| Pattern / Duplication | Locations | Proposed Canonical Home | Estimated Lines Removed |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| `f"{type(exc).__name__}: {exc}"` detail construction | `errors.py:118`, `maintenance.py:71,95,135,206`, `runtime_settings_store.py:388,476,554` | `errors.py::exception_detail(exc)` | ~8 (consistency > line count) |
|
||||||
|
| Filesystem `AppError` construction from `OSError` | `prompts.py:186-191`, `runtime_settings_store.py:384-389,472-477,550-555` | `errors.py::filesystem_error(message, exc, *, suggestion)` | ~20 |
|
||||||
|
| Overlapping prompt error types | `services/errors.py:15`, `services/prompts.py:19` | `services/errors.py` (LOW-03) | ~5 |
|
||||||
|
| Duplicated `provider_duration_ms` / `processing_duration_ms` max-clamp arithmetic | `workflows.py:341-345, 361-368, 397-404` | `workflows.py::_page_durations(started_at, finished_at, monotonic_started_at)` | ~20 |
|
||||||
|
| `_utc_now_naive` defined per module | `workflows.py:51`, `jobs.py:29`, `db/models.py`, `sources.py` | Single helper in `db/models.py`, imported | ~12 |
|
||||||
|
|
||||||
|
### Proposed Canonical Abstractions
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/transcription/errors.py
|
||||||
|
def exception_detail(exc: BaseException) -> str:
|
||||||
|
"""Internal-only root-cause text for AppError.detail. Never user-facing."""
|
||||||
|
|
||||||
|
|
||||||
|
def filesystem_error[E: AppError](error_type: type[E], message: str, exc: OSError, *, suggestion: str) -> E:
|
||||||
|
"""Build a filesystem AppError with a generic message and the path on detail."""
|
||||||
|
|
||||||
|
|
||||||
|
# src/transcription/ui/components/error_presenter.py
|
||||||
|
def display_failure_detail(error_detail: str | None) -> str | None:
|
||||||
|
"""Sanitize persisted failure detail for UI rendering (HIGH-01)."""
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Meta-Tooling & Instruction Update Recommendations
|
||||||
|
|
||||||
|
1. **`docs/invariant/ai_evidence_and_provenance.md:130`** — resolve the V4 label. Two viable
|
||||||
|
routes, and the maintainer has proposed the second:
|
||||||
|
- **(a) Repair the guard.** Fix the text to V6.1 and make
|
||||||
|
`test_canonical_docs_declare_one_consistent_baseline` token-based rather than phrase-based
|
||||||
|
(MED-02). Keeps version labels as navigational anchors.
|
||||||
|
- **(b) Remove version labels from canonical docs.** While the project has a single principal
|
||||||
|
user and no released versions to support, "canonical" and "current" are the same thing, so the
|
||||||
|
label carries no information a reader can act on — it only creates a second thing to keep in
|
||||||
|
sync. Retain the baseline declaration in `docs/index.md` alone as the release marker, keep
|
||||||
|
version language in `docs/roadmap_plan.md` and the migration/deployment docs (already
|
||||||
|
excluded from the scan for exactly this reason), and replace in-body references with
|
||||||
|
unversioned phrasing ("the canonical architecture, schema, requirements, and error-policy
|
||||||
|
documents"). The guard then inverts: assert that no canonical doc outside the exclusion set
|
||||||
|
contains a version token at all, which is a stricter and much cheaper property to hold than
|
||||||
|
agreement between many labels. Requirement IDs (`REQ-4-021`, `REQ-6-014`) are stable
|
||||||
|
identifiers, not currency claims, and should be left alone.
|
||||||
|
2. **`docs/error_handling.md:107-115`** — either add the sanitizing-projection rule for UI display
|
||||||
|
of `error_detail`, or revise the `detail` "Surfaces" row to admit operator-facing evidence
|
||||||
|
displays. Update the "Enforced by" line once MED-07's guard lands, since it currently overstates
|
||||||
|
coverage.
|
||||||
|
3. **`.github/instructions/error-handling.instructions.md`** — add an explicit clause under
|
||||||
|
"User-Safe Messaging" stating that *persisted* `error_detail` is subject to the same no-paths
|
||||||
|
rule at any render boundary. The current table (line 86) states the rule for `AppError.detail`
|
||||||
|
and stops there, so the persisted-then-rendered path falls between the lines.
|
||||||
|
4. **`.github/instructions/providers.instructions.md`** — record the adapter non-reentrancy
|
||||||
|
constraint (MED-05); it is currently an undocumented precondition of `workflows.py`.
|
||||||
|
5. **`.github/instructions/services.instructions.md`** — the two competing atomicity invariants are
|
||||||
|
well described and both guards verified; no change needed. Worth adding the stale-reclaim
|
||||||
|
threshold constraint (MED-04) alongside them, since it is a third worker-lifecycle rule with no
|
||||||
|
documented home.
|
||||||
|
6. **`tests/test_orphan_sweep.py`** — repair per MED-01 and replace the `>= 420` threshold with
|
||||||
|
set-membership assertions.
|
||||||
|
7. **New `tests/test_error_message_safety.py`** — AST guard per MED-07, covering both the raise
|
||||||
|
sites and the UI render sites.
|
||||||
|
8. **`docs/production-runbook.md`** — document the cwd/`WORKDIR` contract for `.env.production`
|
||||||
|
resolution if MED-03 is resolved by documentation rather than by a code seam.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Prioritized Dependency-Ordered Action Plan
|
||||||
|
|
||||||
|
**Phase 1 — Blocking fixes (privacy; ordered, HIGH-01 first)**
|
||||||
|
1. **HIGH-01** — add `display_failure_detail` and route `sources_page.py:484`,
|
||||||
|
`table/sources.py:90-95`, and `settings_page.py:562` through it. Do this **first**: it closes
|
||||||
|
the render boundary, so the Phase-1.2 fix cannot relocate a leak again.
|
||||||
|
2. **HIGH-02** — move paths from `message` to `detail` at the five sites, including the
|
||||||
|
`prompts.py:186` double violation.
|
||||||
|
3. **MED-06** — move provider payload text to `detail`; add `detail=` to all three
|
||||||
|
`handle_transcription_errors` handlers.
|
||||||
|
|
||||||
|
**Phase 2 — Enforcement hardening (make Phase 1 permanent)**
|
||||||
|
4. **MED-07** — AST guard for raise-site `message` safety **and** for UI reads of `error_detail`.
|
||||||
|
5. **MED-01** — qualify orphan definitions by module; add module-reachability; replace the
|
||||||
|
snapshot threshold.
|
||||||
|
6. **MED-02** — fix the V4/V6.1 text and extend the meta-contract guard to baseline-version currency.
|
||||||
|
|
||||||
|
**Phase 3 — Reliability & concurrency (latent; each must precede its unblocking change)**
|
||||||
|
7. **MED-04** — derive `worker_stale_job_seconds` from `worker_provider_timeout_seconds` with a
|
||||||
|
rejecting validator, ideally plus a progress heartbeat. **Must land before any second worker
|
||||||
|
replica.**
|
||||||
|
8. **MED-05** — scope provider evidence to the call rather than the instance. **Must land before
|
||||||
|
any intra-job page concurrency.** Document non-reentrancy immediately as an interim step.
|
||||||
|
|
||||||
|
**Phase 4 — Consolidation & refactoring**
|
||||||
|
9. **MED-03** — explicit env-file resolution seam shared by `Settings` and `runtime_settings_store`;
|
||||||
|
remove the `model_config` monkeypatch from `conftest.py`.
|
||||||
|
10. **LOW-01** — delete `tags_page.py` (after MED-01, so the suite reproduces the finding).
|
||||||
|
11. **LOW-03** and the §7 consolidations — fold in opportunistically while Phase 1 touches these files.
|
||||||
|
|
||||||
|
**Phase 5 — Non-blocking governance/documentation depth**
|
||||||
|
12. **LOW-02** — relocate or annotate `benchmarking.py`.
|
||||||
|
13. Instruction/doc updates §8.3–§8.5, §8.8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Preserved Strengths
|
||||||
|
|
||||||
|
- **Append-only evidence is real, not aspirational.** Every provider call produces a distinct
|
||||||
|
`ExecutionAttempt`; no runtime path mutates a historical row. Projection writes onto
|
||||||
|
`Source.raw_transcription` are clearly separated from history, and `promote_machine_attempt`
|
||||||
|
(`evidence.py:117-146`) repoints the projection without rewriting evidence — with a docstring
|
||||||
|
that explains exactly why that one write lives in a read-oriented service.
|
||||||
|
- **Transport-layer terminology is honored in code.** `_CapturingAsyncClient` exists specifically so
|
||||||
|
the stored body is the application-boundary capture rather than an SDK-parsed object, and
|
||||||
|
`TransportEvidence(response_received=False)` explicitly represents "no response" instead of
|
||||||
|
conflating it with an empty one. This is invariant 3.4/3.5 implemented rather than asserted.
|
||||||
|
- **Header allowlisting is done the hard, correct way** — filter-before-store with an explicit
|
||||||
|
frozenset, never capture-then-redact (`providers/evidence.py:29-41,130-134`).
|
||||||
|
- **Boundaries are enforced by allowlist, not blocklist.** `test_ui_boundaries.py:20-25` states the
|
||||||
|
reasoning explicitly; it means a newly added persistence helper cannot slip through under an
|
||||||
|
unlisted name.
|
||||||
|
- **The two competing atomicity invariants are both correctly implemented and both genuinely
|
||||||
|
guarded**, with the tests structured so that the naive over-correction fails.
|
||||||
|
- **Cancellation safety in the worker is unusually well handled** — `asyncio.shield` plus
|
||||||
|
`await task` on `CancelledError` actually completes the commit rather than merely deferring
|
||||||
|
cancellation.
|
||||||
|
- **Comments explain rationale, not mechanics.** `workflows.py:269-271`, `openrouter.py:200-202`,
|
||||||
|
`config.py:114-115`, and `jobs.py:191-197` each record *why* a non-obvious choice was made,
|
||||||
|
several citing the review log entry that motivated it. This is what made verifying the atomicity
|
||||||
|
and timeout invariants tractable in this review.
|
||||||
|
- **Documentation-to-code traceability is strong overall.** Page contracts, schema field tables,
|
||||||
|
and requirement IDs are maintained and guarded; the drift found in this review is narrow and
|
||||||
|
specific rather than systemic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A — Repo-Specific Deterministic Checks
|
||||||
|
|
||||||
|
| # | Check | Result | Evidence |
|
||||||
|
| :-- | :--- | :--- | :--- |
|
||||||
|
| 1 | Service boundary rule: no service-to-service imports | **Pass** | `tests/test_service_boundaries.py` green; AST scan, allowlist-based; `workflows.py` composes via `ServiceBundle` |
|
||||||
|
| 2 | UI boundary rule: no persistence access from pages/components | **Pass (structurally)** | `tests/test_ui_boundaries.py` green. Caveat: it guards *data access*, not presentation of internal-only fields — see HIGH-01 |
|
||||||
|
| 3 | Status vocabulary conformance; no stringly-typed literals | **Pass** | `tests/test_model_contract_guards.py` green; enum members verified against `db/models.py` |
|
||||||
|
| 4 | Evidence ownership: append-only history, projections not history mutation | **Pass** | `test_v42_evidence.py::test_attempts_are_append_only_and_exported_with_integrity` verified non-vacuous (asserts both retained attempts and export integrity at lines 281-290) |
|
||||||
|
| 5 | Canonical authority: findings resolve against `docs/*` first | **Pass with defect** | `test_canonical_authority_references_are_present` green. The companion baseline-currency guard (`test_canonical_docs_declare_one_consistent_baseline`) scans the offending file but fails open on its phrasing and on minor-less version tokens — MED-02 |
|
||||||
|
| 6 | Schema contract fidelity: `docs/schema.md` field-accurate | **Pass** | `test_model_contract_guards.py` + `test_meta_contract_guards.py` green |
|
||||||
|
| 7 | Media boundary: record-validated media, controlled URL resolver | **Pass** | `test_media_path_safety.py`, `tests/ui/test_media_urls.py` green; `public_media_path_label` verified path-safe |
|
||||||
|
| 8 | Eager-loading conformance vs `lazy="raise"` | **Pass** | Declaration-side guard green; sampled read paths declare explicit `selectinload` chains. Note: dead `tags_page.py:71-74` would violate it if re-registered (LOW-01) |
|
||||||
|
| 9 | Cross-cutting error conformance | **FAIL** | Guards green but coverage is narrower than documented: HIGH-01, HIGH-02, MED-06, MED-07 |
|
||||||
|
| 10 | Orphan/dead-code conformance | **FAIL** | Guard green but structurally unable to detect a dead module: MED-01, proven by LOW-01 |
|
||||||
|
|
||||||
|
## Appendix B — Evidence & Provenance Auditor Families
|
||||||
|
|
||||||
|
| Family | Subject | Result | Evidence |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| A | Attempt history append-only | **Pass** | No update/delete path to `ExecutionAttempt`; insert-only with `begin_nested` + bounded sequence retry (`sources.py:556-616`) |
|
||||||
|
| B | Attempt numbering monotonic per source | **Pass** | `insert_with_sequence_retry`; uniqueness constraint plus retry on conflict |
|
||||||
|
| C | Transport evidence captured at the transport boundary | **Pass** | `_CapturingAsyncClient` retains the exact wire body pre-SDK-parse (`openrouter.py:66-94`) |
|
||||||
|
| D | Absent response distinguished from empty response | **Pass** | `TransportEvidence.response_received` is explicit, not inferred |
|
||||||
|
| E | Response header persistence is allowlist-based | **Pass** | `SAFE_RESPONSE_HEADERS` (`providers/evidence.py:29-41`) — all nine entries verified non-secret; filter-before-store |
|
||||||
|
| F | No machine-local detail on user-facing surfaces | **FAIL** | `error_detail` rendered verbatim at three UI sites (HIGH-01); paths in `message` at five sites (HIGH-02) |
|
||||||
|
| G | Request manifest excludes embedded media payloads | **Pass** | `_replace_embedded_media` (`openrouter.py:377-395`) substitutes a source reference for base64 data |
|
||||||
|
| H | Evidence attribution is correct under concurrency | **Pass today / at risk** | Correct in the current sequential single-worker deployment; the contract itself is non-reentrant (MED-05) and reclaim has no margin (MED-04) |
|
||||||
@@ -15,8 +15,7 @@ Naming: `<YYYY-MM-DD>-code-review.md` for review reports, and
|
|||||||
## Current
|
## Current
|
||||||
|
|
||||||
- [`2026-08-23-code-review.md`](./2026-08-23-code-review.md) — full review. 0 critical,
|
- [`2026-08-23-code-review.md`](./2026-08-23-code-review.md) — full review. 0 critical,
|
||||||
4 high, 5 medium, 11 low.
|
4 high, 5 medium, 11 low. **All findings remediated.** Retained as a record of the
|
||||||
- [`2026-08-23-remediation-handoff.md`](./2026-08-23-remediation-handoff.md) — **start here
|
reasoning, not as a list of open work. Note that a few of its recommendations were
|
||||||
to continue the remediation work.** Phase 1 (all 4 high findings) is complete as of commit
|
wrong on contact and were corrected during implementation; the code and the guard
|
||||||
`de18c2e`; the handoff covers Phases 2-5 with per-task acceptance criteria, the verification
|
tests are authoritative over the report text.
|
||||||
baseline, and the environment gotchas needed to avoid re-deriving them.
|
|
||||||
|
|||||||
+149
-25
@@ -5,6 +5,9 @@ This roadmap starts at **V6.0** and tracks forward-looking work only.
|
|||||||
## V6.0 - Hosting Migration
|
## V6.0 - Hosting Migration
|
||||||
|
|
||||||
Objective: move from local-only operation to secure, stable remote hosting.
|
Objective: move from local-only operation to secure, stable remote hosting.
|
||||||
|
Status: **Completed**
|
||||||
|
|
||||||
|
Detailed plan: [`v6_0_hosting_migration_plan.md`](v6_0_hosting_migration_plan.md)
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
1. Containerize app runtime for production deployment.
|
1. Containerize app runtime for production deployment.
|
||||||
@@ -23,26 +26,151 @@ Objective: move from local-only operation to secure, stable remote hosting.
|
|||||||
- One end-to-end document -> source -> job workflow succeeds remotely.
|
- One end-to-end document -> source -> job workflow succeeds remotely.
|
||||||
- Backup and restore procedure is tested.
|
- Backup and restore procedure is tested.
|
||||||
|
|
||||||
## V6.1 - Reporting Features
|
### Accomplished
|
||||||
|
1. Delivered production Docker deployment with split `app`/`worker`, `postgres`, and `cloudflared`.
|
||||||
|
2. Landed SQLite -> PostgreSQL migration tooling and runbook coverage.
|
||||||
|
3. Added production health/reliability wiring and operational runbooks for deploy/rollback/recovery.
|
||||||
|
4. Established host-visible backup workflow and restore path for PostgreSQL plus media/config assets.
|
||||||
|
|
||||||
Objective: improve research value with person-centric outputs.
|
## V6.1 - Testing and Refinement
|
||||||
|
|
||||||
|
Objective: improve navigation and operational workflows after user feedback.
|
||||||
|
Status: **Completed**
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
1. Person timeline views using document dates and linked records.
|
1. Make Document Detail the primary source-page workspace:
|
||||||
2. AI-assisted biography/family-history generation from curated sources.
|
- Use Source-style pan/zoom + previous/next page controls.
|
||||||
3. Exportable report views (human-readable, print-oriented).
|
- Move editable revision controls into Document Detail.
|
||||||
|
- Move archival/system metadata to dedicated Document Info route.
|
||||||
|
2. Simplify top navigation:
|
||||||
|
- Remove top-level Tags and Sources entries.
|
||||||
|
- Retire the Tags page and the global Source Asset Records entry flow.
|
||||||
|
3. Improve list/detail clarity:
|
||||||
|
- Add Document transcription status to Archival Documents list.
|
||||||
|
- Add Document Date in People Detail -> Linked Documents table.
|
||||||
|
4. Add worker-backed Settings maintenance runs:
|
||||||
|
- Add `maintenance_run` persistence (`id`, `job_type`, `status`, `started_at`, `finished_at`, `triggered_by`, `summary`, `log_path`, `error_detail`).
|
||||||
|
- Add Run Backup and Run Storage Reconciliation actions that enqueue runs and execute in the worker.
|
||||||
|
- Add run history with status, duration, summary, and log view/download.
|
||||||
|
- Defer daily/weekly scheduling controls to V6.2.
|
||||||
|
|
||||||
|
### Accomplished
|
||||||
|
1. Refactored Document Detail into the primary source-page workspace (pan/zoom viewer, previous/next page navigation, editable revision flow) and moved archival/system metadata to Document Info.
|
||||||
|
2. Simplified top navigation by removing Tags/Sources entries and retiring the Tags page/global Source Asset Records flow.
|
||||||
|
3. Improved data clarity with document transcription status in Archival Documents and Document Date in People Detail linked documents.
|
||||||
|
4. Implemented queue-backed maintenance operations (`maintenance_run` model/service/worker/UI) with run history and log view/download.
|
||||||
|
5. Hardened runtime settings operations in production:
|
||||||
|
- runtime settings writes target mounted `.env.production`,
|
||||||
|
- fallback write path for single-file bind mounts,
|
||||||
|
- explicit hidden/deployment-key disclosure in Settings UI.
|
||||||
|
6. Simplified backup configuration and behavior:
|
||||||
|
- standardized on `BACKUP_DIR` + `BACKUP_RETENTION_DAYS`,
|
||||||
|
- backup script uses `DATABASE__*` persistence keys,
|
||||||
|
- compose maps Postgres container init values from `DATABASE__*`,
|
||||||
|
- env contract drift tests now guard `.env.production.example`.
|
||||||
|
|
||||||
|
## V6.2 - GEDCOM Data Layer
|
||||||
|
|
||||||
|
Objective: introduce a genealogical data layer sourced from GEDCOM exports, bridged to
|
||||||
|
existing `Person` records via FamilySearch ID, without disrupting document-focused Person
|
||||||
|
workflows.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
1. Manual `.ged` file upload only. No FamilySearch credentials are stored or used by the
|
||||||
|
app; the user runs the third-party `getmyancestors` tool themselves and uploads the
|
||||||
|
resulting export.
|
||||||
|
2. Four new tables: `genealogy_person`, `genealogy_family`, `genealogy_family_child`, and
|
||||||
|
`genealogy_citation` (raw GEDCOM `SOUR` citations, reusable in a later version to record
|
||||||
|
when a transcribed document itself becomes citation evidence for FamilySearch).
|
||||||
|
3. Upsert-based import keyed on FamilySearch ID (`fs_id`) so repeat imports update existing
|
||||||
|
records in place without breaking existing `Person.family_search_id` links or duplicating
|
||||||
|
surrogate keys.
|
||||||
|
4. Reuse the existing V6.1 worker-backed `maintenance_run` pattern for import runs (run
|
||||||
|
history, status, summary, log view/download) rather than new infrastructure.
|
||||||
|
|
||||||
### Deliverables
|
### Deliverables
|
||||||
- Timeline UI and service queries with clear ordering/filters.
|
- GEDCOM parser/importer producing the four genealogy tables.
|
||||||
- Prompted narrative generation workflow using existing evidence-safe patterns.
|
- `MaintenanceJobType` entry for GEDCOM import with upsert semantics and a run summary
|
||||||
- Saved/printable report presentation for review and sharing.
|
(records added/updated).
|
||||||
|
- Settings UI entry to upload a `.ged` file, trigger an import run, and view history.
|
||||||
|
|
||||||
### Exit Criteria
|
### Exit Criteria
|
||||||
|
- Importing the same `.ged` file twice does not duplicate or orphan data.
|
||||||
|
- Existing `Person.family_search_id` values continue to resolve to the correct
|
||||||
|
`genealogy_person` row after import.
|
||||||
|
- Import run history is visible with status, duration, and summary, consistent with other
|
||||||
|
maintenance runs.
|
||||||
|
|
||||||
|
## V6.3 - Reporting and Genealogy-Enriched Features
|
||||||
|
|
||||||
|
Objective: improve research value with person-centric outputs, grounded in both archival
|
||||||
|
documents and the V6.2 genealogical data layer.
|
||||||
|
|
||||||
|
This version is broken into five sequential sub-versions because of real dependency
|
||||||
|
ordering: entity linking must exist before GEDCOM data can be targeted per-person; the
|
||||||
|
Facts/Events mechanism must exist before timelines or reconciliation have anything
|
||||||
|
meaningful to consume.
|
||||||
|
|
||||||
|
### V6.3.1 - Manual Entity Linking
|
||||||
|
|
||||||
|
- Search/browse UI over `genealogy_person` to find and link a candidate match to an
|
||||||
|
application `Person`, setting `family_search_id`. Linking is reversible (unlink).
|
||||||
|
- Once linked, GEDCOM vitals display alongside the `Person` record without requiring any
|
||||||
|
schema change to `Person`.
|
||||||
|
|
||||||
|
### V6.3.2 - Person Facts and Events
|
||||||
|
|
||||||
|
- New fact/event table capturing: person, fact type (birth/death/event/free-form), date
|
||||||
|
(+raw), place, free-text description, and a link to the source document as evidence.
|
||||||
|
- Manual tagging UI while reviewing a transcribed document: select a passage, choose the
|
||||||
|
person and fact type, record the date/description.
|
||||||
|
- One-time migration of existing `Person.birth_date`/`birth_date_raw`/`birth_place`/
|
||||||
|
`death_date`/`death_date_raw`/`death_place` values into fact/event rows (tagged as
|
||||||
|
legacy/no-document-evidence where no source document is known), followed by retiring those
|
||||||
|
six columns from `Person`. Birth/death become Facts/Events like any other locally-known
|
||||||
|
fact, for both linked and unlinked people. `Person` permanently keeps `last_name`,
|
||||||
|
`given_names`, `biography`, `family_search_id`, `metadata_`, tags, photos, and document
|
||||||
|
associations.
|
||||||
|
|
||||||
|
### V6.3.3 - Person Timelines
|
||||||
|
|
||||||
|
- Timeline query merging GEDCOM milestones (birth, marriage, children's births, death) for
|
||||||
|
linked persons with locally recorded Facts/Events.
|
||||||
|
- Timeline UI on Person Detail with clear ordering/filters; entries link back to their
|
||||||
|
originating document or GEDCOM record.
|
||||||
|
|
||||||
|
### V6.3.4 - Reconciliation
|
||||||
|
|
||||||
|
- Compares Facts/Events (the real, document-evidenced local signal) against corresponding
|
||||||
|
`genealogy_person` fields for linked persons.
|
||||||
|
- Persisted reconciliation record: person, field, local value with evidence-document link,
|
||||||
|
GEDCOM value, and status (open / submitted / dismissed).
|
||||||
|
- Re-evaluated automatically as part of each GEDCOM import maintenance run: opens new
|
||||||
|
discrepancies, auto-resolves ones where GEDCOM now matches, leaves others unchanged.
|
||||||
|
- Reconciliation review UI functions as a manual to-do list for updating FamilySearch; the
|
||||||
|
app does not write back to FamilySearch itself.
|
||||||
|
|
||||||
|
### V6.3.5 - AI-Assisted Biography Generation
|
||||||
|
|
||||||
|
- Prompted narrative generation grounded in GEDCOM facts, Facts/Events, and relevant
|
||||||
|
document snippets as structured input, using existing evidence-safe prompting patterns.
|
||||||
|
- Output cites back to source documents and FamilySearch records.
|
||||||
|
- Saved/printable report presentation for review; reports do not modify archival source
|
||||||
|
data.
|
||||||
|
|
||||||
|
### Exit Criteria (applies across V6.3.1-V6.3.5)
|
||||||
|
- Entity links are reversible and do not alter document associations.
|
||||||
- Timelines are reproducible from persisted records.
|
- Timelines are reproducible from persisted records.
|
||||||
|
- Reconciliation items always carry a link to the document evidence justifying the local
|
||||||
|
value, and re-running GEDCOM import correctly opens, resolves, or leaves items unchanged.
|
||||||
- Narrative generation is traceable to source records and prompts.
|
- Narrative generation is traceable to source records and prompts.
|
||||||
- Reports can be reviewed without modifying archival source data.
|
- Reports can be reviewed without modifying archival source data.
|
||||||
|
|
||||||
## V6.2 - Access Control and Multi-User Readiness
|
## V6.4 - Access Control and Multi-User Readiness
|
||||||
|
|
||||||
|
[ *More thoughts on user accounts:*
|
||||||
|
* *Create a generic "view only" user that does not have the rights to alter any of the data*
|
||||||
|
* *Limit user accounts access to data by Tag. I have distant family members that I would want to share the transcribed data with, but they would only be interested in a subset of it. For example my Cochran cousins would have no interest in Lancaster documents, so limit the Cochra Clan cousins to view-only access to documents tagged "cochran clan"* ]
|
||||||
|
|
||||||
Objective: prepare for managed collaboration beyond single-user operation.
|
Objective: prepare for managed collaboration beyond single-user operation.
|
||||||
|
|
||||||
@@ -61,26 +189,22 @@ Objective: prepare for managed collaboration beyond single-user operation.
|
|||||||
- Role policies are enforced by deterministic tests.
|
- Role policies are enforced by deterministic tests.
|
||||||
- User-attributed changes are visible for audit/review.
|
- User-attributed changes are visible for audit/review.
|
||||||
|
|
||||||
## V6.3 - Scalability and Multi-Tenant Direction (Optional)
|
## Deferred / Future Ideas (not committed scope)
|
||||||
|
|
||||||
Objective: keep architecture ready for broader deployment footprints.
|
Captured for later consideration, not yet scheduled to a version:
|
||||||
|
* AI-assisted entity disambiguation (kinship co-occurrence, chronological plausibility
|
||||||
### Scope
|
filtering) when linking document mentions to people.
|
||||||
1. Evaluate per-tenant or per-user data partitioning strategy.
|
* Kinship-aware `@mention` tagging while transcribing.
|
||||||
2. Formalize connection/runtime strategy for tenant-aware DB selection.
|
* Relationship-calculator badges (e.g., "3rd Great-Grandmother") in the document viewer.
|
||||||
3. Expand operational telemetry for throughput and cost monitoring.
|
* Interactive migration/geography mapping from GEDCOM and document place mentions.
|
||||||
|
* AI-suggested document discovery by date/location overlap with known persons.
|
||||||
### Deliverables
|
* Ability to search within a document to find potential people to add to the People table.
|
||||||
- Decision document for tenancy model and migration strategy.
|
|
||||||
- Prototype-safe runtime boundary for selecting data targets.
|
|
||||||
- Monitoring baseline for queue depth, job latency, and provider cost trends.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Selected tenancy strategy is documented and testable.
|
|
||||||
- Operational metrics support capacity planning.
|
|
||||||
|
|
||||||
## Planning Notes
|
## Planning Notes
|
||||||
|
|
||||||
- Keep architecture, schema, and UI contracts synchronized in `docs/` as each version lands.
|
- Keep architecture, schema, and UI contracts synchronized in `docs/` as each version lands.
|
||||||
- Prefer explicit schema migration over runtime compatibility write paths.
|
- Prefer explicit schema migration over runtime compatibility write paths.
|
||||||
- Preserve evidence/provenance guarantees when adding new AI-powered features.
|
- Preserve evidence/provenance guarantees when adding new AI-powered features.
|
||||||
|
- GEDCOM/FamilySearch data is external, collaborative, and mutable; treat it as a managed
|
||||||
|
cache bridged via `fs_id`, never as a replacement for archival evidence recorded from
|
||||||
|
transcribed documents.
|
||||||
|
|||||||
+101
-4
@@ -1,14 +1,16 @@
|
|||||||
# Data Model and Persistence Schema (Current Baseline: V5.1)
|
# Data Model and Persistence Schema (Current Baseline: V6.1)
|
||||||
|
|
||||||
This document is the field-accurate V5.1 schema contract aligned to `src/transcription/db/models.py`.
|
This document is the field-accurate V6.1 schema contract aligned to `src/transcription/db/models.py`.
|
||||||
|
|
||||||
## Source of Truth Anchors
|
## Source of Truth Anchors
|
||||||
|
|
||||||
- `src/transcription/db/models.py:60-78` (status and purpose enums)
|
- `src/transcription/db/models.py` (status and purpose enums, including maintenance lifecycle enums)
|
||||||
- `src/transcription/db/models.py:80-120` (`DocumentType`, `PersonRole`)
|
- `src/transcription/db/models.py:80-120` (`DocumentType`, `PersonRole`)
|
||||||
- `src/transcription/db/models.py:122-172` (`Tag`, `Document`)
|
- `src/transcription/db/models.py:122-172` (`Tag`, `Document`)
|
||||||
- `src/transcription/db/models.py:175-281` (`Person`, `Photo`, `DocumentPerson`, `DocumentTag`)
|
- `src/transcription/db/models.py` (`Person`, `GenealogyPerson`, `GenealogyFamily`, `GenealogyFamilyChild`, `GenealogyCitation`)
|
||||||
|
- `src/transcription/db/models.py` (`Photo`, `DocumentPerson`, `DocumentTag`)
|
||||||
- `src/transcription/db/models.py:285-347` (`Job`)
|
- `src/transcription/db/models.py:285-347` (`Job`)
|
||||||
|
- `src/transcription/db/models.py` (`MaintenanceRun`)
|
||||||
- `src/transcription/db/models.py:350-462` (`Source`, `JobSource`)
|
- `src/transcription/db/models.py:350-462` (`Source`, `JobSource`)
|
||||||
- `src/transcription/db/models.py:465-522` (`ExecutionAttempt`)
|
- `src/transcription/db/models.py:465-522` (`ExecutionAttempt`)
|
||||||
|
|
||||||
@@ -24,12 +26,22 @@ erDiagram
|
|||||||
Person ||--o{ DocumentPerson : links
|
Person ||--o{ DocumentPerson : links
|
||||||
Person ||--o{ PersonTag : tagged
|
Person ||--o{ PersonTag : tagged
|
||||||
Person ||--o{ Photo : owns
|
Person ||--o{ Photo : owns
|
||||||
|
GenealogyPerson ||--o{ GenealogyFamily : husband
|
||||||
|
GenealogyPerson ||--o{ GenealogyFamily : wife
|
||||||
|
GenealogyPerson ||--o{ GenealogyFamilyChild : child
|
||||||
|
GenealogyFamily ||--o{ GenealogyFamilyChild : includes
|
||||||
|
GenealogyPerson ||--o{ GenealogyCitation : cited
|
||||||
|
GenealogyFamily ||--o{ GenealogyCitation : cited
|
||||||
|
Document ||--o{ GenealogyCitation : evidence
|
||||||
PersonRole ||--o{ DocumentPerson : labels
|
PersonRole ||--o{ DocumentPerson : labels
|
||||||
Tag ||--o{ DocumentTag : labels
|
Tag ||--o{ DocumentTag : labels
|
||||||
Tag ||--o{ PersonTag : labels
|
Tag ||--o{ PersonTag : labels
|
||||||
Job ||--o{ JobSource : includes
|
Job ||--o{ JobSource : includes
|
||||||
Source ||--o{ JobSource : participates
|
Source ||--o{ JobSource : participates
|
||||||
JobSource ||--o{ ExecutionAttempt : attempts
|
JobSource ||--o{ ExecutionAttempt : attempts
|
||||||
|
MaintenanceRun {
|
||||||
|
uuid id PK
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authoritative Enumerations
|
## Authoritative Enumerations
|
||||||
@@ -54,6 +66,19 @@ erDiagram
|
|||||||
- `transcription`
|
- `transcription`
|
||||||
- `retranscription`
|
- `retranscription`
|
||||||
|
|
||||||
|
### MaintenanceJobType
|
||||||
|
|
||||||
|
- `backup`
|
||||||
|
- `storage_reconciliation`
|
||||||
|
- `gedcom_import`
|
||||||
|
|
||||||
|
### MaintenanceRunStatus
|
||||||
|
|
||||||
|
- `queued`
|
||||||
|
- `processing`
|
||||||
|
- `succeeded`
|
||||||
|
- `failed`
|
||||||
|
|
||||||
## Field-Accurate Table Contracts
|
## Field-Accurate Table Contracts
|
||||||
|
|
||||||
### `DocumentType`
|
### `DocumentType`
|
||||||
@@ -126,6 +151,62 @@ erDiagram
|
|||||||
| `created_at` | `datetime` | default now |
|
| `created_at` | `datetime` | default now |
|
||||||
| `updated_at` | `datetime` | default now, onupdate |
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `GenealogyPerson`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `fs_id` | `str` | unique, indexed FamilySearch identifier |
|
||||||
|
| `full_name` | `str` | required |
|
||||||
|
| `birth_date` | `date \| None` | optional |
|
||||||
|
| `birth_date_raw` | `str \| None` | optional |
|
||||||
|
| `birth_place` | `str \| None` | optional |
|
||||||
|
| `death_date` | `date \| None` | optional |
|
||||||
|
| `death_date_raw` | `str \| None` | optional |
|
||||||
|
| `death_place` | `str \| None` | optional |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `GenealogyFamily`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `fs_family_id` | `str` | unique, indexed FamilySearch family identifier |
|
||||||
|
| `husband_id` | `UUID \| None` | nullable FK -> `genealogy_person.id`, indexed |
|
||||||
|
| `wife_id` | `UUID \| None` | nullable FK -> `genealogy_person.id`, indexed |
|
||||||
|
| `marriage_date` | `date \| None` | optional |
|
||||||
|
| `marriage_date_raw` | `str \| None` | optional |
|
||||||
|
| `marriage_place` | `str \| None` | optional |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
|
### `GenealogyFamilyChild`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `family_id` | `UUID` | FK -> `genealogy_family.id`, indexed |
|
||||||
|
| `child_id` | `UUID` | FK -> `genealogy_person.id`, indexed |
|
||||||
|
| `relationship_type` | `str \| None` | optional |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
|
||||||
|
Constraint:
|
||||||
|
- `UniqueConstraint(family_id, child_id)` named `uq_genealogy_family_child`
|
||||||
|
|
||||||
|
### `GenealogyCitation`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `genealogy_person_id` | `UUID \| None` | nullable FK -> `genealogy_person.id`, indexed |
|
||||||
|
| `genealogy_family_id` | `UUID \| None` | nullable FK -> `genealogy_family.id`, indexed |
|
||||||
|
| `fact_type` | `GenealogyCitationFactType` | enum: `birth`, `death`, `marriage`, `other` |
|
||||||
|
| `raw_citation_text` | `str` | required raw GEDCOM citation text |
|
||||||
|
| `source_kind` | `GenealogyCitationSourceKind` | enum: `familysearch_imported`, `transcription_evidence` |
|
||||||
|
| `document_id` | `UUID \| None` | nullable FK -> `document.id`, indexed |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
|
||||||
### `Photo`
|
### `Photo`
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
@@ -201,6 +282,22 @@ Constraint:
|
|||||||
Index:
|
Index:
|
||||||
- `Index("ix_job_status_date_created", "status", "date_created")`
|
- `Index("ix_job_status_date_created", "status", "date_created")`
|
||||||
|
|
||||||
|
### `MaintenanceRun`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id` | `UUID` | PK |
|
||||||
|
| `job_type` | `MaintenanceJobType` | non-null enum |
|
||||||
|
| `status` | `MaintenanceRunStatus` | non-null enum, default `queued` |
|
||||||
|
| `started_at` | `datetime \| None` | optional |
|
||||||
|
| `finished_at` | `datetime \| None` | optional |
|
||||||
|
| `triggered_by` | `str \| None` | optional |
|
||||||
|
| `summary` | `str \| None` | optional |
|
||||||
|
| `log_path` | `str \| None` | optional, log-root-relative POSIX path |
|
||||||
|
| `error_detail` | `str \| None` | optional internal failure detail |
|
||||||
|
| `created_at` | `datetime` | default now |
|
||||||
|
| `updated_at` | `datetime` | default now, onupdate |
|
||||||
|
|
||||||
### `Source`
|
### `Source`
|
||||||
|
|
||||||
| Field | Type | Notes |
|
| Field | Type | Notes |
|
||||||
|
|||||||
+2
-1
@@ -13,6 +13,7 @@ These documents are written for maintainers and AI contributors. They are behavi
|
|||||||
- [People](pages/people.md)
|
- [People](pages/people.md)
|
||||||
- [Jobs](pages/jobs.md)
|
- [Jobs](pages/jobs.md)
|
||||||
- [Sources](pages/sources.md)
|
- [Sources](pages/sources.md)
|
||||||
|
- [Settings](pages/settings.md)
|
||||||
|
|
||||||
NiceGUI registers the routes shown in each contract without the `/ui` prefix. The application mounts NiceGUI under `/ui`, so `/documents` in page code is served to a browser as `/ui/documents`.
|
NiceGUI registers the routes shown in each contract without the `/ui` prefix. The application mounts NiceGUI under `/ui`, so `/documents` in page code is served to a browser as `/ui/documents`.
|
||||||
|
|
||||||
@@ -56,4 +57,4 @@ Each page contract contains:
|
|||||||
|
|
||||||
## Current Baseline
|
## Current Baseline
|
||||||
|
|
||||||
These contracts describe the current V5.1 baseline.
|
These contracts describe the current V6.1 baseline.
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ Documents manages the archival record for each historical artifact independently
|
|||||||
| `/documents` | Searchable archival Document list. |
|
| `/documents` | Searchable archival Document list. |
|
||||||
| `/documents/new` | Create a Document. |
|
| `/documents/new` | Create a Document. |
|
||||||
| `/documents/{document_id}` | View one Document and its related records. |
|
| `/documents/{document_id}` | View one Document and its related records. |
|
||||||
|
| `/documents/{document_id}/info` | View archival metadata and system logistics for one Document. |
|
||||||
| `/documents/{document_id}/edit` | Edit metadata and the complete Linked People set. |
|
| `/documents/{document_id}/edit` | Edit metadata and the complete Linked People set. |
|
||||||
| `/documents/{document_id}/delete` | Confirm or block deletion. |
|
| `/documents/{document_id}/delete` | Confirm or block deletion. |
|
||||||
| `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. |
|
| `/documents/{document_id}/jobs` | Show Jobs belonging to the Document. |
|
||||||
| `/documents/{document_id}/sources` | Redirect to the Document-filtered Sources list. |
|
| `/documents/{document_id}/sources` | Source-image gallery for the Document. |
|
||||||
| `/documents/{document_id}/print` | Preview and browser-print the persisted Document. |
|
| `/documents/{document_id}/print` | Preview and browser-print the persisted Document. |
|
||||||
|
|
||||||
## List Behavior
|
## List Behavior
|
||||||
@@ -22,12 +23,14 @@ Documents manages the archival record for each historical artifact independently
|
|||||||
- The title is **Archival Documents**.
|
- The title is **Archival Documents**.
|
||||||
- **Create new document** opens the create route.
|
- **Create new document** opens the create route.
|
||||||
- The table defaults to Document Title order and supports search and column sorting.
|
- The table defaults to Document Title order and supports search and column sorting.
|
||||||
- Columns are Document Title, Author, Tags, Document Date, Type, and # Sources.
|
- Columns are Document Title, Author, Tags, Document Date, Type, # Sources, and Transcription Status.
|
||||||
- Document Title is left-aligned; the remaining columns are centered.
|
- Document Title is left-aligned; the remaining columns are centered.
|
||||||
- Author lists all linked people in the `author` role.
|
- Author lists all linked people in the `author` role.
|
||||||
- # Sources reflects the count of linked Source rows for each Document.
|
- # Sources reflects the count of linked Source rows for each Document.
|
||||||
|
- Transcription Status reflects the most recent Job status for that Document; documents with no Jobs show a blank marker.
|
||||||
- Date display prefers exact date, then approximate date, then `Unknown`.
|
- Date display prefers exact date, then approximate date, then `Unknown`.
|
||||||
- Selecting a row opens Document Detail.
|
- Selecting a row opens Document Detail.
|
||||||
|
- Row navigation includes list context so Document Detail provides **Back to Documents**.
|
||||||
- No records displays `No documents found in repository.`
|
- No records displays `No documents found in repository.`
|
||||||
|
|
||||||
## Create and Edit Behavior
|
## Create and Edit Behavior
|
||||||
@@ -68,14 +71,27 @@ Rules:
|
|||||||
## Detail Behavior
|
## Detail Behavior
|
||||||
|
|
||||||
- The heading shows name, type, and internal ID.
|
- The heading shows name, type, and internal ID.
|
||||||
- The first Source, when present, appears in the dark-room viewer.
|
- The header includes a contextual back action: **Back to Documents** by default, **Back to Person** when opened from Person Detail, and **Back to Job** when opened from Job Detail.
|
||||||
- Archival Metadata shows authors, Document Type, tags, Document date (`MM-DD-YYYY` for exact dates), location, and archive identifier. Notes appear in a separate archival-notes block within the same card.
|
- The detail workspace shows a Source-style pan/zoom media viewer with **Previous Page** / **Next Page** navigation for document source pages.
|
||||||
- System Logistics shows created and updated timestamps.
|
- The center column is **Editable Revision** for the active source page.
|
||||||
- Related People are grouped by role and link to Person Detail.
|
- Related People are grouped by role and link to Person Detail.
|
||||||
- **Sources & Pipeline Jobs** shows counts and actions for filtered Sources, Document Jobs, and adding a Job.
|
- **Source Pages & Transcriptions** shows source/job counts and actions for source-image gallery, document jobs, and adding a Job.
|
||||||
- **Edit Document**, **Print**, and **Delete** are available from the header.
|
- **Edit Document**, **Print**, **Document Details**, **View Source Detail**, and **Delete** are available from the header.
|
||||||
- Invalid IDs and missing Documents produce explicit states without rendering a partial page.
|
- Invalid IDs and missing Documents produce explicit states without rendering a partial page.
|
||||||
|
|
||||||
|
## Document Source Images Behavior
|
||||||
|
|
||||||
|
- `/documents/{document_id}/sources` shows the current Document's source pages in a thumbnail gallery.
|
||||||
|
- Each card shows the page number, stored filename, and an **Open Source Detail** action.
|
||||||
|
- The page includes a **Back to Document** action.
|
||||||
|
- No source pages displays an explicit empty state.
|
||||||
|
|
||||||
|
## Document Info Behavior
|
||||||
|
|
||||||
|
- `/documents/{document_id}/info` contains **Archival Metadata** and **System Logistics**.
|
||||||
|
- It includes a **Back to Document** action.
|
||||||
|
- Archival metadata includes authors, document type, tags, document date, location (linked when present), archive identifier, and notes.
|
||||||
|
|
||||||
## Print Behavior
|
## Print Behavior
|
||||||
|
|
||||||
- Print opens a dedicated preview for persisted Document data.
|
- Print opens a dedicated preview for persisted Document data.
|
||||||
@@ -130,5 +146,5 @@ Rules:
|
|||||||
|
|
||||||
## Known Limitations and Deferred Work
|
## Known Limitations and Deferred Work
|
||||||
|
|
||||||
- Source page ordering remains read-only in V4.4.
|
- Source page ordering remains read-only.
|
||||||
- Printing other entities, batch printing, and server-side export formats are deferred.
|
- Printing other entities, batch printing, and server-side export formats are deferred.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
|
|||||||
- Search covers Job ID, document name, and status.
|
- Search covers Job ID, document name, and status.
|
||||||
- Status is displayed as a semantic status chip.
|
- Status is displayed as a semantic status chip.
|
||||||
- Selecting a row opens Job Detail.
|
- Selecting a row opens Job Detail.
|
||||||
|
- Global Job-list row navigation includes list context so Job Detail provides **Back to Jobs**.
|
||||||
- No records displays `No job records found in repository.`
|
- No records displays `No job records found in repository.`
|
||||||
|
|
||||||
## Create Behavior
|
## Create Behavior
|
||||||
@@ -43,8 +44,9 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
|
|||||||
## Detail and Lifecycle Behavior
|
## Detail and Lifecycle Behavior
|
||||||
|
|
||||||
- The heading shows Job ID and a status badge.
|
- The heading shows Job ID and a status badge.
|
||||||
|
- Job Detail includes a contextual back action: **Back to Jobs** by default and **Back to Document** when opened from a Document-filtered Job list.
|
||||||
- Execution Logistics shows provider, model, prompt, retry count, and last update.
|
- Execution Logistics shows provider, model, prompt, retry count, and last update.
|
||||||
- Document Links show a clickable Document Name, Sources count, and a single **View Sources** action using document filtering.
|
- Document Links show a clickable Document Name (with Job context), Sources count, and a single **View Sources** action using job filtering.
|
||||||
- Queued and processing Jobs show an auto-refresh notice and reload every four seconds.
|
- Queued and processing Jobs show an auto-refresh notice and reload every four seconds.
|
||||||
- Polling stops when the Job becomes terminal or a refresh fails.
|
- Polling stops when the Job becomes terminal or a refresh fails.
|
||||||
- Queued and processing Jobs expose **Cancel**.
|
- Queued and processing Jobs expose **Cancel**.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ People manages reusable historical-person records. A Person may appear in many D
|
|||||||
- # Documents reflects how many linked Documents each Person is connected to.
|
- # Documents reflects how many linked Documents each Person is connected to.
|
||||||
- Birth and death values independently prefer exact date, then approximate date, then `Unknown`.
|
- Birth and death values independently prefer exact date, then approximate date, then `Unknown`.
|
||||||
- Selecting a row opens Person Detail.
|
- Selecting a row opens Person Detail.
|
||||||
|
- Row navigation includes list context so Person Detail provides **Back to People**.
|
||||||
- No records displays `No person records found in repository.`
|
- No records displays `No person records found in repository.`
|
||||||
|
|
||||||
## Create and Edit Behavior
|
## Create and Edit Behavior
|
||||||
@@ -55,6 +56,7 @@ Rules:
|
|||||||
## Detail Behavior
|
## Detail Behavior
|
||||||
|
|
||||||
- The header provides **New Document**, **Edit Person**, **Edit Photo(s)**, and **Delete**.
|
- The header provides **New Document**, **Edit Person**, **Edit Photo(s)**, and **Delete**.
|
||||||
|
- The header includes a contextual back action: **Back to People** by default, and **Back to Document** when opened from Document Detail.
|
||||||
- **New Document** opens Document creation with this Person requested for author preselection.
|
- **New Document** opens Document creation with this Person requested for author preselection.
|
||||||
- Person Detail shows a single-photo viewer with **Previous/Next** navigation; the page-level **Edit Photo(s)** header action opens photo management.
|
- Person Detail shows a single-photo viewer with **Previous/Next** navigation; the page-level **Edit Photo(s)** header action opens photo management.
|
||||||
- Photo management (upload, description edit, set-primary, delete) is intentionally moved to `/people/{person_id}/photos`.
|
- Photo management (upload, description edit, set-primary, delete) is intentionally moved to `/people/{person_id}/photos`.
|
||||||
@@ -62,7 +64,7 @@ Rules:
|
|||||||
- Birth and death place values are clickable links to Google Maps when present.
|
- Birth and death place values are clickable links to Google Maps when present.
|
||||||
- FamilySearch ID is shown as a metadata value and is clickable to the FamilySearch person details route when present.
|
- FamilySearch ID is shown as a metadata value and is clickable to the FamilySearch person details route when present.
|
||||||
- Biography has an explicit empty value.
|
- Biography has an explicit empty value.
|
||||||
- Linked Documents render as a table with **Document Name**, **Role**, and **Number of Pages**; selecting a row opens Document Detail.
|
- Linked Documents render as a table with **Document Name**, **Document Date**, **Role**, and **Number of Pages**; selecting a row opens Document Detail.
|
||||||
- No links shows both an empty state and guidance to link from a Document workflow.
|
- No links shows both an empty state and guidance to link from a Document workflow.
|
||||||
- System Logistics shows created and updated timestamps.
|
- System Logistics shows created and updated timestamps.
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Settings manages installation-local registries and editable text assets from one route.
|
Settings manages installation-local registries, safe runtime .env settings, and editable text assets from one route.
|
||||||
|
|
||||||
## Route
|
## Route
|
||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/settings` | Manage Document Types, Person Roles, Tags, Prompts, and Home Page Text. |
|
| `/settings` | Manage Runtime Settings, Document Types, Person Roles, Tags, Prompts, Home Page Text, and Maintenance runs. |
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
@@ -19,18 +19,37 @@ Settings manages installation-local registries and editable text assets from one
|
|||||||
- **Tags**
|
- **Tags**
|
||||||
- **Prompts**
|
- **Prompts**
|
||||||
- **Home Page Text**
|
- **Home Page Text**
|
||||||
|
- **Maintenance**
|
||||||
|
- **Runtime Settings**
|
||||||
|
- Runtime Settings exposes an allowlisted set of non-secret fields synchronized with `Settings` model fields except excluded secret/unsafe fields.
|
||||||
|
- Runtime Settings is rendered as a compact two-column editor (**Setting**, **Value**) in a centered, narrower responsive container.
|
||||||
|
- Runtime Settings persists changes to the resolved runtime env file, validates by constructing a `Settings` instance, and reports validation failures through the shared UI error presenter.
|
||||||
|
- `Settings` resolves its env file in this order: explicit `_env_file`, `ENV_FILE`, then the repository-root `.env.production`.
|
||||||
|
- Runtime Settings resolves its write target in this order: explicit function override (tests/tools), `RUNTIME_SETTINGS_ENV_FILE` environment variable (deployment override), `ENV_FILE`, then the repository-root `.env.production`.
|
||||||
|
- Runtime Settings changes require application restart to take effect.
|
||||||
|
- Runtime Settings renders a host-side restart command (`docker compose -f docker-compose.production.yml up -d --force-recreate app worker`) so operators can apply saved values without granting Docker control to the app container.
|
||||||
|
- Runtime Settings includes an explicit "Other settings not shown here" markdown table listing:
|
||||||
|
- secrets (`OPENROUTER_API_KEY`, `DATABASE__PASSWORD`)
|
||||||
|
- high-risk database connection settings (`DATABASE__DRIVER`, `DATABASE__PATH`, `DATABASE__HOST`, `DATABASE__PORT`, `DATABASE__DATABASE`, `DATABASE__USER`)
|
||||||
|
and deployment/helper keys (`CLOUDFLARE_TUNNEL_TOKEN`, `BACKUP_DIR`, `BACKUP_RETENTION_DAYS`, `RUNTIME_SETTINGS_ENV_FILE`, `ENV_FILE`, `COMPOSE_FILE`) plus legacy/deprecated keys (`POSTGRES_*`, `DATABASE_BACKUP_DIR`, `APP_DATA_BACKUP_DIR`, `UPLOADS_BACKUP_DIR`, `SYNOLOGY_BACKUP_DIR`), and directs edits for those keys to the resolved runtime env file path.
|
||||||
- Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails.
|
- Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails.
|
||||||
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
|
- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
|
||||||
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
- Home Page Text edits the same Markdown content rendered on `/homepage`.
|
||||||
|
- Maintenance provides queue-backed **Run Backup** and **Run Storage Reconciliation** actions.
|
||||||
|
- Maintenance also provides GEDCOM upload and **Run GEDCOM Import** actions, using the same queue-backed `MaintenanceRun` history/log flow.
|
||||||
|
- Maintenance run history shows job type, status, started/finished timestamps, duration, summary, and log view/download actions.
|
||||||
|
- Maintenance actions enqueue work and signal the worker; the page itself does not execute shell commands directly.
|
||||||
|
|
||||||
## Acceptance Checklist
|
## Acceptance Checklist
|
||||||
|
|
||||||
- `/ui/settings` renders all five tabs.
|
- `/ui/settings` renders all seven tabs.
|
||||||
- Registry and prompt workflows keep existing validation and error handling.
|
- Registry and prompt workflows keep existing validation and error handling.
|
||||||
|
- Runtime Settings excludes secret fields and rejects invalid values.
|
||||||
- Saving Home Page Text persists content for the homepage view.
|
- Saving Home Page Text persists content for the homepage view.
|
||||||
|
|
||||||
## Implementation Anchors
|
## Implementation Anchors
|
||||||
|
|
||||||
- `src/transcription/ui/pages/settings_page.py`
|
- `src/transcription/ui/pages/settings_page.py`
|
||||||
|
- `src/transcription/ui/runtime_settings_store.py`
|
||||||
- `src/transcription/ui/homepage_store.py`
|
- `src/transcription/ui/homepage_store.py`
|
||||||
- `tests/ui/test_pages_registration.py`
|
- `tests/ui/test_pages_registration.py`
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Sources manages individual archived page/file records. It provides source-media
|
|||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `/sources` | Global or filtered Source list. |
|
| `/sources` | Document-filtered or Job-filtered Source list; global route redirects to Documents. |
|
||||||
| `/sources/{source_id}` | View media, transcription, revision, metadata, and evidence. |
|
| `/sources/{source_id}` | View media, transcription, revision, metadata, and evidence. |
|
||||||
| `/sources/{source_id}/delete` | Confirm or block deletion. |
|
| `/sources/{source_id}/delete` | Confirm or block deletion. |
|
||||||
|
|
||||||
@@ -16,11 +16,11 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
|
|||||||
|
|
||||||
## List Behavior
|
## List Behavior
|
||||||
|
|
||||||
- The title is **Source Asset Records**, **Sources for Document**, or **Sources for Job** according to context.
|
- The global `/sources` route redirects to `/documents`.
|
||||||
- Global context provides **Create Job**.
|
- Filtered list titles are **Sources for Document** and **Sources for Job**.
|
||||||
- Filtered context provides **Back to Document** or **Back to Job**.
|
- Filtered context provides **Back to Document** or **Back to Job**.
|
||||||
- Rows are ordered by page number and then upload name.
|
- Rows are ordered by page number and then upload name.
|
||||||
- Columns are Document Name, Page Number, Upload Title, Status, and Error Detail.
|
- Columns are Upload Title, Page Number, Document Name, Status, and Error Detail.
|
||||||
- Document Name, Upload Title, and Error Detail are left-aligned; Status is centered.
|
- Document Name, Upload Title, and Error Detail are left-aligned; Status is centered.
|
||||||
- Status labels are presented in uppercase for consistency with Jobs.
|
- Status labels are presented in uppercase for consistency with Jobs.
|
||||||
- Stored Filename is intentionally absent from the list.
|
- Stored Filename is intentionally absent from the list.
|
||||||
@@ -30,7 +30,7 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
|
|||||||
## Detail Behavior
|
## Detail Behavior
|
||||||
|
|
||||||
- The heading shows page number, upload name, and Source ID.
|
- The heading shows page number, upload name, and Source ID.
|
||||||
- **Back to Sources** returns to the global list.
|
- **Back to Document** returns to Document Detail for the active source page.
|
||||||
- **Retranscribe Source** opens Create Processing Job with this Source and its Document locked.
|
- **Retranscribe Source** opens Create Processing Job with this Source and its Document locked.
|
||||||
- **Delete Source** opens the guarded delete route.
|
- **Delete Source** opens the guarded delete route.
|
||||||
- Previous and Next navigate only among Sources belonging to the same Document in page order; unavailable boundary actions are disabled.
|
- Previous and Next navigate only among Sources belonging to the same Document in page order; unavailable boundary actions are disabled.
|
||||||
@@ -96,4 +96,4 @@ The list accepts optional `document_id` and `job_id` query parameters. Document
|
|||||||
|
|
||||||
## Planned Changes
|
## Planned Changes
|
||||||
|
|
||||||
- Source page reordering is deferred beyond V4.3 and may be reconsidered if a demonstrated workflow need emerges.
|
- Source page reordering remains deferred unless a demonstrated workflow need emerges.
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# Tags Page Contract
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Tags provides a dedicated browse/filter entry point for document tagging workflows.
|
|
||||||
|
|
||||||
## Route
|
|
||||||
|
|
||||||
| Route | Purpose |
|
|
||||||
| --- | --- |
|
|
||||||
| `/tags` | Browse Documents grouped by Tag and filter to one Tag. |
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- The page title is **Tags**.
|
|
||||||
- When no tags exist, the page shows `No tags are configured yet.`
|
|
||||||
- A Tag filter select allows narrowing to one tag.
|
|
||||||
- Each rendered group header includes the tag label and document count.
|
|
||||||
- Document names are clickable and open Document Detail.
|
|
||||||
|
|
||||||
## Acceptance Checklist
|
|
||||||
|
|
||||||
- `/ui/tags` renders successfully from the main navigation.
|
|
||||||
- Group counts match the number of linked Documents per Tag.
|
|
||||||
- Filtering hides non-matching tag groups.
|
|
||||||
|
|
||||||
## Implementation Anchors
|
|
||||||
|
|
||||||
- `src/transcription/ui/pages/tags_page.py`
|
|
||||||
- `src/transcription/services/documents.py`
|
|
||||||
- `tests/ui/test_tags_page.py`
|
|
||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"psycopg2-binary>=2.9.12",
|
"psycopg2-binary>=2.9.12",
|
||||||
"pydantic>=2.13.4",
|
"pydantic>=2.13.4",
|
||||||
"pydantic-settings>=2.9.1",
|
"pydantic-settings>=2.9.1",
|
||||||
|
"python-gedcom>=1.1.0",
|
||||||
"sqlmodel>=0.0.25",
|
"sqlmodel>=0.0.25",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -50,24 +50,24 @@ async def _lifespan(app: FastAPI):
|
|||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.database_backup_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
await _recover_stale_processing_jobs(app)
|
await _recover_stale_processing_jobs(app)
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
stack.push_async_callback(dispose_database_runtime)
|
stack.push_async_callback(dispose_database_runtime)
|
||||||
stop_event, worker_notifier, worker_health = await stack.enter_async_context(
|
if settings.run_embedded_worker:
|
||||||
worker_consumer_lifespan(
|
stop_event, worker_notifier, worker_health = await stack.enter_async_context(
|
||||||
session_factory=app.state.runtime.session_factory,
|
worker_consumer_lifespan(
|
||||||
poll_interval_seconds=settings.worker_poll_interval_seconds,
|
session_factory=app.state.runtime.session_factory,
|
||||||
shutdown_timeout_seconds=(
|
poll_interval_seconds=settings.worker_poll_interval_seconds,
|
||||||
settings.worker_provider_timeout_seconds + settings.worker_shutdown_grace_seconds
|
shutdown_timeout_seconds=(
|
||||||
),
|
settings.worker_provider_timeout_seconds + settings.worker_shutdown_grace_seconds
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
app.state.worker_stop_event = stop_event
|
||||||
app.state.worker_stop_event = stop_event
|
app.state.worker_notifier = worker_notifier
|
||||||
app.state.worker_notifier = worker_notifier
|
app.state.worker_health = worker_health
|
||||||
app.state.worker_health = worker_health
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""Private-corpus benchmark contracts and deterministic text scoring."""
|
"""Private-corpus benchmark contracts and deterministic text scoring.
|
||||||
|
|
||||||
|
This module is retained as the implementation of the evaluation policy in
|
||||||
|
`docs/invariant/ai_evidence_and_provenance.md` §5. Application runtime paths do
|
||||||
|
not call it directly, but preserved execution attempts and manually reviewed
|
||||||
|
references need a deterministic scorer that remains importable for tests and
|
||||||
|
operator tooling.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,24 +20,6 @@ class BenchmarkModel(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
class BenchmarkItem(BenchmarkModel):
|
|
||||||
"""One private benchmark item referenced by archival identity."""
|
|
||||||
|
|
||||||
source_id: UUID
|
|
||||||
source_digest_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
||||||
categories: frozenset[str] = Field(min_length=1)
|
|
||||||
reference_transcription: str = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class BenchmarkManifest(BenchmarkModel):
|
|
||||||
"""Versioned private benchmark definition without copied source media."""
|
|
||||||
|
|
||||||
schema_name: str = "transcription.private-benchmark"
|
|
||||||
schema_version: str = "1"
|
|
||||||
name: str = Field(min_length=1)
|
|
||||||
items: tuple[BenchmarkItem, ...] = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class EditorialAssessment(BenchmarkModel):
|
class EditorialAssessment(BenchmarkModel):
|
||||||
"""Manually reviewed errors not represented adequately by CER or WER."""
|
"""Manually reviewed errors not represented adequately by CER or WER."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Centralized application configuration.
|
"""Centralized application configuration.
|
||||||
|
|
||||||
All settings are loaded from environment variables (or a .env file)
|
All settings are loaded from environment variables (or an env file)
|
||||||
once at startup. Provider-specific defaults (model names, base URLs)
|
once at startup. Provider-specific defaults (model names, base URLs)
|
||||||
are resolved by the provider adapters, not here.
|
are resolved by the provider adapters, not here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import logging.config
|
import logging.config
|
||||||
|
import os
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from functools import cache
|
from functools import cache
|
||||||
@@ -26,6 +27,16 @@ from pydantic_settings import BaseSettings
|
|||||||
from pydantic_settings import SettingsConfigDict
|
from pydantic_settings import SettingsConfigDict
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_ENV_FILE_NAME = ".env.production"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settings_env_file_path() -> Path:
|
||||||
|
"""Resolve the runtime env file independent of the process working directory."""
|
||||||
|
override = os.getenv("ENV_FILE", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
return PROJECT_ROOT / DEFAULT_ENV_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
class Provider(StrEnum):
|
class Provider(StrEnum):
|
||||||
@@ -37,6 +48,7 @@ PromptFilename = Annotated[str, StringConstraints(strip_whitespace=True, min_len
|
|||||||
Probability = Annotated[float, Field(ge=0.0, le=1.0)]
|
Probability = Annotated[float, Field(ge=0.0, le=1.0)]
|
||||||
Temperature = Annotated[float, Field(ge=0.0, le=2.0)]
|
Temperature = Annotated[float, Field(ge=0.0, le=2.0)]
|
||||||
DEFAULT_PROVIDER_MODEL = "google/gemini-2.5-flash"
|
DEFAULT_PROVIDER_MODEL = "google/gemini-2.5-flash"
|
||||||
|
WORKER_STALE_TIMEOUT_MULTIPLIER = 3.0
|
||||||
|
|
||||||
|
|
||||||
class SqliteSettings(BaseModel):
|
class SqliteSettings(BaseModel):
|
||||||
@@ -65,7 +77,7 @@ DatabaseSettings = Annotated[
|
|||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=None,
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
env_nested_delimiter="__",
|
env_nested_delimiter="__",
|
||||||
@@ -74,6 +86,11 @@ class Settings(BaseSettings):
|
|||||||
frozen=True,
|
frozen=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, /, **values: Any) -> None:
|
||||||
|
if "_env_file" not in values:
|
||||||
|
values["_env_file"] = resolve_settings_env_file_path()
|
||||||
|
super().__init__(**values)
|
||||||
|
|
||||||
# --- NiceGUI Server ---
|
# --- NiceGUI Server ---
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
@@ -98,6 +115,7 @@ class Settings(BaseSettings):
|
|||||||
# --- runtime environment ---
|
# --- runtime environment ---
|
||||||
environment: Literal["development", "test", "production"] = "development"
|
environment: Literal["development", "test", "production"] = "development"
|
||||||
transcription_commit: NonEmptyStr | None = None
|
transcription_commit: NonEmptyStr | None = None
|
||||||
|
run_embedded_worker: bool = True
|
||||||
|
|
||||||
# --- persistence ---
|
# --- persistence ---
|
||||||
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
||||||
@@ -107,14 +125,13 @@ class Settings(BaseSettings):
|
|||||||
# --- filesystem paths ---
|
# --- filesystem paths ---
|
||||||
upload_dir: Path = Path("./data")
|
upload_dir: Path = Path("./data")
|
||||||
prompt_dir: Path = Path("./prompts")
|
prompt_dir: Path = Path("./prompts")
|
||||||
database_backup_dir: Path = Path("./data/backups")
|
|
||||||
|
|
||||||
# --- worker reliability ---
|
# --- worker reliability ---
|
||||||
worker_max_retries: int = Field(default=0, ge=0)
|
worker_max_retries: int = Field(default=0, ge=0)
|
||||||
# Bounded only from below. Vision transcription of a dense page routinely runs
|
# Bounded only from below. Vision transcription of a dense page routinely runs
|
||||||
# well past twenty seconds, so an upper cap here would silently fail real work.
|
# well past twenty seconds, so an upper cap here would silently fail real work.
|
||||||
worker_provider_timeout_seconds: float = Field(default=30.0, gt=0.0)
|
worker_provider_timeout_seconds: float = Field(default=30.0, gt=0.0)
|
||||||
worker_stale_job_seconds: float = Field(default=30.0, gt=0.0)
|
worker_stale_job_seconds: float = Field(default=90.0, gt=0.0)
|
||||||
worker_retry_backoff_seconds: float = Field(default=1.0, ge=0.0)
|
worker_retry_backoff_seconds: float = Field(default=1.0, ge=0.0)
|
||||||
worker_shutdown_grace_seconds: float = Field(default=5.0, ge=0.0)
|
worker_shutdown_grace_seconds: float = Field(default=5.0, ge=0.0)
|
||||||
worker_poll_interval_seconds: float = Field(default=1.0, gt=0.0)
|
worker_poll_interval_seconds: float = Field(default=1.0, gt=0.0)
|
||||||
@@ -169,6 +186,34 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
return {**data, "provider_model": default_model, "provider_models": tuple(deduplicated)}
|
return {**data, "provider_model": default_model, "provider_models": tuple(deduplicated)}
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _derive_worker_stale_job_seconds(cls, data: object) -> object:
|
||||||
|
"""Default stale-job recovery with margin over one provider timeout."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return data
|
||||||
|
if data.get("worker_stale_job_seconds") is not None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
timeout = data.get("worker_provider_timeout_seconds", 30.0)
|
||||||
|
if not isinstance(timeout, (str, int, float)):
|
||||||
|
return data
|
||||||
|
try:
|
||||||
|
timeout_seconds = float(timeout)
|
||||||
|
except ValueError:
|
||||||
|
return data
|
||||||
|
return {
|
||||||
|
**data,
|
||||||
|
"worker_stale_job_seconds": timeout_seconds * WORKER_STALE_TIMEOUT_MULTIPLIER,
|
||||||
|
}
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_worker_stale_job_seconds(self) -> "Settings":
|
||||||
|
"""Reject stale recovery that can fire before one provider timeout expires."""
|
||||||
|
if self.worker_stale_job_seconds <= self.worker_provider_timeout_seconds:
|
||||||
|
raise ValueError("WORKER_STALE_JOB_SECONDS must exceed WORKER_PROVIDER_TIMEOUT_SECONDS")
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_bootstrap_schema(self) -> bool:
|
def should_bootstrap_schema(self) -> bool:
|
||||||
"""Return whether startup should auto-create schema for this environment."""
|
"""Return whether startup should auto-create schema for this environment."""
|
||||||
|
|||||||
@@ -73,14 +73,3 @@ async def dispose_engine(database_url: str) -> None:
|
|||||||
engine = _ENGINES.pop(database_url, None)
|
engine = _ENGINES.pop(database_url, None)
|
||||||
if engine is not None:
|
if engine is not None:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
async def dispose_all_engines() -> None:
|
|
||||||
while _ENGINES:
|
|
||||||
_, engine = _ENGINES.popitem()
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_engine(database_url: str) -> AsyncEngine:
|
|
||||||
await dispose_engine(database_url)
|
|
||||||
return get_engine(database_url)
|
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ from uuid import uuid4
|
|||||||
from sqlalchemy import URL
|
from sqlalchemy import URL
|
||||||
from sqlalchemy import MetaData
|
from sqlalchemy import MetaData
|
||||||
from sqlalchemy import Table
|
from sqlalchemy import Table
|
||||||
|
from sqlalchemy import bindparam
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.engine import RowMapping
|
from sqlalchemy.engine import RowMapping
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
@@ -36,6 +39,8 @@ EXPORT_TABLE_ORDER = (
|
|||||||
"tag",
|
"tag",
|
||||||
"document",
|
"document",
|
||||||
"person",
|
"person",
|
||||||
|
"genealogy_person",
|
||||||
|
"genealogy_family",
|
||||||
"photo",
|
"photo",
|
||||||
"document_person",
|
"document_person",
|
||||||
"document_tag",
|
"document_tag",
|
||||||
@@ -44,9 +49,12 @@ EXPORT_TABLE_ORDER = (
|
|||||||
"source",
|
"source",
|
||||||
"job_source",
|
"job_source",
|
||||||
"execution_attempt",
|
"execution_attempt",
|
||||||
|
"genealogy_family_child",
|
||||||
|
"genealogy_citation",
|
||||||
)
|
)
|
||||||
|
|
||||||
BYTES_FIELDS = {"transport_body"}
|
BYTES_FIELDS = {"transport_body"}
|
||||||
|
VERIFICATION_TABLES = EXPORT_TABLE_ORDER
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -142,21 +150,74 @@ def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Pa
|
|||||||
engine = create_engine(target_db_url)
|
engine = create_engine(target_db_url)
|
||||||
try:
|
try:
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
execution_attempt_ids = _collect_execution_attempt_ids(payload)
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
for table_name in reversed(EXPORT_TABLE_ORDER):
|
for table_name in reversed(EXPORT_TABLE_ORDER):
|
||||||
table = SQLModel.metadata.tables[table_name]
|
table = SQLModel.metadata.tables[table_name]
|
||||||
connection.execute(table.delete())
|
connection.execute(table.delete())
|
||||||
|
|
||||||
|
deferred_source_preferred_attempt_updates: list[dict[str, Any]] = []
|
||||||
for table_name in EXPORT_TABLE_ORDER:
|
for table_name in EXPORT_TABLE_ORDER:
|
||||||
rows = payload.get("tables", {}).get(table_name, [])
|
rows = payload.get("tables", {}).get(table_name, [])
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
table = SQLModel.metadata.tables[table_name]
|
table = SQLModel.metadata.tables[table_name]
|
||||||
|
if table_name == "source":
|
||||||
|
prepared_source_rows, updates = _prepare_source_rows_for_import(
|
||||||
|
rows=rows,
|
||||||
|
source_table=table,
|
||||||
|
execution_attempt_ids=execution_attempt_ids,
|
||||||
|
)
|
||||||
|
deferred_source_preferred_attempt_updates.extend(updates)
|
||||||
|
connection.execute(table.insert(), prepared_source_rows)
|
||||||
|
continue
|
||||||
connection.execute(table.insert(), [_deserialize_row(row, table) for row in rows])
|
connection.execute(table.insert(), [_deserialize_row(row, table) for row in rows])
|
||||||
|
|
||||||
|
if deferred_source_preferred_attempt_updates:
|
||||||
|
source_table = SQLModel.metadata.tables["source"]
|
||||||
|
connection.execute(
|
||||||
|
source_table.update()
|
||||||
|
.where(source_table.c.id == bindparam("source_id"))
|
||||||
|
.values(preferred_execution_attempt_id=bindparam("preferred_execution_attempt_id")),
|
||||||
|
deferred_source_preferred_attempt_updates,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_execution_attempt_ids(payload: dict[str, Any]) -> set[str]:
|
||||||
|
execution_attempt_rows = payload.get("tables", {}).get("execution_attempt", [])
|
||||||
|
return {_normalize_uuid_like(row.get("id")) for row in execution_attempt_rows if row.get("id") is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_source_rows_for_import(
|
||||||
|
*,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
source_table: Table,
|
||||||
|
execution_attempt_ids: set[str],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
prepared_source_rows: list[dict[str, Any]] = []
|
||||||
|
updates: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
source_row = _deserialize_row(row, source_table)
|
||||||
|
source_id = source_row.get("id")
|
||||||
|
preferred_attempt_id = source_row.get("preferred_execution_attempt_id")
|
||||||
|
if (
|
||||||
|
source_id is not None
|
||||||
|
and preferred_attempt_id is not None
|
||||||
|
and _normalize_uuid_like(preferred_attempt_id) in execution_attempt_ids
|
||||||
|
):
|
||||||
|
updates.append(
|
||||||
|
{
|
||||||
|
"source_id": source_id,
|
||||||
|
"preferred_execution_attempt_id": preferred_attempt_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
source_row["preferred_execution_attempt_id"] = None
|
||||||
|
prepared_source_rows.append(source_row)
|
||||||
|
return prepared_source_rows, updates
|
||||||
|
|
||||||
|
|
||||||
def _ensure_sqlite_target_parent_exists(target_db_url: str) -> None:
|
def _ensure_sqlite_target_parent_exists(target_db_url: str) -> None:
|
||||||
parsed = make_url(target_db_url)
|
parsed = make_url(target_db_url)
|
||||||
if not parsed.drivername.startswith("sqlite"):
|
if not parsed.drivername.startswith("sqlite"):
|
||||||
@@ -192,6 +253,24 @@ def migrate_via_bundle(paths: MigrationPaths) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MigrationVerificationReport:
|
||||||
|
source_counts: dict[str, int]
|
||||||
|
target_counts: dict[str, int]
|
||||||
|
mismatched_tables: dict[str, dict[str, int]]
|
||||||
|
integrity_violations: dict[str, int]
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"success": self.success,
|
||||||
|
"source_counts": self.source_counts,
|
||||||
|
"target_counts": self.target_counts,
|
||||||
|
"mismatched_tables": self.mismatched_tables,
|
||||||
|
"integrity_violations": self.integrity_violations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def sqlite_url_from_path(path: Path) -> str:
|
def sqlite_url_from_path(path: Path) -> str:
|
||||||
return URL.create(drivername="sqlite", database=str(path)).render_as_string(hide_password=False)
|
return URL.create(drivername="sqlite", database=str(path)).render_as_string(hide_password=False)
|
||||||
|
|
||||||
@@ -201,6 +280,87 @@ def default_sync_db_url(settings: Settings | None = None) -> str:
|
|||||||
return get_database_url(runtime_settings).replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "")
|
return get_database_url(runtime_settings).replace("+aiosqlite", "").replace("+asyncpg", "").replace("+psycopg", "")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_migration(*, source_db_url: str, target_db_url: str) -> MigrationVerificationReport:
|
||||||
|
source_counts = _table_counts(source_db_url)
|
||||||
|
target_counts = _table_counts(target_db_url)
|
||||||
|
mismatched_tables = {
|
||||||
|
table_name: {"source": source_counts[table_name], "target": target_counts[table_name]}
|
||||||
|
for table_name in VERIFICATION_TABLES
|
||||||
|
if source_counts[table_name] != target_counts[table_name]
|
||||||
|
}
|
||||||
|
integrity_violations = _integrity_violations(target_db_url)
|
||||||
|
success = not mismatched_tables and all(count == 0 for count in integrity_violations.values())
|
||||||
|
return MigrationVerificationReport(
|
||||||
|
source_counts=source_counts,
|
||||||
|
target_counts=target_counts,
|
||||||
|
mismatched_tables=mismatched_tables,
|
||||||
|
integrity_violations=integrity_violations,
|
||||||
|
success=success,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_counts(db_url: str) -> dict[str, int]:
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
try:
|
||||||
|
metadata = MetaData()
|
||||||
|
metadata.reflect(bind=engine)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
with engine.connect() as connection:
|
||||||
|
for table_name in VERIFICATION_TABLES:
|
||||||
|
table = metadata.tables.get(table_name)
|
||||||
|
if table is None:
|
||||||
|
counts[table_name] = 0
|
||||||
|
continue
|
||||||
|
counts[table_name] = int(connection.execute(select(func.count()).select_from(table)).scalar_one())
|
||||||
|
return counts
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _integrity_violations(db_url: str) -> dict[str, int]:
|
||||||
|
checks = {
|
||||||
|
"orphan_source_document": (
|
||||||
|
"select count(*) from source s left join document d on d.id = s.document_id where d.id is null"
|
||||||
|
),
|
||||||
|
"orphan_job_document": (
|
||||||
|
"select count(*) from job j left join document d on d.id = j.document_id where d.id is null"
|
||||||
|
),
|
||||||
|
"orphan_job_source_job": (
|
||||||
|
"select count(*) from job_source js left join job j on j.id = js.job_id where j.id is null"
|
||||||
|
),
|
||||||
|
"orphan_job_source_source": (
|
||||||
|
"select count(*) from job_source js left join source s on s.id = js.source_id where s.id is null"
|
||||||
|
),
|
||||||
|
"orphan_attempt_job_source": (
|
||||||
|
"select count(*) from execution_attempt ea "
|
||||||
|
"left join job_source js on js.id = ea.job_source_id "
|
||||||
|
"where js.id is null"
|
||||||
|
),
|
||||||
|
"orphan_attempt_job": (
|
||||||
|
"select count(*) from execution_attempt ea left join job j on j.id = ea.job_id where j.id is null"
|
||||||
|
),
|
||||||
|
"orphan_attempt_source": (
|
||||||
|
"select count(*) from execution_attempt ea left join source s on s.id = ea.source_id where s.id is null"
|
||||||
|
),
|
||||||
|
"duplicate_attempt_numbers": (
|
||||||
|
"select count(*) from ("
|
||||||
|
" select job_id, source_id, attempt_number, count(*) as c"
|
||||||
|
" from execution_attempt"
|
||||||
|
" group by job_id, source_id, attempt_number"
|
||||||
|
" having count(*) > 1"
|
||||||
|
") x"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
return {
|
||||||
|
check_name: int(connection.execute(text(query)).scalar_one()) for check_name, query in checks.items()
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def _serialize_row(row: RowMapping, *, table_name: str, source_upload_dir: Path) -> dict[str, Any]:
|
def _serialize_row(row: RowMapping, *, table_name: str, source_upload_dir: Path) -> dict[str, Any]:
|
||||||
serialized: dict[str, Any] = {}
|
serialized: dict[str, Any] = {}
|
||||||
for raw_key, value in row.items():
|
for raw_key, value in row.items():
|
||||||
@@ -283,6 +443,18 @@ def _deserialize_value(python_type: type[Any], value: Any) -> Any:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_uuid_like(value: Any) -> str:
|
||||||
|
if isinstance(value, UUID):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
text_value = value.strip()
|
||||||
|
try:
|
||||||
|
return str(UUID(text_value))
|
||||||
|
except ValueError:
|
||||||
|
return text_value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, preferred_prefix: str) -> str:
|
def _canonical_media_relative_path(value: str, *, source_upload_dir: Path, preferred_prefix: str) -> str:
|
||||||
normalized = value.strip().replace("\\", "/")
|
normalized = value.strip().replace("\\", "/")
|
||||||
lowered = normalized.casefold()
|
lowered = normalized.casefold()
|
||||||
|
|||||||
+243
-32
@@ -46,6 +46,11 @@ def _loaded_attribute(instance: object, attribute: str) -> Any | None:
|
|||||||
return state.dict.get(attribute)
|
return state.dict.get(attribute)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
"""Return a UTC timestamp stored as a naive datetime."""
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
class JSONBCompat(TypeDecorator):
|
class JSONBCompat(TypeDecorator):
|
||||||
"""JSONB for PostgreSQL and JSON for SQLite/testing backends."""
|
"""JSONB for PostgreSQL and JSON for SQLite/testing backends."""
|
||||||
|
|
||||||
@@ -77,6 +82,31 @@ class JobPurpose(StrEnum):
|
|||||||
RETRANSCRIPTION = "retranscription"
|
RETRANSCRIPTION = "retranscription"
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceJobType(StrEnum):
|
||||||
|
BACKUP = "backup"
|
||||||
|
STORAGE_RECONCILIATION = "storage_reconciliation"
|
||||||
|
GEDCOM_IMPORT = "gedcom_import"
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceRunStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
SUCCEEDED = "succeeded"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class GenealogyCitationFactType(StrEnum):
|
||||||
|
BIRTH = "birth"
|
||||||
|
DEATH = "death"
|
||||||
|
MARRIAGE = "marriage"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class GenealogyCitationSourceKind(StrEnum):
|
||||||
|
FAMILYSEARCH_IMPORTED = "familysearch_imported"
|
||||||
|
TRANSCRIPTION_EVIDENCE = "transcription_evidence"
|
||||||
|
|
||||||
|
|
||||||
class DocumentType(SQLModel, table=True):
|
class DocumentType(SQLModel, table=True):
|
||||||
"""Registry of allowed document types."""
|
"""Registry of allowed document types."""
|
||||||
|
|
||||||
@@ -87,10 +117,10 @@ class DocumentType(SQLModel, table=True):
|
|||||||
label: str
|
label: str
|
||||||
normalized_label: str = Field(index=True, unique=True)
|
normalized_label: str = Field(index=True, unique=True)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
documents: list["Document"] = Relationship(
|
documents: list["Document"] = Relationship(
|
||||||
@@ -108,10 +138,10 @@ class PersonRole(SQLModel, table=True):
|
|||||||
label: str
|
label: str
|
||||||
normalized_label: str = Field(index=True, unique=True)
|
normalized_label: str = Field(index=True, unique=True)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
document_people: list["DocumentPerson"] = Relationship(
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
@@ -129,10 +159,10 @@ class Tag(SQLModel, table=True):
|
|||||||
label: str
|
label: str
|
||||||
normalized_label: str = Field(index=True, unique=True)
|
normalized_label: str = Field(index=True, unique=True)
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
document_tags: list["DocumentTag"] = Relationship(
|
document_tags: list["DocumentTag"] = Relationship(
|
||||||
@@ -156,10 +186,10 @@ class Document(SQLModel, table=True):
|
|||||||
location_created: str | None = None
|
location_created: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
archive_identifier: str | None = None
|
archive_identifier: str | None = None
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "raise"})
|
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "raise"})
|
||||||
@@ -194,10 +224,10 @@ class Person(SQLModel, table=True):
|
|||||||
default=None,
|
default=None,
|
||||||
sa_column=Column("metadata", JSONBCompat(), nullable=True),
|
sa_column=Column("metadata", JSONBCompat(), nullable=True),
|
||||||
)
|
)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
document_people: list["DocumentPerson"] = Relationship(
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
@@ -218,6 +248,146 @@ class Person(SQLModel, table=True):
|
|||||||
return f"{self.given_names} {self.last_name}".strip()
|
return f"{self.given_names} {self.last_name}".strip()
|
||||||
|
|
||||||
|
|
||||||
|
class GenealogyPerson(SQLModel, table=True):
|
||||||
|
"""An individual imported from a GEDCOM export."""
|
||||||
|
|
||||||
|
__tablename__ = "genealogy_person"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
fs_id: str = Field(index=True, unique=True)
|
||||||
|
full_name: str
|
||||||
|
birth_date: date | None = None
|
||||||
|
birth_date_raw: str | None = None
|
||||||
|
birth_place: str | None = None
|
||||||
|
death_date: date | None = None
|
||||||
|
death_date_raw: str | None = None
|
||||||
|
death_place: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=_utc_now_naive,
|
||||||
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
|
)
|
||||||
|
|
||||||
|
husband_families: list["GenealogyFamily"] = Relationship(
|
||||||
|
back_populates="husband",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "foreign_keys": "[GenealogyFamily.husband_id]"},
|
||||||
|
)
|
||||||
|
wife_families: list["GenealogyFamily"] = Relationship(
|
||||||
|
back_populates="wife",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "foreign_keys": "[GenealogyFamily.wife_id]"},
|
||||||
|
)
|
||||||
|
child_family_memberships: list["GenealogyFamilyChild"] = Relationship(
|
||||||
|
back_populates="child",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
citations: list["GenealogyCitation"] = Relationship(
|
||||||
|
back_populates="genealogy_person",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenealogyFamily(SQLModel, table=True):
|
||||||
|
"""A family linking two GenealogyPerson records."""
|
||||||
|
|
||||||
|
__tablename__ = "genealogy_family"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
fs_family_id: str = Field(index=True, unique=True)
|
||||||
|
husband_id: UUID | None = Field(default=None, foreign_key="genealogy_person.id", index=True)
|
||||||
|
wife_id: UUID | None = Field(default=None, foreign_key="genealogy_person.id", index=True)
|
||||||
|
marriage_date: date | None = None
|
||||||
|
marriage_date_raw: str | None = None
|
||||||
|
marriage_place: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=_utc_now_naive,
|
||||||
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
|
)
|
||||||
|
|
||||||
|
husband: Optional["GenealogyPerson"] = Relationship(
|
||||||
|
back_populates="husband_families",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "foreign_keys": "[GenealogyFamily.husband_id]"},
|
||||||
|
)
|
||||||
|
wife: Optional["GenealogyPerson"] = Relationship(
|
||||||
|
back_populates="wife_families",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise", "foreign_keys": "[GenealogyFamily.wife_id]"},
|
||||||
|
)
|
||||||
|
children: list["GenealogyFamilyChild"] = Relationship(
|
||||||
|
back_populates="family",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
citations: list["GenealogyCitation"] = Relationship(
|
||||||
|
back_populates="genealogy_family",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenealogyFamilyChild(SQLModel, table=True):
|
||||||
|
"""Junction table for child membership within a genealogy family."""
|
||||||
|
|
||||||
|
__tablename__ = "genealogy_family_child"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
family_id: UUID = Field(foreign_key="genealogy_family.id", index=True)
|
||||||
|
child_id: UUID = Field(foreign_key="genealogy_person.id", index=True)
|
||||||
|
relationship_type: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("family_id", "child_id", name="uq_genealogy_family_child"),)
|
||||||
|
|
||||||
|
family: Optional["GenealogyFamily"] = Relationship(
|
||||||
|
back_populates="children",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
child: Optional["GenealogyPerson"] = Relationship(
|
||||||
|
back_populates="child_family_memberships",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenealogyCitation(SQLModel, table=True):
|
||||||
|
"""A source citation attached to a genealogical fact."""
|
||||||
|
|
||||||
|
__tablename__ = "genealogy_citation"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
genealogy_person_id: UUID | None = Field(default=None, foreign_key="genealogy_person.id", index=True)
|
||||||
|
genealogy_family_id: UUID | None = Field(default=None, foreign_key="genealogy_family.id", index=True)
|
||||||
|
fact_type: GenealogyCitationFactType = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
GenealogyCitationFactType,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raw_citation_text: str
|
||||||
|
source_kind: GenealogyCitationSourceKind = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
GenealogyCitationSourceKind,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
document_id: UUID | None = Field(default=None, foreign_key="document.id", index=True)
|
||||||
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
|
|
||||||
|
genealogy_person: Optional["GenealogyPerson"] = Relationship(
|
||||||
|
back_populates="citations",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
genealogy_family: Optional["GenealogyFamily"] = Relationship(
|
||||||
|
back_populates="citations",
|
||||||
|
sa_relationship_kwargs={"lazy": "raise"},
|
||||||
|
)
|
||||||
|
document: Optional["Document"] = Relationship(sa_relationship_kwargs={"lazy": "raise"})
|
||||||
|
|
||||||
|
|
||||||
class Photo(SQLModel, table=True):
|
class Photo(SQLModel, table=True):
|
||||||
"""A reusable image record for Person and homepage galleries."""
|
"""A reusable image record for Person and homepage galleries."""
|
||||||
|
|
||||||
@@ -228,10 +398,10 @@ class Photo(SQLModel, table=True):
|
|||||||
path: str
|
path: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
is_primary: bool = False
|
is_primary: bool = False
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
person: Optional["Person"] = Relationship(
|
person: Optional["Person"] = Relationship(
|
||||||
@@ -249,10 +419,10 @@ class DocumentPerson(SQLModel, table=True):
|
|||||||
document_id: UUID = Field(foreign_key="document.id", index=True)
|
document_id: UUID = Field(foreign_key="document.id", index=True)
|
||||||
person_id: UUID = Field(foreign_key="person.id", index=True)
|
person_id: UUID = Field(foreign_key="person.id", index=True)
|
||||||
role_id: UUID = Field(foreign_key="person_role.id", index=True)
|
role_id: UUID = Field(foreign_key="person_role.id", index=True)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("document_id", "person_id", name="uq_document_person"),)
|
__table_args__ = (UniqueConstraint("document_id", "person_id", name="uq_document_person"),)
|
||||||
@@ -276,10 +446,10 @@ class DocumentTag(SQLModel, table=True):
|
|||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
document_id: UUID = Field(foreign_key="document.id", index=True)
|
document_id: UUID = Field(foreign_key="document.id", index=True)
|
||||||
tag_id: UUID = Field(foreign_key="tag.id", index=True)
|
tag_id: UUID = Field(foreign_key="tag.id", index=True)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("document_id", "tag_id", name="uq_document_tag"),)
|
__table_args__ = (UniqueConstraint("document_id", "tag_id", name="uq_document_tag"),)
|
||||||
@@ -302,10 +472,10 @@ class PersonTag(SQLModel, table=True):
|
|||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
person_id: UUID = Field(foreign_key="person.id", index=True)
|
person_id: UUID = Field(foreign_key="person.id", index=True)
|
||||||
tag_id: UUID = Field(foreign_key="tag.id", index=True)
|
tag_id: UUID = Field(foreign_key="tag.id", index=True)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
updated_at: datetime = Field(
|
updated_at: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("person_id", "tag_id", name="uq_person_tag"),)
|
__table_args__ = (UniqueConstraint("person_id", "tag_id", name="uq_person_tag"),)
|
||||||
@@ -351,10 +521,10 @@ class Job(SQLModel, table=True):
|
|||||||
default=JobPurpose.TRANSCRIPTION.value,
|
default=JobPurpose.TRANSCRIPTION.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
date_created: datetime = Field(default_factory=_utc_now_naive)
|
||||||
date_updated: datetime = Field(
|
date_updated: datetime = Field(
|
||||||
default_factory=lambda: datetime.now(UTC),
|
default_factory=_utc_now_naive,
|
||||||
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
)
|
)
|
||||||
provider: str | None = None
|
provider: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
@@ -385,6 +555,47 @@ class Job(SQLModel, table=True):
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceRun(SQLModel, table=True):
|
||||||
|
"""A queued/processed maintenance task execution record."""
|
||||||
|
|
||||||
|
__tablename__ = "maintenance_run"
|
||||||
|
|
||||||
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
job_type: MaintenanceJobType = Field(
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
MaintenanceJobType,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
status: MaintenanceRunStatus = Field(
|
||||||
|
default=MaintenanceRunStatus.QUEUED,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(
|
||||||
|
MaintenanceRunStatus,
|
||||||
|
values_callable=lambda enum_cls: [item.value for item in enum_cls],
|
||||||
|
native_enum=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
default=MaintenanceRunStatus.QUEUED.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
triggered_by: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
log_path: str | None = None
|
||||||
|
error_detail: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=_utc_now_naive,
|
||||||
|
sa_column_kwargs={"onupdate": _utc_now_naive},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Source(SQLModel, table=True):
|
class Source(SQLModel, table=True):
|
||||||
"""A document source image or PDF page."""
|
"""A document source image or PDF page."""
|
||||||
|
|
||||||
@@ -413,7 +624,7 @@ class Source(SQLModel, table=True):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
revised_text: str | None = None
|
revised_text: str | None = None
|
||||||
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
date_uploaded: datetime = Field(default_factory=_utc_now_naive)
|
||||||
date_revised: datetime | None = None
|
date_revised: datetime | None = None
|
||||||
|
|
||||||
document: Optional["Document"] = Relationship(
|
document: Optional["Document"] = Relationship(
|
||||||
@@ -551,7 +762,7 @@ class ExecutionAttempt(SQLModel, table=True):
|
|||||||
started_at: datetime
|
started_at: datetime
|
||||||
finished_at: datetime
|
finished_at: datetime
|
||||||
duration_ms: int = Field(ge=0)
|
duration_ms: int = Field(ge=0)
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
created_at: datetime = Field(default_factory=_utc_now_naive)
|
||||||
|
|
||||||
job_source: Optional["JobSource"] = Relationship(
|
job_source: Optional["JobSource"] = Relationship(
|
||||||
back_populates="execution_attempts", sa_relationship_kwargs={"lazy": "raise"}
|
back_populates="execution_attempts", sa_relationship_kwargs={"lazy": "raise"}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ def new_error_id() -> str:
|
|||||||
return uuid4().hex[:8]
|
return uuid4().hex[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def exception_detail(exc: BaseException) -> str:
|
||||||
|
"""Return internal-only root-cause text for persisted diagnostics."""
|
||||||
|
return f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
class AppError(RuntimeError):
|
class AppError(RuntimeError):
|
||||||
"""Base application error carrying user-safe handling metadata."""
|
"""Base application error carrying user-safe handling metadata."""
|
||||||
|
|
||||||
@@ -115,7 +120,7 @@ def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
|
|||||||
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
||||||
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
||||||
retriable=False,
|
retriable=False,
|
||||||
detail=f"{type(exc).__name__}: {exc}",
|
detail=exception_detail(exc),
|
||||||
)
|
)
|
||||||
logger.error(
|
logger.error(
|
||||||
"Unexpected error operation=%s error_id=%s",
|
"Unexpected error operation=%s error_id=%s",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from transcription.config import Provider
|
|||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.providers.base import ProviderAuthError
|
from transcription.providers.base import ProviderAuthError
|
||||||
|
from transcription.providers.base import ProviderCallEvidence
|
||||||
from transcription.providers.base import ProviderError
|
from transcription.providers.base import ProviderError
|
||||||
from transcription.providers.base import ProviderResponseError
|
from transcription.providers.base import ProviderResponseError
|
||||||
from transcription.providers.base import TranscriptionMetadata
|
from transcription.providers.base import TranscriptionMetadata
|
||||||
@@ -27,6 +28,7 @@ def get_transcription_provider(*, settings: Settings | None = None) -> Transcrip
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"OpenRouterTranscriptionProvider",
|
"OpenRouterTranscriptionProvider",
|
||||||
"ProviderAuthError",
|
"ProviderAuthError",
|
||||||
|
"ProviderCallEvidence",
|
||||||
"ProviderError",
|
"ProviderError",
|
||||||
"ProviderResponseError",
|
"ProviderResponseError",
|
||||||
"RequestManifest",
|
"RequestManifest",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Provider interfaces and validated shared contracts for transcription adapters."""
|
"""Provider interfaces and validated shared contracts for transcription adapters."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -37,6 +38,14 @@ class ProviderResponseError(ProviderError):
|
|||||||
"""Raised when provider responses are malformed or unusable."""
|
"""Raised when provider responses are malformed or unusable."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ProviderCallEvidence:
|
||||||
|
"""Caller-owned evidence sink for one provider invocation."""
|
||||||
|
|
||||||
|
request_manifest: RequestManifest | None = None
|
||||||
|
transport_evidence: TransportEvidence | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProviderUsage(BaseModel):
|
class ProviderUsage(BaseModel):
|
||||||
"""Normalized provider token accounting."""
|
"""Normalized provider token accounting."""
|
||||||
|
|
||||||
@@ -107,16 +116,6 @@ class TranscriptionProvider(Protocol):
|
|||||||
"""Return the resolved model slug this adapter will call."""
|
"""Return the resolved model slug this adapter will call."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@property
|
|
||||||
def current_request_manifest(self) -> RequestManifest | None:
|
|
||||||
"""Return the manifest for the most recent call, for failure evidence."""
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_transport_evidence(self) -> TransportEvidence | None:
|
|
||||||
"""Return transport-level evidence for the most recent call."""
|
|
||||||
...
|
|
||||||
|
|
||||||
async def transcribe(
|
async def transcribe(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -127,8 +126,9 @@ class TranscriptionProvider(Protocol):
|
|||||||
top_p: float | None = None,
|
top_p: float | None = None,
|
||||||
source_reference: SourceEvidenceReference | None = None,
|
source_reference: SourceEvidenceReference | None = None,
|
||||||
requested_model: str | None = None,
|
requested_model: str | None = None,
|
||||||
|
evidence_capture: ProviderCallEvidence | None = None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
"""Transcribe the provided image according to the prompt text."""
|
"""Transcribe one source and write failure evidence into the provided capture sink."""
|
||||||
...
|
...
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import contextvars
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -25,6 +27,7 @@ from pydantic import ValidationError
|
|||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.providers.base import ProviderAuthError
|
from transcription.providers.base import ProviderAuthError
|
||||||
|
from transcription.providers.base import ProviderCallEvidence
|
||||||
from transcription.providers.base import ProviderError
|
from transcription.providers.base import ProviderError
|
||||||
from transcription.providers.base import ProviderResponseError
|
from transcription.providers.base import ProviderResponseError
|
||||||
from transcription.providers.base import ProviderUsage
|
from transcription.providers.base import ProviderUsage
|
||||||
@@ -65,19 +68,24 @@ class _CapturingAsyncClient:
|
|||||||
|
|
||||||
def __init__(self, client: httpx.AsyncClient):
|
def __init__(self, client: httpx.AsyncClient):
|
||||||
self._client = client
|
self._client = client
|
||||||
self.last_response: httpx.Response | None = None
|
self._active_capture: contextvars.ContextVar[_TransportCapture | None] = contextvars.ContextVar(
|
||||||
self.last_body: bytes | None = None
|
"openrouter_transport_capture",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
|
async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
|
||||||
|
capture = self._active_capture.get()
|
||||||
response = await self._client.send(request, **kwargs)
|
response = await self._client.send(request, **kwargs)
|
||||||
self.last_response = response
|
if capture is None:
|
||||||
|
return response
|
||||||
|
capture.response = response
|
||||||
try:
|
try:
|
||||||
self.last_body = response.content
|
capture.body = response.content
|
||||||
except httpx.ResponseNotRead:
|
except httpx.ResponseNotRead:
|
||||||
stream = response.stream
|
stream = response.stream
|
||||||
if not isinstance(stream, httpx.AsyncByteStream):
|
if not isinstance(stream, httpx.AsyncByteStream):
|
||||||
raise
|
raise
|
||||||
response.stream = _CapturingAsyncByteStream(stream, self._capture_body)
|
response.stream = _CapturingAsyncByteStream(stream, lambda body: self._capture_body(capture, body))
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
|
def build_request(self, *args: Any, **kwargs: Any) -> httpx.Request:
|
||||||
@@ -86,12 +94,21 @@ class _CapturingAsyncClient:
|
|||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
|
|
||||||
def reset(self) -> None:
|
def begin_capture(self, capture: _TransportCapture) -> contextvars.Token[_TransportCapture | None]:
|
||||||
self.last_response = None
|
return self._active_capture.set(capture)
|
||||||
self.last_body = None
|
|
||||||
|
|
||||||
def _capture_body(self, body: bytes) -> None:
|
def end_capture(self, token: contextvars.Token[_TransportCapture | None]) -> None:
|
||||||
self.last_body = body
|
self._active_capture.reset(token)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _capture_body(capture: _TransportCapture, body: bytes) -> None:
|
||||||
|
capture.body = body
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _TransportCapture:
|
||||||
|
response: httpx.Response | None = None
|
||||||
|
body: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
class _ProviderModel(BaseModel):
|
class _ProviderModel(BaseModel):
|
||||||
@@ -195,8 +212,6 @@ class OpenRouterTranscriptionProvider:
|
|||||||
self._settings = settings or get_settings()
|
self._settings = settings or get_settings()
|
||||||
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
|
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
|
||||||
self._capturing_client: _CapturingAsyncClient | None = None
|
self._capturing_client: _CapturingAsyncClient | None = None
|
||||||
self._current_request_manifest: RequestManifest | None = None
|
|
||||||
self._current_transport_evidence: TransportEvidence | None = None
|
|
||||||
if client is None:
|
if client is None:
|
||||||
# httpx defaults every phase to 5s, which silently caps provider calls far
|
# httpx defaults every phase to 5s, which silently caps provider calls far
|
||||||
# below worker_provider_timeout_seconds. Track the configured budget instead.
|
# below worker_provider_timeout_seconds. Track the configured budget instead.
|
||||||
@@ -218,18 +233,6 @@ class OpenRouterTranscriptionProvider:
|
|||||||
"""Return the resolved OpenRouter model slug."""
|
"""Return the resolved OpenRouter model slug."""
|
||||||
return self._model
|
return self._model
|
||||||
|
|
||||||
@property
|
|
||||||
def current_request_manifest(self) -> RequestManifest | None:
|
|
||||||
return self._current_request_manifest
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_transport_evidence(self) -> TransportEvidence | None:
|
|
||||||
if self._current_transport_evidence is not None:
|
|
||||||
return self._current_transport_evidence
|
|
||||||
if self._current_request_manifest is None:
|
|
||||||
return None
|
|
||||||
return self._captured_transport_evidence()
|
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
if self._capturing_client is not None:
|
if self._capturing_client is not None:
|
||||||
await self._capturing_client.aclose()
|
await self._capturing_client.aclose()
|
||||||
@@ -244,6 +247,7 @@ class OpenRouterTranscriptionProvider:
|
|||||||
top_p: float | None = None,
|
top_p: float | None = None,
|
||||||
source_reference: SourceEvidenceReference | None = None,
|
source_reference: SourceEvidenceReference | None = None,
|
||||||
requested_model: str | None = None,
|
requested_model: str | None = None,
|
||||||
|
evidence_capture: ProviderCallEvidence | None = None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
"""Send prompt + image to OpenRouter and return normalized text output."""
|
"""Send prompt + image to OpenRouter and return normalized text output."""
|
||||||
request = self._build_request(
|
request = self._build_request(
|
||||||
@@ -261,79 +265,87 @@ class OpenRouterTranscriptionProvider:
|
|||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=top_p,
|
top_p=top_p,
|
||||||
)
|
)
|
||||||
self._current_request_manifest = manifest
|
if evidence_capture is not None:
|
||||||
self._current_transport_evidence = None
|
evidence_capture.request_manifest = manifest
|
||||||
if self._capturing_client is not None:
|
evidence_capture.transport_evidence = None
|
||||||
self._capturing_client.reset()
|
transport_capture = _TransportCapture()
|
||||||
|
token = self._capturing_client.begin_capture(transport_capture) if self._capturing_client is not None else None
|
||||||
try:
|
try:
|
||||||
response = await self._client.chat.send_async(
|
try:
|
||||||
**request.model_dump(mode="json", exclude_none=True),
|
response = await self._client.chat.send_async(
|
||||||
retries=None,
|
**request.model_dump(mode="json", exclude_none=True),
|
||||||
)
|
retries=None,
|
||||||
except Exception as exc:
|
)
|
||||||
transport = self._captured_transport_evidence()
|
except Exception as exc:
|
||||||
self._current_transport_evidence = transport
|
transport = self._captured_transport_evidence(transport_capture)
|
||||||
if isinstance(exc, openrouter_errors.UnauthorizedResponseError):
|
if evidence_capture is not None:
|
||||||
raise ProviderAuthError(
|
evidence_capture.transport_evidence = transport
|
||||||
"OpenRouter authentication failed",
|
if isinstance(exc, openrouter_errors.UnauthorizedResponseError):
|
||||||
|
raise ProviderAuthError(
|
||||||
|
"OpenRouter authentication failed",
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
|
failure_phase="http_response" if transport.response_received else "connection",
|
||||||
|
) from exc
|
||||||
|
failure_phase = (
|
||||||
|
"response_validation"
|
||||||
|
if isinstance(exc, openrouter_errors.ResponseValidationError)
|
||||||
|
else "http_response"
|
||||||
|
if transport.response_received
|
||||||
|
else "connection"
|
||||||
|
)
|
||||||
|
raise ProviderError(
|
||||||
|
self._transport_error_message(transport),
|
||||||
request_manifest=manifest,
|
request_manifest=manifest,
|
||||||
transport_evidence=transport,
|
transport_evidence=transport,
|
||||||
failure_phase="http_response" if transport.response_received else "connection",
|
failure_phase=failure_phase,
|
||||||
) from exc
|
) from exc
|
||||||
failure_phase = (
|
|
||||||
"response_validation"
|
transport = self._captured_transport_evidence(transport_capture)
|
||||||
if isinstance(exc, openrouter_errors.ResponseValidationError)
|
if evidence_capture is not None:
|
||||||
else "http_response"
|
evidence_capture.transport_evidence = transport
|
||||||
if transport.response_received
|
raw_api_response = self._coerce_raw_response(response)
|
||||||
else "connection"
|
try:
|
||||||
|
validated_response = OpenRouterResponse.model_validate(raw_api_response)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ProviderResponseError(
|
||||||
|
"OpenRouter response failed schema validation",
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
|
failure_phase="response_validation",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = self._extract_text(validated_response)
|
||||||
|
except ProviderResponseError as exc:
|
||||||
|
raise ProviderResponseError(
|
||||||
|
str(exc),
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
|
failure_phase="response_validation",
|
||||||
|
) from exc
|
||||||
|
model = validated_response.model or requested_model or self.model
|
||||||
|
metadata = self._build_metadata(validated_response)
|
||||||
|
logger.info("OpenRouter transcription completed using model=%s", model)
|
||||||
|
return TranscriptionResult(
|
||||||
|
text=text,
|
||||||
|
provider="openrouter",
|
||||||
|
prompt_name=None,
|
||||||
|
prompt_hash=None,
|
||||||
|
system_prompt=None,
|
||||||
|
user_prompt=prompt_text,
|
||||||
|
temperature=temperature,
|
||||||
|
top_p=top_p,
|
||||||
|
model=model,
|
||||||
|
metadata=metadata,
|
||||||
|
raw_api_response=raw_api_response,
|
||||||
|
request_manifest=manifest,
|
||||||
|
transport_evidence=transport,
|
||||||
)
|
)
|
||||||
raise ProviderError(
|
finally:
|
||||||
self._transport_error_message(transport),
|
client = self._capturing_client
|
||||||
request_manifest=manifest,
|
if token is not None and client is not None:
|
||||||
transport_evidence=transport,
|
client.end_capture(token)
|
||||||
failure_phase=failure_phase,
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
transport = self._captured_transport_evidence()
|
|
||||||
self._current_transport_evidence = transport
|
|
||||||
raw_api_response = self._coerce_raw_response(response)
|
|
||||||
try:
|
|
||||||
validated_response = OpenRouterResponse.model_validate(raw_api_response)
|
|
||||||
except ValidationError as exc:
|
|
||||||
raise ProviderResponseError(
|
|
||||||
"OpenRouter response failed schema validation",
|
|
||||||
request_manifest=manifest,
|
|
||||||
transport_evidence=transport,
|
|
||||||
failure_phase="response_validation",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
text = self._extract_text(validated_response)
|
|
||||||
except ProviderResponseError as exc:
|
|
||||||
raise ProviderResponseError(
|
|
||||||
str(exc),
|
|
||||||
request_manifest=manifest,
|
|
||||||
transport_evidence=transport,
|
|
||||||
failure_phase="response_validation",
|
|
||||||
) from exc
|
|
||||||
model = validated_response.model or requested_model or self.model
|
|
||||||
metadata = self._build_metadata(validated_response)
|
|
||||||
logger.info("OpenRouter transcription completed using model=%s", model)
|
|
||||||
return TranscriptionResult(
|
|
||||||
text=text,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name=None,
|
|
||||||
prompt_hash=None,
|
|
||||||
system_prompt=None,
|
|
||||||
user_prompt=prompt_text,
|
|
||||||
temperature=temperature,
|
|
||||||
top_p=top_p,
|
|
||||||
model=model,
|
|
||||||
metadata=metadata,
|
|
||||||
raw_api_response=raw_api_response,
|
|
||||||
request_manifest=manifest,
|
|
||||||
transport_evidence=transport,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_request_manifest(
|
def _build_request_manifest(
|
||||||
self,
|
self,
|
||||||
@@ -345,6 +357,7 @@ class OpenRouterTranscriptionProvider:
|
|||||||
top_p: float | None,
|
top_p: float | None,
|
||||||
) -> RequestManifest | None:
|
) -> RequestManifest | None:
|
||||||
if source_reference is None:
|
if source_reference is None:
|
||||||
|
logger.warning("OpenRouter request manifest omitted because source evidence reference is missing.")
|
||||||
return None
|
return None
|
||||||
request_payload = request.model_dump(mode="json", exclude_none=True)
|
request_payload = request.model_dump(mode="json", exclude_none=True)
|
||||||
sanitized_request = self._replace_embedded_media(request_payload, source_reference=source_reference)
|
sanitized_request = self._replace_embedded_media(request_payload, source_reference=source_reference)
|
||||||
@@ -393,16 +406,15 @@ class OpenRouterTranscriptionProvider:
|
|||||||
return [self._replace_embedded_media(item, source_reference=source_reference) for item in value]
|
return [self._replace_embedded_media(item, source_reference=source_reference) for item in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _captured_transport_evidence(self) -> TransportEvidence:
|
def _captured_transport_evidence(self, capture: _TransportCapture) -> TransportEvidence:
|
||||||
response = self._capturing_client.last_response if self._capturing_client is not None else None
|
response = capture.response
|
||||||
if response is None:
|
if response is None:
|
||||||
return TransportEvidence(response_received=False)
|
return TransportEvidence(response_received=False)
|
||||||
headers = filter_safe_response_headers(response.headers)
|
headers = filter_safe_response_headers(response.headers)
|
||||||
body = self._capturing_client.last_body if self._capturing_client is not None else None
|
|
||||||
return TransportEvidence(
|
return TransportEvidence(
|
||||||
response_received=True,
|
response_received=True,
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
body=body,
|
body=capture.body,
|
||||||
safe_headers=headers,
|
safe_headers=headers,
|
||||||
content_type=headers.get("content-type"),
|
content_type=headers.get("content-type"),
|
||||||
content_encoding=headers.get("content-encoding"),
|
content_encoding=headers.get("content-encoding"),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from ..config import Settings
|
|||||||
from .documents import DocumentService
|
from .documents import DocumentService
|
||||||
from .evidence import EvidenceService
|
from .evidence import EvidenceService
|
||||||
from .jobs import JobService
|
from .jobs import JobService
|
||||||
|
from .maintenance import MaintenanceService
|
||||||
from .people import PeopleService
|
from .people import PeopleService
|
||||||
from .photos import PhotosService
|
from .photos import PhotosService
|
||||||
from .prompts import PromptStore
|
from .prompts import PromptStore
|
||||||
@@ -20,6 +21,7 @@ __all__ = [
|
|||||||
"DocumentService",
|
"DocumentService",
|
||||||
"EvidenceService",
|
"EvidenceService",
|
||||||
"JobService",
|
"JobService",
|
||||||
|
"MaintenanceService",
|
||||||
"PeopleService",
|
"PeopleService",
|
||||||
"PhotosService",
|
"PhotosService",
|
||||||
"PromptStore",
|
"PromptStore",
|
||||||
@@ -35,6 +37,7 @@ class ServiceBundle:
|
|||||||
documents: DocumentService = field(default_factory=DocumentService)
|
documents: DocumentService = field(default_factory=DocumentService)
|
||||||
sources: SourceService = field(default_factory=SourceService)
|
sources: SourceService = field(default_factory=SourceService)
|
||||||
jobs: JobService = field(default_factory=JobService)
|
jobs: JobService = field(default_factory=JobService)
|
||||||
|
maintenance: MaintenanceService = field(default_factory=MaintenanceService)
|
||||||
people: PeopleService = field(default_factory=PeopleService)
|
people: PeopleService = field(default_factory=PeopleService)
|
||||||
photos: PhotosService = field(default_factory=PhotosService)
|
photos: PhotosService = field(default_factory=PhotosService)
|
||||||
evidence: EvidenceService = field(default_factory=EvidenceService)
|
evidence: EvidenceService = field(default_factory=EvidenceService)
|
||||||
@@ -53,6 +56,7 @@ class ServiceBundle:
|
|||||||
documents=DocumentService(session_factory=session_factory, settings=settings),
|
documents=DocumentService(session_factory=session_factory, settings=settings),
|
||||||
sources=SourceService(session_factory=session_factory, settings=settings),
|
sources=SourceService(session_factory=session_factory, settings=settings),
|
||||||
jobs=JobService(session_factory=session_factory, settings=settings),
|
jobs=JobService(session_factory=session_factory, settings=settings),
|
||||||
|
maintenance=MaintenanceService(session_factory=session_factory, settings=settings),
|
||||||
people=PeopleService(session_factory=session_factory, settings=settings),
|
people=PeopleService(session_factory=session_factory, settings=settings),
|
||||||
photos=PhotosService(session_factory=session_factory, settings=settings),
|
photos=PhotosService(session_factory=session_factory, settings=settings),
|
||||||
evidence=EvidenceService(session_factory=session_factory, settings=settings),
|
evidence=EvidenceService(session_factory=session_factory, settings=settings),
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ class DocumentService(ServiceBase):
|
|||||||
selectinload(Document.document_type_ref),
|
selectinload(Document.document_type_ref),
|
||||||
selectinload(Document.document_tags).selectinload(orm_attribute(DocumentTag.tag_ref)),
|
selectinload(Document.document_tags).selectinload(orm_attribute(DocumentTag.tag_ref)),
|
||||||
selectinload(Document.sources),
|
selectinload(Document.sources),
|
||||||
|
selectinload(Document.jobs),
|
||||||
)
|
)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ class PromptLoadError(AppError):
|
|||||||
"""Raised when prompt artifacts cannot be loaded safely."""
|
"""Raised when prompt artifacts cannot be loaded safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class PromptStoreError(PromptLoadError):
|
||||||
|
"""Raised when prompt storage validation or persistence fails."""
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionError(AppError):
|
class TranscriptionError(AppError):
|
||||||
"""Raised when transcription execution fails."""
|
"""Raised when transcription execution fails."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
"""GEDCOM parsing and import helpers for maintenance-driven genealogy sync."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from gedcom.element.element import Element
|
||||||
|
from gedcom.parser import Parser
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlmodel import col
|
||||||
|
from sqlmodel import delete
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from transcription.db.models import GenealogyCitation
|
||||||
|
from transcription.db.models import GenealogyCitationFactType
|
||||||
|
from transcription.db.models import GenealogyCitationSourceKind
|
||||||
|
from transcription.db.models import GenealogyFamily
|
||||||
|
from transcription.db.models import GenealogyFamilyChild
|
||||||
|
from transcription.db.models import GenealogyPerson
|
||||||
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
|
_MONTHS = {
|
||||||
|
"JAN": 1,
|
||||||
|
"FEB": 2,
|
||||||
|
"MAR": 3,
|
||||||
|
"APR": 4,
|
||||||
|
"MAY": 5,
|
||||||
|
"JUN": 6,
|
||||||
|
"JUL": 7,
|
||||||
|
"AUG": 8,
|
||||||
|
"SEP": 9,
|
||||||
|
"OCT": 10,
|
||||||
|
"NOV": 11,
|
||||||
|
"DEC": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedCitation:
|
||||||
|
fact_type: GenealogyCitationFactType
|
||||||
|
raw_citation_text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedPerson:
|
||||||
|
pointer: str
|
||||||
|
fs_id: str | None
|
||||||
|
full_name: str
|
||||||
|
birth_date: date | None
|
||||||
|
birth_date_raw: str | None
|
||||||
|
birth_place: str | None
|
||||||
|
death_date: date | None
|
||||||
|
death_date_raw: str | None
|
||||||
|
death_place: str | None
|
||||||
|
citations: tuple[ParsedCitation, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedFamilyChild:
|
||||||
|
child_pointer: str
|
||||||
|
relationship_type: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedFamily:
|
||||||
|
fs_family_id: str | None
|
||||||
|
husband_pointer: str | None
|
||||||
|
wife_pointer: str | None
|
||||||
|
marriage_date: date | None
|
||||||
|
marriage_date_raw: str | None
|
||||||
|
marriage_place: str | None
|
||||||
|
children: tuple[ParsedFamilyChild, ...]
|
||||||
|
citations: tuple[ParsedCitation, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedGedcom:
|
||||||
|
people: tuple[ParsedPerson, ...]
|
||||||
|
families: tuple[ParsedFamily, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GedcomImportResult:
|
||||||
|
new_people: int
|
||||||
|
updated_people: int
|
||||||
|
skipped_people_without_fs_id: int
|
||||||
|
new_families: int
|
||||||
|
updated_families: int
|
||||||
|
skipped_families_without_fs_id: int
|
||||||
|
family_children: int
|
||||||
|
citations: int
|
||||||
|
|
||||||
|
|
||||||
|
class GedcomImportError(AppError):
|
||||||
|
"""Raised when GEDCOM content cannot be parsed or imported."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gedcom(*, file_path: Path) -> ParsedGedcom:
|
||||||
|
parser = Parser()
|
||||||
|
try:
|
||||||
|
parser.parse_file(str(file_path))
|
||||||
|
except Exception as exc:
|
||||||
|
raise GedcomImportError(
|
||||||
|
"GEDCOM file could not be parsed.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Upload a GEDCOM 5.5.1-compatible export and retry.",
|
||||||
|
detail=f"{type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
people: list[ParsedPerson] = []
|
||||||
|
families: list[ParsedFamily] = []
|
||||||
|
for element in parser.get_root_child_elements():
|
||||||
|
tag = element.get_tag()
|
||||||
|
if tag == "INDI":
|
||||||
|
people.append(_parse_person(element))
|
||||||
|
elif tag == "FAM":
|
||||||
|
families.append(_parse_family(element))
|
||||||
|
return ParsedGedcom(people=tuple(people), families=tuple(families))
|
||||||
|
|
||||||
|
|
||||||
|
async def import_gedcom_file(*, session: AsyncSession, file_path: Path) -> GedcomImportResult:
|
||||||
|
parsed = parse_gedcom(file_path=file_path)
|
||||||
|
pointer_to_fs_id = _pointer_to_fs_id_map(parsed=parsed)
|
||||||
|
people_by_fs_id, new_people, updated_people, skipped_people_without_fs_id = await _upsert_people(
|
||||||
|
session=session,
|
||||||
|
parsed=parsed,
|
||||||
|
)
|
||||||
|
(
|
||||||
|
families_by_fs_id,
|
||||||
|
new_families,
|
||||||
|
updated_families,
|
||||||
|
skipped_families_without_fs_id,
|
||||||
|
family_children,
|
||||||
|
) = await _upsert_families(
|
||||||
|
session=session,
|
||||||
|
parsed=parsed,
|
||||||
|
pointer_to_fs_id=pointer_to_fs_id,
|
||||||
|
people_by_fs_id=people_by_fs_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
citation_rows = _citation_rows(
|
||||||
|
parsed=parsed,
|
||||||
|
pointer_to_fs_id=pointer_to_fs_id,
|
||||||
|
people=people_by_fs_id,
|
||||||
|
families=families_by_fs_id,
|
||||||
|
)
|
||||||
|
await _replace_imported_citations(
|
||||||
|
session=session,
|
||||||
|
person_ids={item.id for item in people_by_fs_id.values()},
|
||||||
|
family_ids={item.id for item in families_by_fs_id.values()},
|
||||||
|
citations=citation_rows,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return GedcomImportResult(
|
||||||
|
new_people=new_people,
|
||||||
|
updated_people=updated_people,
|
||||||
|
skipped_people_without_fs_id=skipped_people_without_fs_id,
|
||||||
|
new_families=new_families,
|
||||||
|
updated_families=updated_families,
|
||||||
|
skipped_families_without_fs_id=skipped_families_without_fs_id,
|
||||||
|
family_children=family_children,
|
||||||
|
citations=len(citation_rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_people_by_fs_id(*, session: AsyncSession, fs_ids: set[str]) -> dict[str, GenealogyPerson]:
|
||||||
|
if not fs_ids:
|
||||||
|
return {}
|
||||||
|
query = select(GenealogyPerson).where(col(GenealogyPerson.fs_id).in_(fs_ids))
|
||||||
|
return {person.fs_id: person for person in (await session.exec(query)).all()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_families_by_fs_id(*, session: AsyncSession, fs_family_ids: set[str]) -> dict[str, GenealogyFamily]:
|
||||||
|
if not fs_family_ids:
|
||||||
|
return {}
|
||||||
|
query = select(GenealogyFamily).where(col(GenealogyFamily.fs_family_id).in_(fs_family_ids))
|
||||||
|
return {family.fs_family_id: family for family in (await session.exec(query)).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def _pointer_to_fs_id_map(*, parsed: ParsedGedcom) -> dict[str, str]:
|
||||||
|
return {person.pointer: person.fs_id for person in parsed.people if person.pointer and person.fs_id is not None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_people(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
parsed: ParsedGedcom,
|
||||||
|
) -> tuple[dict[str, GenealogyPerson], int, int, int]:
|
||||||
|
people_with_fs_id = [person for person in parsed.people if person.fs_id is not None]
|
||||||
|
fs_ids = {person.fs_id for person in people_with_fs_id if person.fs_id is not None}
|
||||||
|
people_by_fs_id = await _load_people_by_fs_id(session=session, fs_ids=fs_ids)
|
||||||
|
new_people = 0
|
||||||
|
updated_people = 0
|
||||||
|
|
||||||
|
for person in people_with_fs_id:
|
||||||
|
assert person.fs_id is not None
|
||||||
|
existing = people_by_fs_id.get(person.fs_id)
|
||||||
|
if existing is None:
|
||||||
|
existing = GenealogyPerson(
|
||||||
|
fs_id=person.fs_id,
|
||||||
|
full_name=person.full_name,
|
||||||
|
birth_date=person.birth_date,
|
||||||
|
birth_date_raw=person.birth_date_raw,
|
||||||
|
birth_place=person.birth_place,
|
||||||
|
death_date=person.death_date,
|
||||||
|
death_date_raw=person.death_date_raw,
|
||||||
|
death_place=person.death_place,
|
||||||
|
)
|
||||||
|
session.add(existing)
|
||||||
|
await session.flush()
|
||||||
|
people_by_fs_id[person.fs_id] = existing
|
||||||
|
new_people += 1
|
||||||
|
continue
|
||||||
|
if _apply_person_updates(existing=existing, person=person):
|
||||||
|
updated_people += 1
|
||||||
|
|
||||||
|
skipped = len(parsed.people) - len(people_with_fs_id)
|
||||||
|
return people_by_fs_id, new_people, updated_people, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_person_updates(*, existing: GenealogyPerson, person: ParsedPerson) -> bool:
|
||||||
|
changed = False
|
||||||
|
fields = (
|
||||||
|
("full_name", person.full_name),
|
||||||
|
("birth_date", person.birth_date),
|
||||||
|
("birth_date_raw", person.birth_date_raw),
|
||||||
|
("birth_place", person.birth_place),
|
||||||
|
("death_date", person.death_date),
|
||||||
|
("death_date_raw", person.death_date_raw),
|
||||||
|
("death_place", person.death_place),
|
||||||
|
)
|
||||||
|
for name, value in fields:
|
||||||
|
if getattr(existing, name) != value:
|
||||||
|
setattr(existing, name, value)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def _upsert_families(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
parsed: ParsedGedcom,
|
||||||
|
pointer_to_fs_id: Mapping[str, str],
|
||||||
|
people_by_fs_id: dict[str, GenealogyPerson],
|
||||||
|
) -> tuple[dict[str, GenealogyFamily], int, int, int, int]:
|
||||||
|
families_with_fs_id = [family for family in parsed.families if family.fs_family_id is not None]
|
||||||
|
fs_family_ids = {family.fs_family_id for family in families_with_fs_id if family.fs_family_id is not None}
|
||||||
|
families_by_fs_id = await _load_families_by_fs_id(session=session, fs_family_ids=fs_family_ids)
|
||||||
|
new_families = 0
|
||||||
|
updated_families = 0
|
||||||
|
family_children = 0
|
||||||
|
|
||||||
|
for family in families_with_fs_id:
|
||||||
|
assert family.fs_family_id is not None
|
||||||
|
husband_id = _person_id_from_pointer(
|
||||||
|
pointer=family.husband_pointer,
|
||||||
|
pointer_to_fs_id=pointer_to_fs_id,
|
||||||
|
people=people_by_fs_id,
|
||||||
|
)
|
||||||
|
wife_id = _person_id_from_pointer(
|
||||||
|
pointer=family.wife_pointer,
|
||||||
|
pointer_to_fs_id=pointer_to_fs_id,
|
||||||
|
people=people_by_fs_id,
|
||||||
|
)
|
||||||
|
target = families_by_fs_id.get(family.fs_family_id)
|
||||||
|
if target is None:
|
||||||
|
target = GenealogyFamily(
|
||||||
|
fs_family_id=family.fs_family_id,
|
||||||
|
husband_id=husband_id,
|
||||||
|
wife_id=wife_id,
|
||||||
|
marriage_date=family.marriage_date,
|
||||||
|
marriage_date_raw=family.marriage_date_raw,
|
||||||
|
marriage_place=family.marriage_place,
|
||||||
|
)
|
||||||
|
session.add(target)
|
||||||
|
await session.flush()
|
||||||
|
families_by_fs_id[family.fs_family_id] = target
|
||||||
|
new_families += 1
|
||||||
|
elif _apply_family_updates(existing=target, family=family, husband_id=husband_id, wife_id=wife_id):
|
||||||
|
updated_families += 1
|
||||||
|
|
||||||
|
family_children += await _replace_family_children(
|
||||||
|
session=session,
|
||||||
|
family=family,
|
||||||
|
family_id=target.id,
|
||||||
|
pointer_to_fs_id=pointer_to_fs_id,
|
||||||
|
people=people_by_fs_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
skipped = len(parsed.families) - len(families_with_fs_id)
|
||||||
|
return families_by_fs_id, new_families, updated_families, skipped, family_children
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_family_updates(
|
||||||
|
*,
|
||||||
|
existing: GenealogyFamily,
|
||||||
|
family: ParsedFamily,
|
||||||
|
husband_id: UUID | None,
|
||||||
|
wife_id: UUID | None,
|
||||||
|
) -> bool:
|
||||||
|
changed = False
|
||||||
|
fields = (
|
||||||
|
("husband_id", husband_id),
|
||||||
|
("wife_id", wife_id),
|
||||||
|
("marriage_date", family.marriage_date),
|
||||||
|
("marriage_date_raw", family.marriage_date_raw),
|
||||||
|
("marriage_place", family.marriage_place),
|
||||||
|
)
|
||||||
|
for name, value in fields:
|
||||||
|
if getattr(existing, name) != value:
|
||||||
|
setattr(existing, name, value)
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_family_children(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
family: ParsedFamily,
|
||||||
|
family_id: UUID,
|
||||||
|
pointer_to_fs_id: Mapping[str, str],
|
||||||
|
people: dict[str, GenealogyPerson],
|
||||||
|
) -> int:
|
||||||
|
await session.exec(delete(GenealogyFamilyChild).where(col(GenealogyFamilyChild.family_id) == family_id))
|
||||||
|
child_rows = _family_child_rows(
|
||||||
|
family=family,
|
||||||
|
family_id=family_id,
|
||||||
|
pointer_to_fs_id=pointer_to_fs_id,
|
||||||
|
people=people,
|
||||||
|
)
|
||||||
|
for child_row in child_rows:
|
||||||
|
session.add(child_row)
|
||||||
|
return len(child_rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _person_id_from_pointer(
|
||||||
|
*,
|
||||||
|
pointer: str | None,
|
||||||
|
pointer_to_fs_id: Mapping[str, str],
|
||||||
|
people: dict[str, GenealogyPerson],
|
||||||
|
) -> UUID | None:
|
||||||
|
if pointer is None:
|
||||||
|
return None
|
||||||
|
fs_id = pointer_to_fs_id.get(pointer)
|
||||||
|
if fs_id is None:
|
||||||
|
return None
|
||||||
|
person = people.get(fs_id)
|
||||||
|
return person.id if person is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _family_child_rows(
|
||||||
|
*,
|
||||||
|
family: ParsedFamily,
|
||||||
|
family_id: UUID,
|
||||||
|
pointer_to_fs_id: Mapping[str, str],
|
||||||
|
people: dict[str, GenealogyPerson],
|
||||||
|
) -> list[GenealogyFamilyChild]:
|
||||||
|
rows: list[GenealogyFamilyChild] = []
|
||||||
|
seen_child_ids: set[UUID] = set()
|
||||||
|
for child in family.children:
|
||||||
|
child_id = _person_id_from_pointer(
|
||||||
|
pointer=child.child_pointer,
|
||||||
|
pointer_to_fs_id=pointer_to_fs_id,
|
||||||
|
people=people,
|
||||||
|
)
|
||||||
|
if child_id is None or child_id in seen_child_ids:
|
||||||
|
continue
|
||||||
|
seen_child_ids.add(child_id)
|
||||||
|
rows.append(
|
||||||
|
GenealogyFamilyChild(
|
||||||
|
id=uuid4(),
|
||||||
|
family_id=family_id,
|
||||||
|
child_id=child_id,
|
||||||
|
relationship_type=child.relationship_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _citation_rows(
|
||||||
|
*,
|
||||||
|
parsed: ParsedGedcom,
|
||||||
|
pointer_to_fs_id: Mapping[str, str],
|
||||||
|
people: dict[str, GenealogyPerson],
|
||||||
|
families: dict[str, GenealogyFamily],
|
||||||
|
) -> list[GenealogyCitation]:
|
||||||
|
rows: list[GenealogyCitation] = []
|
||||||
|
seen: set[tuple[UUID | None, UUID | None, str, str]] = set()
|
||||||
|
|
||||||
|
for person in parsed.people:
|
||||||
|
fs_id = pointer_to_fs_id.get(person.pointer)
|
||||||
|
if fs_id is None:
|
||||||
|
continue
|
||||||
|
person_row = people.get(fs_id)
|
||||||
|
if person_row is None:
|
||||||
|
continue
|
||||||
|
for citation in person.citations:
|
||||||
|
key = (person_row.id, None, citation.fact_type.value, citation.raw_citation_text)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append(
|
||||||
|
GenealogyCitation(
|
||||||
|
id=uuid4(),
|
||||||
|
genealogy_person_id=person_row.id,
|
||||||
|
genealogy_family_id=None,
|
||||||
|
fact_type=citation.fact_type,
|
||||||
|
raw_citation_text=citation.raw_citation_text,
|
||||||
|
source_kind=GenealogyCitationSourceKind.FAMILYSEARCH_IMPORTED,
|
||||||
|
document_id=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for family in parsed.families:
|
||||||
|
if family.fs_family_id is None:
|
||||||
|
continue
|
||||||
|
family_row = families.get(family.fs_family_id)
|
||||||
|
if family_row is None:
|
||||||
|
continue
|
||||||
|
for citation in family.citations:
|
||||||
|
key = (None, family_row.id, citation.fact_type.value, citation.raw_citation_text)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
rows.append(
|
||||||
|
GenealogyCitation(
|
||||||
|
id=uuid4(),
|
||||||
|
genealogy_person_id=None,
|
||||||
|
genealogy_family_id=family_row.id,
|
||||||
|
fact_type=citation.fact_type,
|
||||||
|
raw_citation_text=citation.raw_citation_text,
|
||||||
|
source_kind=GenealogyCitationSourceKind.FAMILYSEARCH_IMPORTED,
|
||||||
|
document_id=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_imported_citations(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
person_ids: set[UUID],
|
||||||
|
family_ids: set[UUID],
|
||||||
|
citations: list[GenealogyCitation],
|
||||||
|
) -> None:
|
||||||
|
where_clauses = []
|
||||||
|
if person_ids:
|
||||||
|
where_clauses.append(col(GenealogyCitation.genealogy_person_id).in_(person_ids))
|
||||||
|
if family_ids:
|
||||||
|
where_clauses.append(col(GenealogyCitation.genealogy_family_id).in_(family_ids))
|
||||||
|
if where_clauses:
|
||||||
|
target_scope = where_clauses[0] if len(where_clauses) == 1 else or_(*where_clauses)
|
||||||
|
await session.exec(
|
||||||
|
delete(GenealogyCitation).where(
|
||||||
|
col(GenealogyCitation.source_kind) == GenealogyCitationSourceKind.FAMILYSEARCH_IMPORTED,
|
||||||
|
target_scope,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for citation in citations:
|
||||||
|
session.add(citation)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_person(element: Element) -> ParsedPerson:
|
||||||
|
birth_event = _first_child(element, "BIRT")
|
||||||
|
death_event = _first_child(element, "DEAT")
|
||||||
|
birth_date_raw = _child_value(birth_event, "DATE")
|
||||||
|
death_date_raw = _child_value(death_event, "DATE")
|
||||||
|
return ParsedPerson(
|
||||||
|
pointer=element.get_pointer() or "",
|
||||||
|
fs_id=_extract_fs_identifier(element),
|
||||||
|
full_name=_person_name(element),
|
||||||
|
birth_date=_parse_exact_date(birth_date_raw),
|
||||||
|
birth_date_raw=birth_date_raw,
|
||||||
|
birth_place=_child_value(birth_event, "PLAC"),
|
||||||
|
death_date=_parse_exact_date(death_date_raw),
|
||||||
|
death_date_raw=death_date_raw,
|
||||||
|
death_place=_child_value(death_event, "PLAC"),
|
||||||
|
citations=(
|
||||||
|
*_fact_citations(fact_element=birth_event, fact_type=GenealogyCitationFactType.BIRTH),
|
||||||
|
*_fact_citations(fact_element=death_event, fact_type=GenealogyCitationFactType.DEATH),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_family(element: Element) -> ParsedFamily:
|
||||||
|
marriage_event = _first_child(element, "MARR")
|
||||||
|
marriage_date_raw = _child_value(marriage_event, "DATE")
|
||||||
|
children = tuple(
|
||||||
|
ParsedFamilyChild(
|
||||||
|
child_pointer=(child.get_value() or "").strip(),
|
||||||
|
relationship_type=_child_value(child, "PEDI"),
|
||||||
|
)
|
||||||
|
for child in _children(element, "CHIL")
|
||||||
|
if (child.get_value() or "").strip()
|
||||||
|
)
|
||||||
|
return ParsedFamily(
|
||||||
|
fs_family_id=_extract_fs_identifier(element),
|
||||||
|
husband_pointer=(_child_value(element, "HUSB") or "").strip() or None,
|
||||||
|
wife_pointer=(_child_value(element, "WIFE") or "").strip() or None,
|
||||||
|
marriage_date=_parse_exact_date(marriage_date_raw),
|
||||||
|
marriage_date_raw=marriage_date_raw,
|
||||||
|
marriage_place=_child_value(marriage_event, "PLAC"),
|
||||||
|
children=children,
|
||||||
|
citations=_fact_citations(fact_element=marriage_event, fact_type=GenealogyCitationFactType.MARRIAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fact_citations(
|
||||||
|
*, fact_element: Element | None, fact_type: GenealogyCitationFactType
|
||||||
|
) -> tuple[ParsedCitation, ...]:
|
||||||
|
if fact_element is None:
|
||||||
|
return ()
|
||||||
|
citations: list[ParsedCitation] = []
|
||||||
|
for source in _children(fact_element, "SOUR"):
|
||||||
|
raw_citation = _flatten_tag_values(source)
|
||||||
|
if raw_citation is None:
|
||||||
|
continue
|
||||||
|
citations.append(ParsedCitation(fact_type=fact_type, raw_citation_text=raw_citation))
|
||||||
|
return tuple(citations)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_fs_identifier(element: Element) -> str | None:
|
||||||
|
for tag in ("_FSFTID", "FSFTID"):
|
||||||
|
value = _child_value(element, tag)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
for refn in _children(element, "REFN"):
|
||||||
|
refn_value = (refn.get_value() or "").strip()
|
||||||
|
refn_type = (_child_value(refn, "TYPE") or "").strip().casefold()
|
||||||
|
if refn_value and ("fsftid" in refn_type or "familysearch" in refn_type):
|
||||||
|
return refn_value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _person_name(element: Element) -> str:
|
||||||
|
raw_name = _child_value(element, "NAME")
|
||||||
|
if raw_name is None:
|
||||||
|
return "Unknown"
|
||||||
|
cleaned = raw_name.replace("/", " ").strip()
|
||||||
|
return " ".join(part for part in cleaned.split() if part) or "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exact_date(raw: str | None) -> date | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
tokens = [token for token in raw.strip().upper().split() if token]
|
||||||
|
if len(tokens) != 3:
|
||||||
|
return None
|
||||||
|
day_token, month_token, year_token = tokens
|
||||||
|
if month_token not in _MONTHS:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date(year=int(year_token), month=_MONTHS[month_token], day=int(day_token))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_child(element: Element | None, tag: str) -> Element | None:
|
||||||
|
if element is None:
|
||||||
|
return None
|
||||||
|
for child in element.get_child_elements():
|
||||||
|
if child.get_tag() == tag:
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _children(element: Element | None, tag: str) -> list[Element]:
|
||||||
|
if element is None:
|
||||||
|
return []
|
||||||
|
return [child for child in element.get_child_elements() if child.get_tag() == tag]
|
||||||
|
|
||||||
|
|
||||||
|
def _child_value(element: Element | None, tag: str) -> str | None:
|
||||||
|
child = _first_child(element, tag)
|
||||||
|
if child is None:
|
||||||
|
return None
|
||||||
|
value = (child.get_value() or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_tag_values(element: Element) -> str | None:
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
def walk(node: Element) -> None:
|
||||||
|
value = (node.get_value() or "").strip()
|
||||||
|
if value:
|
||||||
|
lines.append(f"{node.get_tag()}: {value}")
|
||||||
|
for child in node.get_child_elements():
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
walk(element)
|
||||||
|
return " | ".join(lines) if lines else None
|
||||||
@@ -26,6 +26,18 @@ from .base import ServiceBase
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
"""Return current UTC as naive datetime for DB timestamp columns."""
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_naive_utc(value: datetime) -> datetime:
|
||||||
|
"""Normalize datetimes to naive UTC for DB comparisons/binds."""
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value
|
||||||
|
return value.astimezone(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
class JobDeleteBlockedError(AppError):
|
class JobDeleteBlockedError(AppError):
|
||||||
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||||
|
|
||||||
@@ -171,6 +183,16 @@ class JobService(ServiceBase):
|
|||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
async def note_processing_progress(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
||||||
|
"""Refresh job liveness while a multi-page batch is still in progress."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
job = await _session.get(Job, job_id)
|
||||||
|
if job is None:
|
||||||
|
raise self._not_found(job_id)
|
||||||
|
job.date_updated = _utc_now_naive()
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
|
return job
|
||||||
|
|
||||||
async def claim_next_queued_job(
|
async def claim_next_queued_job(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -201,7 +223,7 @@ class JobService(ServiceBase):
|
|||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return job
|
return job
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = _utc_now_naive()
|
||||||
queued_job_id = (
|
queued_job_id = (
|
||||||
select(col(Job.id))
|
select(col(Job.id))
|
||||||
.where(col(Job.status) == JobStatus.QUEUED)
|
.where(col(Job.status) == JobStatus.QUEUED)
|
||||||
@@ -239,12 +261,15 @@ class JobService(ServiceBase):
|
|||||||
``stale_before`` are considered stale and re-queued.
|
``stale_before`` are considered stale and re-queued.
|
||||||
"""
|
"""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job).where(Job.status == JobStatus.PROCESSING).where(Job.date_updated < stale_before)
|
normalized_stale_before = _as_naive_utc(stale_before)
|
||||||
|
query = (
|
||||||
|
select(Job).where(Job.status == JobStatus.PROCESSING).where(Job.date_updated < normalized_stale_before)
|
||||||
|
)
|
||||||
stale_jobs = (await _session.exec(query)).all()
|
stale_jobs = (await _session.exec(query)).all()
|
||||||
if not stale_jobs:
|
if not stale_jobs:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = _utc_now_naive()
|
||||||
for job in stale_jobs:
|
for job in stale_jobs:
|
||||||
job.status = JobStatus.QUEUED
|
job.status = JobStatus.QUEUED
|
||||||
job.date_updated = now
|
job.date_updated = now
|
||||||
@@ -352,7 +377,7 @@ class JobService(ServiceBase):
|
|||||||
suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
|
suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = _utc_now_naive()
|
||||||
job.status = JobStatus.FAILED
|
job.status = JobStatus.FAILED
|
||||||
job.date_updated = now
|
job.date_updated = now
|
||||||
|
|
||||||
@@ -398,7 +423,7 @@ class JobService(ServiceBase):
|
|||||||
suggestion="Only failed or cancelled sources can be resubmitted.",
|
suggestion="Only failed or cancelled sources can be resubmitted.",
|
||||||
)
|
)
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = _utc_now_naive()
|
||||||
for job_source in candidates:
|
for job_source in candidates:
|
||||||
job_source.status = JobSourceStatus.PENDING
|
job_source.status = JobSourceStatus.PENDING
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"""Queue-backed maintenance operations executed by the worker loop."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import update
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlmodel import col
|
||||||
|
from sqlmodel import func
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import MaintenanceJobType
|
||||||
|
from transcription.db.models import MaintenanceRun
|
||||||
|
from transcription.db.models import MaintenanceRunStatus
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.errors import classify_unexpected_error
|
||||||
|
|
||||||
|
from .base import ServiceBase
|
||||||
|
from .gedcom_import import GedcomImportError
|
||||||
|
from .gedcom_import import import_gedcom_file
|
||||||
|
from .media_storage import persist_named_media
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MaintenanceExecution:
|
||||||
|
"""In-memory result for one executed maintenance run."""
|
||||||
|
|
||||||
|
status: MaintenanceRunStatus
|
||||||
|
summary: str
|
||||||
|
output: str
|
||||||
|
error_detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceError(AppError):
|
||||||
|
"""Raised when maintenance operations cannot be enqueued or executed."""
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceService(ServiceBase):
|
||||||
|
"""Persist and execute background maintenance runs."""
|
||||||
|
|
||||||
|
async def store_gedcom_upload(self, *, filename: str, file_bytes: bytes) -> str:
|
||||||
|
if not file_bytes:
|
||||||
|
raise MaintenanceError(
|
||||||
|
"GEDCOM upload is empty.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Upload a non-empty .ged file and retry.",
|
||||||
|
)
|
||||||
|
if Path(filename).suffix.casefold() != ".ged":
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Unsupported GEDCOM upload format.",
|
||||||
|
category=ErrorCategory.USER_INPUT,
|
||||||
|
suggestion="Upload a file with a .ged extension.",
|
||||||
|
)
|
||||||
|
stored_path = await persist_named_media(
|
||||||
|
root=self.settings.upload_dir,
|
||||||
|
namespace=Path("genealogy"),
|
||||||
|
filename=filename,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
filename_stem=str(uuid4()),
|
||||||
|
error=MaintenanceError,
|
||||||
|
failure_message="GEDCOM file could not be persisted.",
|
||||||
|
failure_suggestion="Check upload directory permissions and retry.",
|
||||||
|
log_label="gedcom file",
|
||||||
|
)
|
||||||
|
return str(stored_path.resolve().relative_to(self.settings.upload_dir.resolve()).as_posix())
|
||||||
|
|
||||||
|
def latest_gedcom_upload_path(self) -> str | None:
|
||||||
|
latest = self._latest_gedcom_upload()
|
||||||
|
if latest is None:
|
||||||
|
return None
|
||||||
|
return str(latest.resolve().relative_to(self.settings.upload_dir.resolve()).as_posix())
|
||||||
|
|
||||||
|
async def list_runs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> list[MaintenanceRun]:
|
||||||
|
try:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(MaintenanceRun)
|
||||||
|
.order_by(col(MaintenanceRun.created_at).desc(), col(MaintenanceRun.id).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list((await _session.exec(query)).all())
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Maintenance runs are unavailable.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify database schema access and retry.",
|
||||||
|
detail=f"Failed to list maintenance runs: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def enqueue_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_type: MaintenanceJobType,
|
||||||
|
triggered_by: str = "ui.settings",
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> MaintenanceRun:
|
||||||
|
run = MaintenanceRun(
|
||||||
|
job_type=job_type,
|
||||||
|
status=MaintenanceRunStatus.QUEUED,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
_session.add(run)
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(run,))
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Maintenance run could not be queued.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Run the V6.1 schema migration, then retry.",
|
||||||
|
detail=f"Failed to enqueue maintenance run: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
return run
|
||||||
|
|
||||||
|
async def claim_next_queued_run(self, *, session: AsyncSession | None = None) -> MaintenanceRun | None:
|
||||||
|
try:
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
now = _utc_now_naive()
|
||||||
|
queued_run_id = (
|
||||||
|
select(col(MaintenanceRun.id))
|
||||||
|
.where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED)
|
||||||
|
.order_by(col(MaintenanceRun.created_at), col(MaintenanceRun.id))
|
||||||
|
.limit(1)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
claim_statement = (
|
||||||
|
update(MaintenanceRun)
|
||||||
|
.where(col(MaintenanceRun.id) == queued_run_id)
|
||||||
|
.where(col(MaintenanceRun.status) == MaintenanceRunStatus.QUEUED)
|
||||||
|
.values(
|
||||||
|
status=MaintenanceRunStatus.PROCESSING,
|
||||||
|
started_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
.returning(col(MaintenanceRun.id))
|
||||||
|
)
|
||||||
|
claimed_row = (await _session.exec(claim_statement)).first()
|
||||||
|
if claimed_row is None:
|
||||||
|
return None
|
||||||
|
claimed_run_id = claimed_row if isinstance(claimed_row, UUID) else claimed_row[0]
|
||||||
|
run = await _session.get(MaintenanceRun, claimed_run_id)
|
||||||
|
if run is None:
|
||||||
|
return None
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(run,))
|
||||||
|
return run
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Maintenance queue claim failed.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify database schema access and retry.",
|
||||||
|
detail=f"Failed to claim queued maintenance run: {type(exc).__name__}: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def process_next_queued_run(self, *, session: AsyncSession | None = None) -> bool:
|
||||||
|
run = await self.claim_next_queued_run(session=session)
|
||||||
|
if run is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
execution = await self._execute_run(run)
|
||||||
|
await self._finalize_run(run_id=run.id, execution=execution, session=session)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _finalize_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: UUID,
|
||||||
|
execution: MaintenanceExecution,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = _utc_now_naive()
|
||||||
|
log_path = self._write_log(run_id=run_id, output=execution.output)
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
run = await _session.get(MaintenanceRun, run_id)
|
||||||
|
if run is None:
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Maintenance run not found while finalizing.",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Refresh the page and retry.",
|
||||||
|
)
|
||||||
|
run.status = execution.status
|
||||||
|
run.summary = execution.summary
|
||||||
|
run.error_detail = execution.error_detail
|
||||||
|
run.log_path = log_path
|
||||||
|
run.finished_at = now
|
||||||
|
run.updated_at = now
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(run,))
|
||||||
|
|
||||||
|
async def _execute_run(self, run: MaintenanceRun) -> MaintenanceExecution:
|
||||||
|
if run.job_type == MaintenanceJobType.BACKUP:
|
||||||
|
return await self._execute_backup()
|
||||||
|
if run.job_type == MaintenanceJobType.STORAGE_RECONCILIATION:
|
||||||
|
return await self._execute_storage_reconciliation()
|
||||||
|
if run.job_type == MaintenanceJobType.GEDCOM_IMPORT:
|
||||||
|
return await self._execute_gedcom_import()
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Unsupported maintenance job type.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Choose a supported maintenance action and retry.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_backup(self) -> MaintenanceExecution:
|
||||||
|
script_path = Path("deploy") / "backup" / "create_postgres_backup.sh"
|
||||||
|
if not script_path.is_file():
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary="Backup script is unavailable in this environment.",
|
||||||
|
output="Backup script not found.",
|
||||||
|
error_detail=f"Missing script: {script_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
command = ["sh", str(script_path)]
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
stdout, _ = await process.communicate()
|
||||||
|
except OSError as exc:
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary="Backup command failed to start.",
|
||||||
|
output=f"Failed to execute {' '.join(command)}",
|
||||||
|
error_detail=f"{type(exc).__name__}: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
output = stdout.decode("utf-8", errors="replace")
|
||||||
|
if process.returncode == 0:
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.SUCCEEDED,
|
||||||
|
summary="Backup completed successfully.",
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary="Backup command failed.",
|
||||||
|
output=output,
|
||||||
|
error_detail=f"Exit code: {process.returncode}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_storage_reconciliation(self) -> MaintenanceExecution:
|
||||||
|
try:
|
||||||
|
mismatches = await self._collect_storage_mismatches()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
error = classify_unexpected_error(exc, operation="maintenance.storage_reconciliation")
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary="Storage reconciliation failed.",
|
||||||
|
output="Storage reconciliation failed before completion.",
|
||||||
|
error_detail=error.detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mismatches:
|
||||||
|
report = "\n".join(f"- {item}" for item in mismatches)
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary=f"Storage reconciliation found {len(mismatches)} issue(s).",
|
||||||
|
output=report,
|
||||||
|
error_detail="Reconciliation mismatches were detected.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.SUCCEEDED,
|
||||||
|
summary="Storage reconciliation found no mismatches.",
|
||||||
|
output="No storage reconciliation mismatches detected.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_gedcom_import(self) -> MaintenanceExecution:
|
||||||
|
latest_upload = self._latest_gedcom_upload()
|
||||||
|
if latest_upload is None:
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary="No GEDCOM upload is available.",
|
||||||
|
output="No .ged file found under uploads/genealogy.",
|
||||||
|
error_detail="Missing GEDCOM upload in uploads/genealogy.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._session_scope() as session:
|
||||||
|
result = await import_gedcom_file(session=session, file_path=latest_upload)
|
||||||
|
except GedcomImportError as exc:
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary="GEDCOM import failed.",
|
||||||
|
output=f"GEDCOM import failed for {latest_upload.name}.",
|
||||||
|
error_detail=exc.detail,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
error = classify_unexpected_error(exc, operation="maintenance.gedcom_import")
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.FAILED,
|
||||||
|
summary="GEDCOM import failed.",
|
||||||
|
output=f"GEDCOM import failed for {latest_upload.name}.",
|
||||||
|
error_detail=error.detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = (
|
||||||
|
f"Imported {result.new_people + result.updated_people} people "
|
||||||
|
f"({result.new_people} new, {result.updated_people} updated) and "
|
||||||
|
f"{result.new_families + result.updated_families} families "
|
||||||
|
f"({result.new_families} new, {result.updated_families} updated)."
|
||||||
|
)
|
||||||
|
output_lines = [
|
||||||
|
f"GEDCOM file: {latest_upload.as_posix()}",
|
||||||
|
summary,
|
||||||
|
f"Skipped people without FamilySearch ID: {result.skipped_people_without_fs_id}",
|
||||||
|
f"Skipped families without FamilySearch ID: {result.skipped_families_without_fs_id}",
|
||||||
|
f"Family child links written: {result.family_children}",
|
||||||
|
f"Imported citations: {result.citations}",
|
||||||
|
]
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.SUCCEEDED,
|
||||||
|
summary=summary,
|
||||||
|
output="\n".join(output_lines),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _collect_storage_mismatches(self) -> list[str]:
|
||||||
|
upload_root = self.settings.upload_dir
|
||||||
|
folder_ids = _document_folder_ids(upload_root)
|
||||||
|
doc_ids = await self._document_ids()
|
||||||
|
source_counts = await self._source_counts_by_document()
|
||||||
|
folder_by_normalized = {_normalize_identifier(folder_id): folder_id for folder_id in folder_ids}
|
||||||
|
doc_by_normalized = {_normalize_identifier(doc_id): doc_id for doc_id in doc_ids}
|
||||||
|
source_counts_by_normalized = {
|
||||||
|
_normalize_identifier(document_id): count for document_id, count in source_counts.items()
|
||||||
|
}
|
||||||
|
mismatches: list[str] = []
|
||||||
|
|
||||||
|
missing_in_table = sorted(set(folder_by_normalized) - set(doc_by_normalized))
|
||||||
|
for folder_key in missing_in_table:
|
||||||
|
folder_name = folder_by_normalized[folder_key]
|
||||||
|
mismatches.append(f"document-folder-without-row: documents/{folder_name}")
|
||||||
|
|
||||||
|
missing_in_folders = sorted(set(doc_by_normalized) - set(folder_by_normalized))
|
||||||
|
for doc_key in missing_in_folders:
|
||||||
|
doc_id = doc_by_normalized[doc_key]
|
||||||
|
source_count = source_counts_by_normalized.get(doc_key, 0)
|
||||||
|
mismatches.append(f"document-row-without-folder: {doc_id} (source rows: {source_count})")
|
||||||
|
|
||||||
|
for doc_key in sorted(doc_by_normalized):
|
||||||
|
doc_id = doc_by_normalized[doc_key]
|
||||||
|
folder_name = folder_by_normalized.get(doc_key)
|
||||||
|
db_count = source_counts_by_normalized.get(doc_key, 0)
|
||||||
|
file_count = _source_file_count_for_document(upload_root, folder_name) if folder_name is not None else 0
|
||||||
|
if db_count != file_count:
|
||||||
|
path_label = f"documents/{folder_name}" if folder_name is not None else "documents/<missing-folder>"
|
||||||
|
mismatches.append(
|
||||||
|
f"source-count-mismatch: {doc_id} -> source rows: {db_count}, files in {path_label}: {file_count}"
|
||||||
|
)
|
||||||
|
return mismatches
|
||||||
|
|
||||||
|
def _latest_gedcom_upload(self) -> Path | None:
|
||||||
|
genealogy_root = self.settings.upload_dir / "genealogy"
|
||||||
|
if not genealogy_root.exists():
|
||||||
|
return None
|
||||||
|
candidates = [path for path in genealogy_root.rglob("*.ged") if path.is_file()]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return max(candidates, key=lambda path: (path.stat().st_mtime_ns, path.name.casefold()))
|
||||||
|
|
||||||
|
async def _document_ids(self) -> set[str]:
|
||||||
|
async with self._session_scope() as session:
|
||||||
|
rows = await session.exec(select(Document.id))
|
||||||
|
return {str(item) for item in rows.all()}
|
||||||
|
|
||||||
|
async def _source_counts_by_document(self) -> dict[str, int]:
|
||||||
|
async with self._session_scope() as session:
|
||||||
|
rows = await session.exec(
|
||||||
|
select(
|
||||||
|
Source.document_id,
|
||||||
|
func.count(Source.id), # ty: ignore[invalid-argument-type] - SQLAlchemy descriptor false positive.
|
||||||
|
).group_by(
|
||||||
|
Source.document_id # ty: ignore[invalid-argument-type] - SQLAlchemy descriptor false positive.
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {str(document_id): int(count) for document_id, count in rows}
|
||||||
|
|
||||||
|
def read_log_bytes(self, *, log_path: str) -> bytes:
|
||||||
|
candidate = (self.settings.log_dir / Path(log_path)).resolve()
|
||||||
|
base = self.settings.log_dir.resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(base)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Maintenance log path is invalid.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Refresh and retry.",
|
||||||
|
detail=f"Requested path outside log root: {candidate}",
|
||||||
|
) from exc
|
||||||
|
if not candidate.is_file():
|
||||||
|
raise MaintenanceError(
|
||||||
|
"Maintenance log file is unavailable.",
|
||||||
|
category=ErrorCategory.NOT_FOUND,
|
||||||
|
suggestion="Refresh and retry.",
|
||||||
|
)
|
||||||
|
return candidate.read_bytes()
|
||||||
|
|
||||||
|
def _write_log(self, *, run_id: UUID, output: str) -> str:
|
||||||
|
timestamp = _utc_now_naive().strftime("%Y%m%d-%H%M%S")
|
||||||
|
logs_dir = self.settings.log_dir / "maintenance"
|
||||||
|
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = logs_dir / f"maintenance-{run_id}-{timestamp}.log"
|
||||||
|
file_path.write_text(output, encoding="utf-8")
|
||||||
|
return str(file_path.relative_to(self.settings.log_dir).as_posix())
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_identifier(value: str) -> str:
|
||||||
|
return value.replace("-", "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _document_folder_ids(root: Path) -> set[str]:
|
||||||
|
documents_root = root / "documents"
|
||||||
|
if not documents_root.exists():
|
||||||
|
return set()
|
||||||
|
return {entry.name for entry in documents_root.iterdir() if entry.is_dir()}
|
||||||
|
|
||||||
|
|
||||||
|
def _source_file_count_for_document(root: Path, document_id: str | None) -> int:
|
||||||
|
if document_id is None:
|
||||||
|
return 0
|
||||||
|
directory = root / "documents" / document_id
|
||||||
|
if not directory.exists():
|
||||||
|
return 0
|
||||||
|
return sum(1 for entry in directory.iterdir() if entry.is_file())
|
||||||
@@ -9,17 +9,14 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from ..config import Settings
|
from ..config import Settings
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..errors import AppError
|
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
|
from ..errors import exception_detail
|
||||||
|
from .errors import PromptStoreError
|
||||||
|
|
||||||
PROMPT_EXTENSION = ".md"
|
PROMPT_EXTENSION = ".md"
|
||||||
BACKUP_SUFFIX = ".bak"
|
BACKUP_SUFFIX = ".bak"
|
||||||
|
|
||||||
|
|
||||||
class PromptStoreError(AppError):
|
|
||||||
"""Raised when prompt storage validation or persistence fails."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PromptSummary:
|
class PromptSummary:
|
||||||
"""Read model for one editable prompt artifact."""
|
"""Read model for one editable prompt artifact."""
|
||||||
@@ -96,9 +93,10 @@ class PromptStore:
|
|||||||
raise self._filesystem_error("Prompt directory could not be resolved", exc) from exc
|
raise self._filesystem_error("Prompt directory could not be resolved", exc) from exc
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
raise PromptStoreError(
|
raise PromptStoreError(
|
||||||
f"Prompt directory is unavailable: {root}",
|
"Prompt directory is unavailable.",
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Restore the configured prompt directory and its permissions.",
|
suggestion="Restore the configured prompt directory and its permissions.",
|
||||||
|
detail=f"Prompt directory is unavailable: {root}",
|
||||||
)
|
)
|
||||||
return root
|
return root
|
||||||
|
|
||||||
@@ -185,7 +183,8 @@ class PromptStore:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _filesystem_error(message: str, exc: Exception) -> PromptStoreError:
|
def _filesystem_error(message: str, exc: Exception) -> PromptStoreError:
|
||||||
return PromptStoreError(
|
return PromptStoreError(
|
||||||
f"{message}: {exc}",
|
message,
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Check prompt directory permissions and available disk space, then retry.",
|
suggestion="Check prompt directory permissions and available disk space, then retry.",
|
||||||
|
detail=exception_detail(exc),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from transcription.db.models import JobSourceStatus
|
|||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
from transcription.providers import ProviderAuthError
|
from transcription.providers import ProviderAuthError
|
||||||
|
from transcription.providers import ProviderCallEvidence
|
||||||
from transcription.providers import ProviderError
|
from transcription.providers import ProviderError
|
||||||
from transcription.providers import ProviderResponseError
|
from transcription.providers import ProviderResponseError
|
||||||
from transcription.providers import RequestManifest
|
from transcription.providers import RequestManifest
|
||||||
@@ -66,6 +67,18 @@ JSON_OBJECT_ADAPTER = TypeAdapter(dict[str, JsonValue])
|
|||||||
MAX_EXECUTION_ATTEMPT_NUMBER_RETRIES = 3
|
MAX_EXECUTION_ATTEMPT_NUMBER_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
"""Return current UTC as naive datetime for DB timestamp columns."""
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_naive_utc(value: datetime) -> datetime:
|
||||||
|
"""Normalize aware or naive datetimes to naive UTC."""
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value
|
||||||
|
return value.astimezone(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
class PromptExecution(BaseModel):
|
class PromptExecution(BaseModel):
|
||||||
"""Resolved prompt inputs captured for one page execution."""
|
"""Resolved prompt inputs captured for one page execution."""
|
||||||
|
|
||||||
@@ -533,8 +546,8 @@ class SourceService(ServiceBase):
|
|||||||
else:
|
else:
|
||||||
job_source.status = outcome
|
job_source.status = outcome
|
||||||
|
|
||||||
finish_time = finished_at or datetime.now(UTC)
|
finish_time = _as_naive_utc(finished_at) if finished_at is not None else _utc_now_naive()
|
||||||
start_time = started_at or finish_time
|
start_time = _as_naive_utc(started_at) if started_at is not None else finish_time
|
||||||
transport = transport_evidence or TransportEvidence(response_received=False)
|
transport = transport_evidence or TransportEvidence(response_received=False)
|
||||||
manifest_payload = request_manifest.model_dump(mode="json") if request_manifest is not None else None
|
manifest_payload = request_manifest.model_dump(mode="json") if request_manifest is not None else None
|
||||||
software_payload = (
|
software_payload = (
|
||||||
@@ -641,7 +654,7 @@ class SourceService(ServiceBase):
|
|||||||
source = await self._read_source(session=_session, source_id=source_id)
|
source = await self._read_source(session=_session, source_id=source_id)
|
||||||
|
|
||||||
source.revised_text = text
|
source.revised_text = text
|
||||||
source.date_revised = datetime.now(UTC)
|
source.date_revised = _utc_now_naive()
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
||||||
return source
|
return source
|
||||||
|
|
||||||
@@ -757,6 +770,7 @@ async def transcribe_document_image(
|
|||||||
provider: TranscriptionProvider | None = None,
|
provider: TranscriptionProvider | None = None,
|
||||||
source_reference: SourceEvidenceReference | None = None,
|
source_reference: SourceEvidenceReference | None = None,
|
||||||
requested_model: str | None = None,
|
requested_model: str | None = None,
|
||||||
|
evidence_capture: ProviderCallEvidence | None = None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
"""Transcribe a local image using the configured prompt and provider."""
|
"""Transcribe a local image using the configured prompt and provider."""
|
||||||
runtime_settings = settings or get_settings()
|
runtime_settings = settings or get_settings()
|
||||||
@@ -790,6 +804,7 @@ async def transcribe_document_image(
|
|||||||
top_p=prompt_execution.top_p,
|
top_p=prompt_execution.top_p,
|
||||||
source_reference=source_reference,
|
source_reference=source_reference,
|
||||||
requested_model=requested_model,
|
requested_model=requested_model,
|
||||||
|
evidence_capture=evidence_capture,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if owns_adapter:
|
if owns_adapter:
|
||||||
@@ -841,17 +856,19 @@ def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settin
|
|||||||
|
|
||||||
if not prompt_path.exists() or not prompt_path.is_file():
|
if not prompt_path.exists() or not prompt_path.is_file():
|
||||||
raise PromptLoadError(
|
raise PromptLoadError(
|
||||||
f"Prompt file not found: {prompt_path}",
|
f"Prompt file not found: {prompt_path.name}",
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
|
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
|
||||||
|
detail=f"Prompt file missing at {prompt_path}",
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
||||||
if not prompt_text:
|
if not prompt_text:
|
||||||
raise PromptLoadError(
|
raise PromptLoadError(
|
||||||
f"Prompt file is empty: {prompt_path}",
|
f"Prompt file is empty: {prompt_path.name}",
|
||||||
category=ErrorCategory.VALIDATION,
|
category=ErrorCategory.VALIDATION,
|
||||||
suggestion="Populate the prompt file with valid instructions and retry.",
|
suggestion="Populate the prompt file with valid instructions and retry.",
|
||||||
|
detail=f"Prompt file is empty at {prompt_path}",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Loaded prompt artifact: %s", prompt_path)
|
logger.info("Loaded prompt artifact: %s", prompt_path)
|
||||||
@@ -899,9 +916,10 @@ def load_source_payload(source_path: str | Path) -> tuple[bytes, str]:
|
|||||||
|
|
||||||
if not path.exists() or not path.is_file():
|
if not path.exists() or not path.is_file():
|
||||||
raise TranscriptionError(
|
raise TranscriptionError(
|
||||||
f"Source file not found: {path}",
|
f"Source file not found: {path.name}",
|
||||||
category=ErrorCategory.NOT_FOUND,
|
category=ErrorCategory.NOT_FOUND,
|
||||||
suggestion="Verify the Source file exists and retry from the jobs page.",
|
suggestion="Verify the Source file exists and retry from the jobs page.",
|
||||||
|
detail=f"Source file not found at {path}",
|
||||||
)
|
)
|
||||||
|
|
||||||
content = path.read_bytes()
|
content = path.read_bytes()
|
||||||
@@ -918,6 +936,7 @@ def handle_transcription_errors():
|
|||||||
"Provider authentication failed",
|
"Provider authentication failed",
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
suggestion="Verify provider API credentials and retry.",
|
suggestion="Verify provider API credentials and retry.",
|
||||||
|
detail=f"{type(exc).__name__}: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
except ProviderResponseError as exc:
|
except ProviderResponseError as exc:
|
||||||
raise TranscriptionError(
|
raise TranscriptionError(
|
||||||
@@ -925,11 +944,13 @@ def handle_transcription_errors():
|
|||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
|
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
|
||||||
retriable=True,
|
retriable=True,
|
||||||
|
detail=f"{type(exc).__name__}: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
except ProviderError as exc:
|
except ProviderError as exc:
|
||||||
raise TranscriptionError(
|
raise TranscriptionError(
|
||||||
f"Provider transcription failed: {exc}",
|
"Provider transcription failed",
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
category=ErrorCategory.EXTERNAL_PROVIDER,
|
||||||
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
suggestion="Retry the transcription from jobs. If repeated, check provider availability.",
|
||||||
retriable=True,
|
retriable=True,
|
||||||
|
detail=f"{type(exc).__name__}: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from ..errors import AppError
|
|||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from ..errors import classify_unexpected_error
|
from ..errors import classify_unexpected_error
|
||||||
from ..errors import format_error_detail
|
from ..errors import format_error_detail
|
||||||
|
from ..providers import ProviderCallEvidence
|
||||||
from ..providers import ProviderError
|
from ..providers import ProviderError
|
||||||
from ..providers import RequestManifest
|
from ..providers import RequestManifest
|
||||||
from ..providers import SourceEvidenceReference
|
from ..providers import SourceEvidenceReference
|
||||||
@@ -48,6 +49,11 @@ _RETRIABLE_FAILED_JOB_ERROR_CATEGORIES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
"""Return current UTC as naive datetime for DB timestamp columns."""
|
||||||
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
async def create_document_with_people(
|
async def create_document_with_people(
|
||||||
*,
|
*,
|
||||||
document: Document,
|
document: Document,
|
||||||
@@ -272,13 +278,14 @@ async def process_queued_job( # noqa: PLR0915
|
|||||||
externally_stopped = True
|
externally_stopped = True
|
||||||
break
|
break
|
||||||
|
|
||||||
started_at = datetime.now(UTC)
|
started_at = _utc_now_naive()
|
||||||
# Fallback start for failures raised before the provider call; reset to the
|
# Fallback start for failures raised before the provider call; reset to the
|
||||||
# true call boundary immediately before the wait_for below.
|
# true call boundary immediately before the wait_for below.
|
||||||
monotonic_started_at = asyncio.get_running_loop().time()
|
monotonic_started_at = asyncio.get_running_loop().time()
|
||||||
result: TranscriptionResult | None = None
|
result: TranscriptionResult | None = None
|
||||||
provider_input = None
|
provider_input = None
|
||||||
page_outcome: _SuccessfulPage | _FailedPage
|
page_outcome: _SuccessfulPage | _FailedPage
|
||||||
|
provider_call_evidence = ProviderCallEvidence()
|
||||||
try:
|
try:
|
||||||
provider_input = build_provider_input(source, upload_dir=runtime_settings.upload_dir)
|
provider_input = build_provider_input(source, upload_dir=runtime_settings.upload_dir)
|
||||||
source_reference = SourceEvidenceReference(
|
source_reference = SourceEvidenceReference(
|
||||||
@@ -303,6 +310,7 @@ async def process_queued_job( # noqa: PLR0915
|
|||||||
provider=provider,
|
provider=provider,
|
||||||
source_reference=source_reference,
|
source_reference=source_reference,
|
||||||
requested_model=source_job.model,
|
requested_model=source_job.model,
|
||||||
|
evidence_capture=provider_call_evidence,
|
||||||
),
|
),
|
||||||
timeout=runtime_settings.worker_provider_timeout_seconds,
|
timeout=runtime_settings.worker_provider_timeout_seconds,
|
||||||
)
|
)
|
||||||
@@ -327,7 +335,7 @@ async def process_queued_job( # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
|
|
||||||
_validate_transcription_quality(result=result, settings=runtime_settings)
|
_validate_transcription_quality(result=result, settings=runtime_settings)
|
||||||
finished_at = datetime.now(UTC)
|
finished_at = _utc_now_naive()
|
||||||
page_outcome = _SuccessfulPage(
|
page_outcome = _SuccessfulPage(
|
||||||
source=source,
|
source=source,
|
||||||
result=result,
|
result=result,
|
||||||
@@ -347,7 +355,7 @@ async def process_queued_job( # noqa: PLR0915
|
|||||||
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
||||||
retriable=True,
|
retriable=True,
|
||||||
)
|
)
|
||||||
finished_at = datetime.now(UTC)
|
finished_at = _utc_now_naive()
|
||||||
page_outcome = _FailedPage(
|
page_outcome = _FailedPage(
|
||||||
source=source,
|
source=source,
|
||||||
error=error,
|
error=error,
|
||||||
@@ -361,8 +369,8 @@ async def process_queued_job( # noqa: PLR0915
|
|||||||
_duration_ms_between(started_at, finished_at),
|
_duration_ms_between(started_at, finished_at),
|
||||||
max(0, int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000)),
|
max(0, int((asyncio.get_running_loop().time() - monotonic_started_at) * 1000)),
|
||||||
),
|
),
|
||||||
request_manifest=provider.current_request_manifest,
|
request_manifest=provider_call_evidence.request_manifest,
|
||||||
transport_evidence=provider.current_transport_evidence,
|
transport_evidence=provider_call_evidence.transport_evidence,
|
||||||
failure_phase="local_timeout",
|
failure_phase="local_timeout",
|
||||||
)
|
)
|
||||||
failed_pages.append(page_outcome)
|
failed_pages.append(page_outcome)
|
||||||
@@ -382,7 +390,7 @@ async def process_queued_job( # noqa: PLR0915
|
|||||||
case _:
|
case _:
|
||||||
error = classify_unexpected_error(exc, operation="worker.process_job")
|
error = classify_unexpected_error(exc, operation="worker.process_job")
|
||||||
|
|
||||||
finished_at = datetime.now(UTC)
|
finished_at = _utc_now_naive()
|
||||||
provider_error = _find_provider_error(exc)
|
provider_error = _find_provider_error(exc)
|
||||||
page_outcome = _FailedPage(
|
page_outcome = _FailedPage(
|
||||||
source=source,
|
source=source,
|
||||||
@@ -668,10 +676,12 @@ async def _persist_page_outcome(
|
|||||||
if session is None:
|
if session is None:
|
||||||
async with unit_of_work(services=services, session=session) as local_session:
|
async with unit_of_work(services=services, session=session) as local_session:
|
||||||
await _write_page_outcome(job=job, services=services, page=page, session=local_session)
|
await _write_page_outcome(job=job, services=services, page=page, session=local_session)
|
||||||
|
await services.jobs.note_processing_progress(job_id=job.id, session=local_session)
|
||||||
await local_session.commit()
|
await local_session.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
await _write_page_outcome(job=job, services=services, page=page, session=session)
|
await _write_page_outcome(job=job, services=services, page=page, session=session)
|
||||||
|
await services.jobs.note_processing_progress(job_id=job.id, session=session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from transcription.ui.pages.people_page import register_page as register_people_
|
|||||||
from transcription.ui.pages.print_preview_page import register_page as register_print_preview_page
|
from transcription.ui.pages.print_preview_page import register_page as register_print_preview_page
|
||||||
from transcription.ui.pages.settings_page import register_page as register_settings_page
|
from transcription.ui.pages.settings_page import register_page as register_settings_page
|
||||||
from transcription.ui.pages.sources_page import register_page as register_sources_page
|
from transcription.ui.pages.sources_page import register_page as register_sources_page
|
||||||
from transcription.ui.pages.tags_page import register_page as register_tags_page
|
|
||||||
from transcription.ui.resources import read_css
|
from transcription.ui.resources import read_css
|
||||||
from transcription.ui.theme import apply_archival_theme
|
from transcription.ui.theme import apply_archival_theme
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ def register_pages(app: FastAPI) -> None:
|
|||||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||||
register_home_page()
|
register_home_page()
|
||||||
register_documents_page()
|
register_documents_page()
|
||||||
register_tags_page()
|
|
||||||
register_people_page()
|
register_people_page()
|
||||||
register_print_preview_page()
|
register_print_preview_page()
|
||||||
register_sources_page()
|
register_sources_page()
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ from transcription.ui.resources import read_svg
|
|||||||
|
|
||||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
("Documents", "/documents", "description"),
|
("Documents", "/documents", "description"),
|
||||||
("Tags", "/tags", "sell"),
|
|
||||||
("People", "/people", "group"),
|
("People", "/people", "group"),
|
||||||
("Sources", "/sources", "folder"),
|
|
||||||
("Jobs", "/jobs", "work_history"),
|
("Jobs", "/jobs", "work_history"),
|
||||||
("Settings", "/settings", "settings"),
|
("Settings", "/settings", "settings"),
|
||||||
)
|
)
|
||||||
@@ -23,10 +21,6 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
|||||||
return current_path == "/documents" or current_path.startswith("/documents/")
|
return current_path == "/documents" or current_path.startswith("/documents/")
|
||||||
if item_path == "/people":
|
if item_path == "/people":
|
||||||
return current_path == "/people" or current_path.startswith("/people/")
|
return current_path == "/people" or current_path.startswith("/people/")
|
||||||
if item_path == "/tags":
|
|
||||||
return current_path == "/tags" or current_path.startswith("/tags/")
|
|
||||||
if item_path == "/sources":
|
|
||||||
return current_path == "/sources" or current_path.startswith("/sources/")
|
|
||||||
if item_path == "/settings":
|
if item_path == "/settings":
|
||||||
return current_path == "/settings" or current_path.startswith("/settings/")
|
return current_path == "/settings" or current_path.startswith("/settings/")
|
||||||
return current_path == item_path
|
return current_path == item_path
|
||||||
|
|||||||
@@ -2,20 +2,28 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from pathlib import PureWindowsPath
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
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")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
_QUOTED_ABSOLUTE_PATH_RE = re.compile(
|
||||||
|
r"""(?P<quote>['"])(?P<path>(?:[A-Za-z]:[\\/][^'"]+|/(?!uploads/)[^'"]+))(?P=quote)"""
|
||||||
|
)
|
||||||
|
_UNQUOTED_WINDOWS_PATH_RE = re.compile(r"""(?P<path>[A-Za-z]:[\\/][^\s|]+)""")
|
||||||
|
_UNQUOTED_POSIX_PATH_RE = re.compile(r"""(?P<path>(?<![A-Za-z0-9:])/(?!uploads/)[^\s|]+)""")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class UiActionOutcome[T]:
|
class UiActionOutcome[T]:
|
||||||
@@ -67,14 +75,35 @@ def show_error(exc: Exception, *, title: str, operation: str) -> None:
|
|||||||
ui.label(f"Category: {display_error_category(error)}").classes("text-caption")
|
ui.label(f"Category: {display_error_category(error)}").classes("text-caption")
|
||||||
|
|
||||||
|
|
||||||
|
def display_failure_detail(error_detail: str | None) -> str | None:
|
||||||
|
"""Render persisted failure detail without machine-local paths."""
|
||||||
|
candidate = (error_detail or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sanitized = _QUOTED_ABSOLUTE_PATH_RE.sub(_replace_quoted_absolute_path, candidate)
|
||||||
|
sanitized = _UNQUOTED_WINDOWS_PATH_RE.sub(_replace_unquoted_absolute_path, sanitized)
|
||||||
|
sanitized = _UNQUOTED_POSIX_PATH_RE.sub(_replace_unquoted_absolute_path, sanitized)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
def display_error_category(error: AppError) -> str:
|
def display_error_category(error: AppError) -> str:
|
||||||
"""Return the canonical UI-facing category label for an AppError."""
|
"""Return the canonical UI-facing category label for an AppError."""
|
||||||
return canonical_error_category(error)
|
return canonical_error_category(error)
|
||||||
|
|
||||||
|
|
||||||
def summarize_error(exc: Exception, *, operation: str) -> str:
|
def _replace_quoted_absolute_path(match: re.Match[str]) -> str:
|
||||||
"""Return short one-line summary for status labels."""
|
quote = match.group("quote")
|
||||||
error = to_app_error(exc, operation=operation)
|
return f"{quote}{_basename_for_absolute_path(match.group('path'))}{quote}"
|
||||||
if error.category == ErrorCategory.INTERNAL_UNEXPECTED:
|
|
||||||
return f"Unexpected error (ref: {error.error_id})"
|
|
||||||
return f"{error.message} (ref: {error.error_id})"
|
def _replace_unquoted_absolute_path(match: re.Match[str]) -> str:
|
||||||
|
return _basename_for_absolute_path(match.group("path"))
|
||||||
|
|
||||||
|
|
||||||
|
def _basename_for_absolute_path(path: str) -> str:
|
||||||
|
if path.startswith("/uploads/"):
|
||||||
|
return path
|
||||||
|
if re.match(r"^[A-Za-z]:[\\/]", path):
|
||||||
|
return PureWindowsPath(path).name or "unknown"
|
||||||
|
return PurePosixPath(path).name or "unknown"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class DocumentTableRow:
|
|||||||
tags: str
|
tags: str
|
||||||
document_date: str
|
document_date: str
|
||||||
source_count: int
|
source_count: int
|
||||||
|
transcription_status: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
|
def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
|
||||||
@@ -37,6 +38,7 @@ def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
|
|||||||
"tags": row.tags or "Not tagged",
|
"tags": row.tags or "Not tagged",
|
||||||
"document_date": row.document_date,
|
"document_date": row.document_date,
|
||||||
"source_count": row.source_count,
|
"source_count": row.source_count,
|
||||||
|
"transcription_status": (row.transcription_status or "").lower(),
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
@@ -106,10 +108,19 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
|
|||||||
"align": "center",
|
"align": "center",
|
||||||
"style": "width: 10%;",
|
"style": "width: 10%;",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "transcription_status",
|
||||||
|
"label": "Transcription Status",
|
||||||
|
"field": "transcription_status",
|
||||||
|
"sortable": True,
|
||||||
|
"classes": "font-mono",
|
||||||
|
"align": "center",
|
||||||
|
"style": "width: 15%;",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
default_sort_by="name",
|
default_sort_by="name",
|
||||||
search_placeholder="Search documents by title, type, or author...",
|
search_placeholder="Search documents by title, type, or author...",
|
||||||
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
|
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}?from=documents"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Render document type using a subtle Quasar badge
|
# Render document type using a subtle Quasar badge
|
||||||
@@ -128,3 +139,20 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
|
|||||||
</q-td>
|
</q-td>
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
table.add_slot(
|
||||||
|
"body-cell-transcription_status",
|
||||||
|
r"""
|
||||||
|
<q-td :props="props">
|
||||||
|
<span v-if="!props.value">-</span>
|
||||||
|
<q-chip
|
||||||
|
v-else
|
||||||
|
dense
|
||||||
|
square
|
||||||
|
size="sm"
|
||||||
|
:class="`ui-status ui-status--${props.value}`"
|
||||||
|
>
|
||||||
|
{{ props.value.toUpperCase() }}
|
||||||
|
</q-chip>
|
||||||
|
</q-td>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|||||||
@@ -57,13 +57,18 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
def render_jobs_table(rows: Sequence[JobTableRow], *, document_context_id: str | None = None) -> None:
|
||||||
"""Render jobs table with search filtering and custom status chips."""
|
"""Render jobs table with search filtering and custom status chips."""
|
||||||
if not rows:
|
if not rows:
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
render_empty_state("No job records found in repository.")
|
render_empty_state("No job records found in repository.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def detail_target(job_id: str) -> str:
|
||||||
|
if document_context_id is not None:
|
||||||
|
return f"/jobs/{job_id}?from=document&document_id={document_context_id}"
|
||||||
|
return f"/jobs/{job_id}?from=jobs"
|
||||||
|
|
||||||
table = build_table(
|
table = build_table(
|
||||||
rows=_serialize_rows(rows),
|
rows=_serialize_rows(rows),
|
||||||
columns=[
|
columns=[
|
||||||
@@ -117,7 +122,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
|||||||
default_sort_by="updated_sort",
|
default_sort_by="updated_sort",
|
||||||
default_descending=True,
|
default_descending=True,
|
||||||
search_placeholder="Search jobs by ID, document, or status...",
|
search_placeholder="Search jobs by ID, document, or status...",
|
||||||
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
|
on_row_click_id=lambda job_id: ui.navigate.to(detail_target(job_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Render job execution status using themed Quasar chips
|
# Render job execution status using themed Quasar chips
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
|||||||
],
|
],
|
||||||
default_sort_by="name",
|
default_sort_by="name",
|
||||||
search_placeholder="Search people by name, tags, FamilySearch ID, or dates...",
|
search_placeholder="Search people by name, tags, FamilySearch ID, or dates...",
|
||||||
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
|
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}?from=people"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Custom column template adding an archival entity icon next to person's name
|
# Custom column template adding an archival entity icon next to person's name
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
|||||||
rows=_serialize_rows(rows),
|
rows=_serialize_rows(rows),
|
||||||
columns=[
|
columns=[
|
||||||
{
|
{
|
||||||
"name": "document_name",
|
"name": "upload_name",
|
||||||
"label": "Document Name",
|
"label": "Upload Title",
|
||||||
"field": "document_name",
|
"field": "upload_name",
|
||||||
"sortable": True,
|
"sortable": True,
|
||||||
"classes": "font-serif text-left ui-table-cell-wrap",
|
"classes": "font-serif text-left ui-table-cell-wrap",
|
||||||
"align": "left",
|
"align": "left",
|
||||||
@@ -69,9 +69,9 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
|||||||
"style": "width: 10%;",
|
"style": "width: 10%;",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "upload_name",
|
"name": "document_name",
|
||||||
"label": "Upload Title",
|
"label": "Document Name",
|
||||||
"field": "upload_name",
|
"field": "document_name",
|
||||||
"sortable": True,
|
"sortable": True,
|
||||||
"classes": "font-serif text-left ui-table-cell-wrap",
|
"classes": "font-serif text-left ui-table-cell-wrap",
|
||||||
"align": "left",
|
"align": "left",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from nicegui import ui
|
|||||||
from transcription.services.source_media import SOURCE_EXTENSIONS
|
from transcription.services.source_media import SOURCE_EXTENSIONS
|
||||||
|
|
||||||
SOURCE_UPLOAD_EXTENSIONS: tuple[str, ...] = tuple(sorted(SOURCE_EXTENSIONS))
|
SOURCE_UPLOAD_EXTENSIONS: tuple[str, ...] = tuple(sorted(SOURCE_EXTENSIONS))
|
||||||
|
GEDCOM_UPLOAD_EXTENSIONS: tuple[str, ...] = (".ged",)
|
||||||
IMAGE_UPLOAD_EXTENSIONS: tuple[str, ...] = (
|
IMAGE_UPLOAD_EXTENSIONS: tuple[str, ...] = (
|
||||||
".bmp",
|
".bmp",
|
||||||
".gif",
|
".gif",
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ from nicegui import ui
|
|||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Source
|
||||||
from transcription.db.registries import AUTHOR_ROLE_SEMANTIC_KEY
|
from transcription.db.registries import AUTHOR_ROLE_SEMANTIC_KEY
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
from transcription.services.documents import DocumentDeleteBlockedError
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
from transcription.services.documents import DocumentError
|
from transcription.services.documents import DocumentError
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.people import PeopleService
|
from transcription.services.people import PeopleService
|
||||||
|
from transcription.services.sources import SourceService
|
||||||
from transcription.services.workflows import create_document_with_people
|
from transcription.services.workflows import create_document_with_people
|
||||||
from transcription.services.workflows import update_document_with_people
|
from transcription.services.workflows import update_document_with_people
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
@@ -27,10 +29,13 @@ from transcription.ui.components.confirm_delete import dependency_summary
|
|||||||
from transcription.ui.components.confirm_delete import render_delete_actions
|
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_link_row
|
||||||
from transcription.ui.components.data_display import metadata_row
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
from transcription.ui.components.error_presenter import run_ui_action
|
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 google_maps_search_url
|
||||||
from transcription.ui.components.formatters import parse_iso_date
|
from transcription.ui.components.formatters import parse_iso_date
|
||||||
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
|
||||||
@@ -43,7 +48,6 @@ 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
|
||||||
from transcription.ui.components.table.documents import DocumentTableRow
|
from transcription.ui.components.table.documents import DocumentTableRow
|
||||||
from transcription.ui.components.table.documents import render_documents_table
|
from transcription.ui.components.table.documents import render_documents_table
|
||||||
from transcription.ui.components.viewers import dark_room_viewer
|
|
||||||
from transcription.ui.runtime import resolve_runtime_settings
|
from transcription.ui.runtime import resolve_runtime_settings
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
@@ -199,6 +203,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
document_date=compact_date(doc.document_date, doc.document_date_raw),
|
document_date=compact_date(doc.document_date, doc.document_date_raw),
|
||||||
document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else ""),
|
document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else ""),
|
||||||
source_count=len(doc.sources),
|
source_count=len(doc.sources),
|
||||||
|
transcription_status=_latest_job_status(doc),
|
||||||
)
|
)
|
||||||
for doc in documents
|
for doc in documents
|
||||||
]
|
]
|
||||||
@@ -207,8 +212,22 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
@ui.page("/documents/{document_id}")
|
@ui.page("/documents/{document_id}")
|
||||||
async def document_detail_page(request: Request, document_id: str, session_factory: SessionFactoryDep) -> None:
|
async def document_detail_page(request: Request, document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
document_service = DocumentService(session_factory=session_factory)
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
sources_service = SourceService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/documents")
|
render_navigation_header(current_path="/documents")
|
||||||
settings = resolve_runtime_settings(request)
|
settings = resolve_runtime_settings(request)
|
||||||
|
back_label = "Back to Documents"
|
||||||
|
back_target = "/documents"
|
||||||
|
from_context = request.query_params.get("from")
|
||||||
|
if from_context == "person":
|
||||||
|
person_id = parse_uuid(request.query_params.get("person_id"))
|
||||||
|
if person_id is not None:
|
||||||
|
back_label = "Back to Person"
|
||||||
|
back_target = f"/people/{person_id}"
|
||||||
|
elif from_context == "job":
|
||||||
|
job_id = parse_uuid(request.query_params.get("job_id"))
|
||||||
|
if job_id is not None:
|
||||||
|
back_label = "Back to Job"
|
||||||
|
back_target = f"/jobs/{job_id}"
|
||||||
|
|
||||||
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
||||||
if parsed_doc_id is None:
|
if parsed_doc_id is None:
|
||||||
@@ -223,12 +242,16 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
show_error(exc, title="Load failed", operation="documents.read")
|
show_error(exc, title="Load failed", operation="documents.read")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
active_source = _resolve_active_source(document, parse_uuid(request.query_params.get("source_id")))
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
type_display = document.document_type_ref.label if document.document_type_ref is not None else "Unspecified"
|
type_display = document.document_type_ref.label if document.document_type_ref is not None else "Unspecified"
|
||||||
|
first_source = _resolve_active_source(document, None)
|
||||||
with section_header_row():
|
with section_header_row():
|
||||||
page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}")
|
page_header(document.name, subtitle=f"Type: {type_display} | ID: {document.id}")
|
||||||
|
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
|
ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").props("flat")
|
||||||
ui.button(
|
ui.button(
|
||||||
"Print",
|
"Print",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/print"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/print"),
|
||||||
@@ -239,6 +262,24 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
|
||||||
icon="edit",
|
icon="edit",
|
||||||
).classes("ui-btn-primary text-xs")
|
).classes("ui-btn-primary text-xs")
|
||||||
|
ui.button(
|
||||||
|
"Document Details",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/info"),
|
||||||
|
icon="info",
|
||||||
|
).props("flat").classes("text-xs")
|
||||||
|
ui.button(
|
||||||
|
"View Source Detail",
|
||||||
|
on_click=(
|
||||||
|
(
|
||||||
|
lambda: ui.navigate.to(
|
||||||
|
f"/sources/{first_source.id}?from=document&document_id={document.id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if first_source is not None
|
||||||
|
else (lambda: ui.notify("No source pages are linked yet.", type="warning"))
|
||||||
|
),
|
||||||
|
icon="description",
|
||||||
|
).props("flat").classes("text-xs")
|
||||||
destructive_button(
|
destructive_button(
|
||||||
"Delete",
|
"Delete",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
||||||
@@ -247,19 +288,105 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
|
|
||||||
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
_render_bento_viewer_zone(document, base_url=str(request.base_url), settings=settings)
|
_render_document_detail_viewer_zone(
|
||||||
_render_bento_metadata_zone(document)
|
document=document,
|
||||||
|
active_source=active_source,
|
||||||
|
base_url=str(request.base_url),
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
_render_document_detail_revision_zone(
|
||||||
|
source=active_source,
|
||||||
|
sources_service=sources_service,
|
||||||
|
)
|
||||||
_render_bento_relations_zone(document)
|
_render_bento_relations_zone(document)
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/info")
|
||||||
|
async def document_info_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
||||||
|
if parsed_doc_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||||
|
except DocumentError:
|
||||||
|
render_record_not_found("Document")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.info.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-6xl mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header("Document Info", subtitle=f"{document.name} ({document.id})")
|
||||||
|
ui.button(
|
||||||
|
"Back to Document",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).props("flat")
|
||||||
|
_render_bento_metadata_zone(document)
|
||||||
|
|
||||||
@ui.page("/documents/{document_id}/jobs")
|
@ui.page("/documents/{document_id}/jobs")
|
||||||
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
|
async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
|
||||||
_ = session_factory
|
_ = session_factory
|
||||||
return RedirectResponse(url=f"/ui/jobs?document_id={document_id}")
|
return RedirectResponse(url=f"/ui/jobs?document_id={document_id}")
|
||||||
|
|
||||||
@ui.page("/documents/{document_id}/sources")
|
@ui.page("/documents/{document_id}/sources")
|
||||||
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
|
async def document_sources_page(request: Request, document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
_ = session_factory
|
document_service = DocumentService(session_factory=session_factory)
|
||||||
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
|
render_navigation_header(current_path="/documents")
|
||||||
|
settings = resolve_runtime_settings(request)
|
||||||
|
|
||||||
|
parsed_doc_id = parsed_record_id(document_id, noun="Document")
|
||||||
|
if parsed_doc_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await document_service.read_document_detail(document_id=parsed_doc_id)
|
||||||
|
except DocumentError:
|
||||||
|
render_record_not_found("Document")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.sources.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
ordered_sources = _sorted_document_sources(document)
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header("Source Images", subtitle=f"{document.name} ({len(ordered_sources)} pages)")
|
||||||
|
ui.button(
|
||||||
|
"Back to Document",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).props("flat")
|
||||||
|
|
||||||
|
if not ordered_sources:
|
||||||
|
render_empty_state("No source pages are linked yet.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.grid().classes("w-full grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3"):
|
||||||
|
for source in ordered_sources:
|
||||||
|
source_url = resolve_media_url(
|
||||||
|
source.file_path,
|
||||||
|
upload_dir=settings.upload_dir,
|
||||||
|
base_url=str(request.base_url),
|
||||||
|
)
|
||||||
|
with archival_card(extra_classes="gap-2"):
|
||||||
|
if source_url is None:
|
||||||
|
render_empty_state("Image unavailable.", extra_classes="text-xs")
|
||||||
|
else:
|
||||||
|
ui.image(source_url).classes("w-full aspect-[3/4] object-contain rounded-sm bg-black/5")
|
||||||
|
ui.label(f"Page {source.page_number}").classes("text-xs font-semibold")
|
||||||
|
ui.label(source.filename).classes("text-[11px] ui-text-muted break-all")
|
||||||
|
ui.button(
|
||||||
|
"Open Source Detail",
|
||||||
|
on_click=lambda _=None, source_id=source.id: ui.navigate.to(
|
||||||
|
f"/sources/{source_id}?from=document&document_id={document.id}"
|
||||||
|
),
|
||||||
|
icon="description",
|
||||||
|
).props("flat dense").classes("text-xs self-start")
|
||||||
|
|
||||||
@ui.page("/documents/{document_id}/edit")
|
@ui.page("/documents/{document_id}/edit")
|
||||||
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
@@ -523,17 +650,134 @@ def _render_document_form_fields(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_bento_viewer_zone(document: Document, *, base_url: str, settings: Settings) -> None:
|
def _resolve_active_source(document: Document, requested_source_id: UUID | None) -> Source | None:
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4"):
|
ordered = _sorted_document_sources(document)
|
||||||
source_path = document.sources[0].file_path if document.sources else None
|
if not ordered:
|
||||||
source_url = resolve_media_url(source_path, upload_dir=settings.upload_dir, base_url=base_url)
|
return None
|
||||||
dark_room_viewer(source_url, count_label=f"{len(document.sources)} Source(s) Linked")
|
if requested_source_id is None:
|
||||||
|
return ordered[0]
|
||||||
|
for source in ordered:
|
||||||
|
if source.id == requested_source_id:
|
||||||
|
return source
|
||||||
|
return ordered[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_document_sources(document: Document) -> list[Source]:
|
||||||
|
return sorted(
|
||||||
|
document.sources,
|
||||||
|
key=lambda source: (source.page_number, source.upload_name.casefold(), source.filename.casefold()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_document_detail_viewer_zone(
|
||||||
|
*,
|
||||||
|
document: Document,
|
||||||
|
active_source: Source | None,
|
||||||
|
base_url: str,
|
||||||
|
settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-2"):
|
||||||
|
_render_document_source_navigation(document=document, active_source=active_source)
|
||||||
|
if active_source is None:
|
||||||
|
render_document_panzoom(media_url=None, filename="No source pages", count_label="0 Source Pages")
|
||||||
|
return
|
||||||
|
source_url = resolve_media_url(active_source.file_path, upload_dir=settings.upload_dir, base_url=base_url)
|
||||||
|
render_document_panzoom(
|
||||||
|
media_url=source_url,
|
||||||
|
filename=active_source.filename,
|
||||||
|
count_label=f"Page {active_source.page_number}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_document_source_navigation(*, document: Document, active_source: Source | None) -> None:
|
||||||
|
ordered = sorted(document.sources, key=lambda source: (source.page_number, source.id))
|
||||||
|
if not ordered or active_source is None:
|
||||||
|
with ui.row().classes("w-full justify-between items-center"):
|
||||||
|
ui.button("Previous Page", icon="chevron_left").props("flat dense disable")
|
||||||
|
ui.button("Next Page", icon="chevron_right").props("flat dense icon-right disable")
|
||||||
|
return
|
||||||
|
active_index = next((index for index, source in enumerate(ordered) if source.id == active_source.id), 0)
|
||||||
|
previous_source = ordered[active_index - 1] if active_index > 0 else None
|
||||||
|
next_source = ordered[active_index + 1] if active_index < len(ordered) - 1 else None
|
||||||
|
previous_target = f"/documents/{document.id}?source_id={previous_source.id}" if previous_source is not None else "#"
|
||||||
|
next_target = f"/documents/{document.id}?source_id={next_source.id}" if next_source is not None else "#"
|
||||||
|
with ui.row().classes("w-full justify-between items-center"):
|
||||||
|
previous = ui.button(
|
||||||
|
"Previous Page",
|
||||||
|
on_click=lambda: ui.navigate.to(previous_target),
|
||||||
|
icon="chevron_left",
|
||||||
|
).props("flat dense")
|
||||||
|
if previous_source is None:
|
||||||
|
previous.props("disable")
|
||||||
|
following = ui.button(
|
||||||
|
"Next Page",
|
||||||
|
on_click=lambda: ui.navigate.to(next_target),
|
||||||
|
icon="chevron_right",
|
||||||
|
).props("flat dense icon-right")
|
||||||
|
if next_source is None:
|
||||||
|
following.props("disable")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_document_detail_revision_zone(*, source: Source | None, sources_service: SourceService) -> None:
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-6 gap-4"), archival_card(title="Editable Revision"):
|
||||||
|
if source is None:
|
||||||
|
render_empty_state("No source pages are linked yet.", italic=True)
|
||||||
|
return
|
||||||
|
seed_revision = source.revised_text if source.revised_text is not None else (source.raw_transcription or "")
|
||||||
|
revision_input = (
|
||||||
|
ui.textarea(
|
||||||
|
label="Revised transcription",
|
||||||
|
value=seed_revision,
|
||||||
|
)
|
||||||
|
.props("outlined autogrow")
|
||||||
|
.classes("w-full ui-form-surface")
|
||||||
|
)
|
||||||
|
save_state = ui.label(
|
||||||
|
f"Last saved: {source.date_revised.isoformat()}"
|
||||||
|
if source.date_revised is not None
|
||||||
|
else "No revision saved yet."
|
||||||
|
).classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
|
async def submit_revision() -> None:
|
||||||
|
revised_text = (revision_input.value or "").strip()
|
||||||
|
if not revised_text:
|
||||||
|
ui.notify("Revised transcription cannot be empty.", type="warning")
|
||||||
|
return
|
||||||
|
save_outcome = await run_ui_action(
|
||||||
|
operation="documents.revision.save",
|
||||||
|
title="Save failed",
|
||||||
|
action=lambda: sources_service.upsert_revision_for_source(source_id=source.id, text=revised_text),
|
||||||
|
)
|
||||||
|
if not save_outcome.ok or save_outcome.value is None:
|
||||||
|
return
|
||||||
|
updated = save_outcome.value
|
||||||
|
source.revised_text = updated.revised_text
|
||||||
|
source.date_revised = updated.date_revised
|
||||||
|
save_state.text = (
|
||||||
|
f"Last saved: {updated.date_revised.isoformat()}"
|
||||||
|
if updated.date_revised is not None
|
||||||
|
else "Revision saved."
|
||||||
|
)
|
||||||
|
ui.notify("Revision saved", type="positive")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Save revision", on_click=submit_revision, icon="save").classes("ui-btn-primary")
|
||||||
|
ui.button(
|
||||||
|
"Reset",
|
||||||
|
on_click=lambda: _reset_document_revision_text(revision_input, source),
|
||||||
|
icon="refresh",
|
||||||
|
).props("flat")
|
||||||
|
|
||||||
|
|
||||||
|
def _first_source_path(document: Document) -> str | None:
|
||||||
|
first_source = _resolve_active_source(document, None)
|
||||||
|
return first_source.file_path if first_source is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _render_bento_metadata_zone(document: Document) -> None:
|
def _render_bento_metadata_zone(document: Document) -> None:
|
||||||
author_names = _author_names(document)
|
author_names = _author_names(document)
|
||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("w-full gap-4"):
|
||||||
with archival_card(title="Archival Metadata"):
|
with archival_card(title="Archival Metadata"):
|
||||||
metadata_row("Author(s):", ", ".join(author_names) if author_names else "Not set")
|
metadata_row("Author(s):", ", ".join(author_names) if author_names else "Not set")
|
||||||
metadata_row(
|
metadata_row(
|
||||||
@@ -546,7 +790,14 @@ def _render_bento_metadata_zone(document: Document) -> None:
|
|||||||
)
|
)
|
||||||
metadata_row("Tags:", ", ".join(tags) if tags else "Not set")
|
metadata_row("Tags:", ", ".join(tags) if tags else "Not set")
|
||||||
metadata_row("Document Date:", _detail_document_date(document.document_date, document.document_date_raw))
|
metadata_row("Document Date:", _detail_document_date(document.document_date, document.document_date_raw))
|
||||||
metadata_row("Location Created:", document.location_created or "Not set")
|
if document.location_created:
|
||||||
|
metadata_link_row(
|
||||||
|
"Location Created:",
|
||||||
|
document.location_created,
|
||||||
|
google_maps_search_url(document.location_created),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
metadata_row("Location Created:", "Not set")
|
||||||
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
||||||
|
|
||||||
with ui.column().classes("w-full mt-2"):
|
with ui.column().classes("w-full mt-2"):
|
||||||
@@ -565,7 +816,7 @@ def _detail_document_date(exact: date | None, approximate: str | None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _render_bento_relations_zone(document: Document) -> None:
|
def _render_bento_relations_zone(document: Document) -> None:
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
with ui.column().classes("col-span-12 lg:col-span-2 gap-4"):
|
||||||
_render_related_people_card(document)
|
_render_related_people_card(document)
|
||||||
_render_document_processing_card(document)
|
_render_document_processing_card(document)
|
||||||
|
|
||||||
@@ -584,23 +835,27 @@ def _render_related_people_card(document: Document) -> None:
|
|||||||
for person in grouped[role_label]:
|
for person in grouped[role_label]:
|
||||||
ui.button(
|
ui.button(
|
||||||
person.full_name,
|
person.full_name,
|
||||||
on_click=lambda _=None, person_id=person.id: ui.navigate.to(f"/people/{person_id}"),
|
on_click=lambda _=None, person_id=person.id: ui.navigate.to(
|
||||||
|
f"/people/{person_id}?from=document&document_id={document.id}"
|
||||||
|
),
|
||||||
icon="person",
|
icon="person",
|
||||||
).props("flat dense no-caps").classes("self-start text-xs font-semibold ui-link-primary")
|
).props("flat dense no-caps").classes("self-start text-xs font-semibold ui-link-primary")
|
||||||
|
|
||||||
|
|
||||||
def _render_document_processing_card(document: Document) -> None:
|
def _render_document_processing_card(document: Document) -> None:
|
||||||
with archival_card(title="Sources & Pipeline Jobs"):
|
with archival_card(title="Source Pages & Transcriptions"):
|
||||||
metadata_row("Sources:", str(len(document.sources)))
|
metadata_row("Source pages:", str(len(document.sources)))
|
||||||
metadata_row("Jobs:", str(len(document.jobs)))
|
metadata_row("Transcription Jobs:", str(len(document.jobs)))
|
||||||
with ui.row().classes("w-full gap-2 mt-2 flex-wrap"):
|
with ui.row().classes("w-full gap-2 mt-2 flex-wrap"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"View Sources",
|
"View Source Images",
|
||||||
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
|
||||||
icon="description",
|
icon="photo_library",
|
||||||
).props("flat dense text-xs").classes("ui-link-primary")
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
ui.button(
|
ui.button(
|
||||||
"View Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history"
|
"View Transcription Jobs",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
|
||||||
|
icon="work_history",
|
||||||
).props("flat dense text-xs").classes("ui-link-primary")
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
ui.button(
|
ui.button(
|
||||||
"+ Add Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add"
|
"+ Add Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add"
|
||||||
@@ -670,3 +925,14 @@ def _resolve_selected_tag_labels(value: object) -> list[str]:
|
|||||||
|
|
||||||
labels = [candidate.strip() for candidate in flatten(value) if candidate and candidate.strip()]
|
labels = [candidate.strip() for candidate in flatten(value) if candidate and candidate.strip()]
|
||||||
return list(dict.fromkeys(labels))
|
return list(dict.fromkeys(labels))
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_job_status(document: Document) -> str | None:
|
||||||
|
if not document.jobs:
|
||||||
|
return None
|
||||||
|
latest = max(document.jobs, key=lambda job: (job.date_created, str(job.id)))
|
||||||
|
return latest.status.value
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_document_revision_text(revision_input: ui.textarea, source: Source) -> None:
|
||||||
|
revision_input.value = source.revised_text if source.revised_text is not None else (source.raw_transcription or "")
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
"ui-btn-primary"
|
"ui-btn-primary"
|
||||||
)
|
)
|
||||||
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
||||||
|
elif parsed_document_id is not None:
|
||||||
|
ui.button(
|
||||||
|
"Back to Document",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{parsed_document_id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).props("flat")
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
async def render_table() -> None:
|
async def render_table() -> None:
|
||||||
@@ -86,7 +92,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
for job in jobs
|
for job in jobs
|
||||||
]
|
]
|
||||||
render_jobs_table(rows)
|
render_jobs_table(rows, document_context_id=str(parsed_document_id) if parsed_document_id else None)
|
||||||
|
|
||||||
await render_table()
|
await render_table()
|
||||||
|
|
||||||
@@ -241,9 +247,17 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
async def job_detail_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
back_label = "Back to Jobs"
|
||||||
|
back_target = "/jobs"
|
||||||
|
from_context = request.query_params.get("from")
|
||||||
|
if from_context == "document":
|
||||||
|
document_id = parse_uuid(request.query_params.get("document_id"))
|
||||||
|
if document_id is not None:
|
||||||
|
back_label = "Back to Document"
|
||||||
|
back_target = f"/documents/{document_id}"
|
||||||
|
|
||||||
parsed_job_id = parsed_record_id(job_id, noun="Job")
|
parsed_job_id = parsed_record_id(job_id, noun="Job")
|
||||||
if parsed_job_id is None:
|
if parsed_job_id is None:
|
||||||
@@ -262,7 +276,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
def render_detail() -> None:
|
def render_detail() -> None:
|
||||||
active_job = current_job[0]
|
active_job = current_job[0]
|
||||||
_render_job_detail_header(active_job)
|
_render_job_detail_header(active_job, back_label=back_label, back_target=back_target)
|
||||||
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
|
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
|
||||||
_render_job_logistics(active_job)
|
_render_job_logistics(active_job)
|
||||||
_render_job_document_links(active_job)
|
_render_job_document_links(active_job)
|
||||||
@@ -540,10 +554,11 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
|
|||||||
render_upload_list()
|
render_upload_list()
|
||||||
|
|
||||||
|
|
||||||
def _render_job_detail_header(job: Job) -> None:
|
def _render_job_detail_header(job: Job, *, back_label: str, back_target: str) -> None:
|
||||||
with section_header_row(classes="justify-between items-center"):
|
with section_header_row(classes="justify-between items-center"):
|
||||||
page_header(f"Job Record: {job.id}")
|
page_header(f"Job Record: {job.id}")
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
|
ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").props("flat")
|
||||||
archival_badge(job.status.value.upper())
|
archival_badge(job.status.value.upper())
|
||||||
|
|
||||||
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
|
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
|
||||||
@@ -584,7 +599,7 @@ def _render_job_document_links(job: Job) -> None:
|
|||||||
ui.label("Document Name:").classes("text-xs font-semibold ui-text-primary")
|
ui.label("Document Name:").classes("text-xs font-semibold ui-text-primary")
|
||||||
ui.button(
|
ui.button(
|
||||||
document_name,
|
document_name,
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}?from=job&job_id={job.id}"),
|
||||||
icon="description",
|
icon="description",
|
||||||
).props("flat dense no-caps").classes("ui-link-primary text-xs")
|
).props("flat dense no-caps").classes("ui-link-primary text-xs")
|
||||||
|
|
||||||
@@ -592,7 +607,7 @@ def _render_job_document_links(job: Job) -> None:
|
|||||||
with ui.row().classes("w-full gap-2 mt-2"):
|
with ui.row().classes("w-full gap-2 mt-2"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"View Sources",
|
"View Sources",
|
||||||
on_click=lambda: ui.navigate.to(f"/sources?document_id={job.document_id}"),
|
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
|
||||||
icon="description",
|
icon="description",
|
||||||
).props("flat dense text-xs").classes("ui-link-primary")
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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
|
||||||
from transcription.ui.components.formatters import google_maps_search_url
|
from transcription.ui.components.formatters import google_maps_search_url
|
||||||
from transcription.ui.components.formatters import parse_iso_date
|
from transcription.ui.components.formatters import parse_iso_date
|
||||||
|
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
|
||||||
from transcription.ui.components.guards import render_record_not_found
|
from transcription.ui.components.guards import render_record_not_found
|
||||||
from transcription.ui.components.media_urls import resolve_media_url
|
from transcription.ui.components.media_urls import resolve_media_url
|
||||||
@@ -178,6 +179,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
people_service = PeopleService(session_factory=session_factory)
|
people_service = PeopleService(session_factory=session_factory)
|
||||||
photos_service = PhotosService(session_factory=session_factory)
|
photos_service = PhotosService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/people")
|
render_navigation_header(current_path="/people")
|
||||||
|
back_label = "Back to People"
|
||||||
|
back_target = "/people"
|
||||||
|
from_context = request.query_params.get("from")
|
||||||
|
if from_context == "document":
|
||||||
|
document_id = parse_uuid(request.query_params.get("document_id"))
|
||||||
|
if document_id is not None:
|
||||||
|
back_label = "Back to Document"
|
||||||
|
back_target = f"/documents/{document_id}"
|
||||||
|
|
||||||
parsed_person_id = parsed_record_id(person_id, noun="Person")
|
parsed_person_id = parsed_record_id(person_id, noun="Person")
|
||||||
if parsed_person_id is None:
|
if parsed_person_id is None:
|
||||||
@@ -197,6 +206,7 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
|
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
|
||||||
|
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
|
ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").props("flat")
|
||||||
ui.button(
|
ui.button(
|
||||||
"New Document",
|
"New Document",
|
||||||
on_click=lambda: ui.navigate.to(f"/documents/new?person_id={person.id}"),
|
on_click=lambda: ui.navigate.to(f"/documents/new?person_id={person.id}"),
|
||||||
@@ -645,13 +655,22 @@ async def _render_person_photo_zone(
|
|||||||
active_index = [0]
|
active_index = [0]
|
||||||
|
|
||||||
with ui.column().classes("col-span-12 lg:col-span-4"), archival_card(title="Photos", extra_classes="gap-3"):
|
with ui.column().classes("col-span-12 lg:col-span-4"), archival_card(title="Photos", extra_classes="gap-3"):
|
||||||
_render_photo_viewer_with_navigation(
|
|
||||||
photos=photos,
|
@ui.refreshable
|
||||||
active_index=active_index,
|
def render_photo_viewer() -> None:
|
||||||
settings=settings,
|
def refresh_photo_viewer() -> None:
|
||||||
request=request,
|
render_photo_viewer.refresh()
|
||||||
empty_message="No portrait photo uploaded yet.",
|
|
||||||
)
|
_render_photo_viewer_with_navigation(
|
||||||
|
photos=photos,
|
||||||
|
active_index=active_index,
|
||||||
|
settings=settings,
|
||||||
|
request=request,
|
||||||
|
empty_message="No portrait photo uploaded yet.",
|
||||||
|
on_change=refresh_photo_viewer,
|
||||||
|
)
|
||||||
|
|
||||||
|
render_photo_viewer()
|
||||||
|
|
||||||
|
|
||||||
def _shift_gallery_index(*, photos: list, active_index: list[int], step: int) -> None:
|
def _shift_gallery_index(*, photos: list, active_index: list[int], step: int) -> None:
|
||||||
@@ -770,6 +789,7 @@ def _render_linked_documents(person: Person) -> None:
|
|||||||
{
|
{
|
||||||
"id": str(link.document.id),
|
"id": str(link.document.id),
|
||||||
"document_name": link.document.name,
|
"document_name": link.document.name,
|
||||||
|
"document_date": compact_date(link.document.document_date, link.document.document_date_raw),
|
||||||
"role": link.role_ref.label if link.role_ref is not None else "Unknown role",
|
"role": link.role_ref.label if link.role_ref is not None else "Unknown role",
|
||||||
"page_count": len(link.document.sources),
|
"page_count": len(link.document.sources),
|
||||||
}
|
}
|
||||||
@@ -795,6 +815,14 @@ def _render_linked_documents(person: Person) -> None:
|
|||||||
"classes": "text-left ui-table-cell-wrap",
|
"classes": "text-left ui-table-cell-wrap",
|
||||||
"align": "left",
|
"align": "left",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "document_date",
|
||||||
|
"label": "Document Date",
|
||||||
|
"field": "document_date",
|
||||||
|
"sortable": True,
|
||||||
|
"classes": "font-mono",
|
||||||
|
"align": "center",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "role",
|
"name": "role",
|
||||||
"label": "Role",
|
"label": "Role",
|
||||||
@@ -812,7 +840,7 @@ def _render_linked_documents(person: Person) -> None:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
default_sort_by="document_name",
|
default_sort_by="document_name",
|
||||||
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
|
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}?from=person&person_id={person.id}"),
|
||||||
show_search=False,
|
show_search=False,
|
||||||
)
|
)
|
||||||
table.add_slot(
|
table.add_slot(
|
||||||
|
|||||||
@@ -2,26 +2,41 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from nicegui import events
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
|
from transcription.db.models import MaintenanceJobType
|
||||||
|
from transcription.db.models import MaintenanceRunStatus
|
||||||
from transcription.runtime_helpers import run_blocking
|
from transcription.runtime_helpers import run_blocking
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.maintenance import MaintenanceService
|
||||||
from transcription.services.people import PeopleService
|
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 display_failure_detail
|
||||||
from transcription.ui.components.error_presenter import run_ui_action
|
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
|
||||||
|
from transcription.ui.components.table.common import build_table
|
||||||
from transcription.ui.components.table.registry import render_registry_table
|
from transcription.ui.components.table.registry import render_registry_table
|
||||||
|
from transcription.ui.components.upload_panel import GEDCOM_UPLOAD_EXTENSIONS
|
||||||
|
from transcription.ui.components.upload_panel import render_upload_picker
|
||||||
from transcription.ui.homepage_store import read_homepage_markdown
|
from transcription.ui.homepage_store import read_homepage_markdown
|
||||||
from transcription.ui.homepage_store import save_homepage_markdown
|
from transcription.ui.homepage_store import save_homepage_markdown
|
||||||
|
from transcription.ui.runtime_settings_store import HIDDEN_SETTINGS_CATEGORIES
|
||||||
|
from transcription.ui.runtime_settings_store import read_runtime_settings_snapshot
|
||||||
|
from transcription.ui.runtime_settings_store import save_runtime_settings
|
||||||
from transcription.ui.theme import page_header
|
from transcription.ui.theme import page_header
|
||||||
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
@@ -30,9 +45,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
"""Register the constrained Settings route."""
|
"""Register the constrained Settings route."""
|
||||||
|
|
||||||
@ui.page("/settings")
|
@ui.page("/settings")
|
||||||
async def settings_page(session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
async def settings_page(request: Request, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
||||||
documents = DocumentService(session_factory=session_factory)
|
documents = DocumentService(session_factory=session_factory)
|
||||||
people = PeopleService(session_factory=session_factory)
|
people = PeopleService(session_factory=session_factory)
|
||||||
|
maintenance = MaintenanceService(session_factory=session_factory)
|
||||||
prompts = PromptStore(settings=settings)
|
prompts = PromptStore(settings=settings)
|
||||||
render_navigation_header(current_path="/settings")
|
render_navigation_header(current_path="/settings")
|
||||||
|
|
||||||
@@ -497,12 +513,260 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary")
|
ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary")
|
||||||
|
|
||||||
|
@ui.refreshable
|
||||||
|
async def render_maintenance() -> None:
|
||||||
|
with archival_card("Maintenance"):
|
||||||
|
ui.label(
|
||||||
|
"Queue maintenance tasks for worker execution. Runs are persisted with summary and logs."
|
||||||
|
).classes("text-xs ui-text-muted mb-3")
|
||||||
|
await _render_gedcom_import_controls(
|
||||||
|
maintenance=maintenance,
|
||||||
|
request=request,
|
||||||
|
refresh=render_maintenance.refresh,
|
||||||
|
)
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Run Backup",
|
||||||
|
icon="save",
|
||||||
|
on_click=lambda: _enqueue_maintenance_run(
|
||||||
|
maintenance=maintenance,
|
||||||
|
job_type=MaintenanceJobType.BACKUP,
|
||||||
|
request=request,
|
||||||
|
refresh=render_maintenance.refresh,
|
||||||
|
),
|
||||||
|
).props("flat")
|
||||||
|
ui.button(
|
||||||
|
"Run Storage Reconciliation",
|
||||||
|
icon="rule",
|
||||||
|
on_click=lambda: _enqueue_maintenance_run(
|
||||||
|
maintenance=maintenance,
|
||||||
|
job_type=MaintenanceJobType.STORAGE_RECONCILIATION,
|
||||||
|
request=request,
|
||||||
|
refresh=render_maintenance.refresh,
|
||||||
|
),
|
||||||
|
).props("flat")
|
||||||
|
|
||||||
|
runs_outcome = await run_ui_action(
|
||||||
|
operation="settings.maintenance.list",
|
||||||
|
title="Maintenance runs unavailable",
|
||||||
|
action=maintenance.list_runs,
|
||||||
|
)
|
||||||
|
if not runs_outcome.ok:
|
||||||
|
return
|
||||||
|
runs = list(runs_outcome.value or ())
|
||||||
|
if not runs:
|
||||||
|
render_empty_state("No maintenance runs recorded yet.", extra_classes="mt-3")
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"id": str(run.id),
|
||||||
|
"job_type": run.job_type.value.replace("_", " ").title(),
|
||||||
|
"status": run.status.value,
|
||||||
|
"started_at": _format_timestamp(run.started_at),
|
||||||
|
"finished_at": _format_timestamp(run.finished_at),
|
||||||
|
"duration": _format_duration(started_at=run.started_at, finished_at=run.finished_at),
|
||||||
|
"summary": run.summary or "-",
|
||||||
|
"log_path": run.log_path or "",
|
||||||
|
"error_detail": display_failure_detail(run.error_detail) or "",
|
||||||
|
}
|
||||||
|
for run in runs
|
||||||
|
]
|
||||||
|
|
||||||
|
table = build_table(
|
||||||
|
rows=rows,
|
||||||
|
columns=[
|
||||||
|
{"name": "job_type", "label": "Job Type", "field": "job_type", "sortable": True},
|
||||||
|
{"name": "status", "label": "Status", "field": "status", "sortable": True},
|
||||||
|
{"name": "started_at", "label": "Started", "field": "started_at", "sortable": True},
|
||||||
|
{"name": "finished_at", "label": "Finished", "field": "finished_at", "sortable": True},
|
||||||
|
{"name": "duration", "label": "Duration", "field": "duration", "sortable": False},
|
||||||
|
{"name": "summary", "label": "Summary", "field": "summary", "sortable": False},
|
||||||
|
],
|
||||||
|
row_key="id",
|
||||||
|
selection="single",
|
||||||
|
show_search=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
table.add_slot(
|
||||||
|
"body-cell-status",
|
||||||
|
r"""
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-chip
|
||||||
|
dense
|
||||||
|
square
|
||||||
|
size="sm"
|
||||||
|
:class="`ui-status ui-status--${props.value}`"
|
||||||
|
>
|
||||||
|
{{ props.value.toUpperCase() }}
|
||||||
|
</q-chip>
|
||||||
|
</q-td>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
run_by_id = {str(run.id): run for run in runs}
|
||||||
|
|
||||||
|
def selected_run_id() -> str | None:
|
||||||
|
selected = table.selected or []
|
||||||
|
if len(selected) != 1:
|
||||||
|
return None
|
||||||
|
return str(selected[0].get("id") or "")
|
||||||
|
|
||||||
|
async def view_log() -> None:
|
||||||
|
run_id = selected_run_id()
|
||||||
|
if not run_id:
|
||||||
|
ui.notify("Select one run first.", type="warning")
|
||||||
|
return
|
||||||
|
run = run_by_id.get(run_id)
|
||||||
|
if run is None or not run.log_path:
|
||||||
|
ui.notify("Log unavailable for this run.", type="warning")
|
||||||
|
return
|
||||||
|
log_path = run.log_path
|
||||||
|
log_outcome = await run_ui_action(
|
||||||
|
operation="settings.maintenance.log.read",
|
||||||
|
title="Maintenance log unavailable",
|
||||||
|
action=lambda: _read_maintenance_log(maintenance=maintenance, log_path=log_path),
|
||||||
|
)
|
||||||
|
if not log_outcome.ok or log_outcome.value is None:
|
||||||
|
return
|
||||||
|
with ui.dialog() as dialog, ui.card().classes("w-full max-w-4xl"):
|
||||||
|
ui.label(f"Log: {run.log_path}").classes("text-sm font-semibold")
|
||||||
|
ui.code(log_outcome.value.decode("utf-8", errors="replace"), language="text").classes(
|
||||||
|
"w-full text-xs max-h-[65vh] overflow-auto"
|
||||||
|
)
|
||||||
|
with ui.row().classes("w-full justify-end"):
|
||||||
|
ui.button("Close", on_click=dialog.close).props("flat")
|
||||||
|
dialog.open()
|
||||||
|
|
||||||
|
async def download_log() -> None:
|
||||||
|
run_id = selected_run_id()
|
||||||
|
if not run_id:
|
||||||
|
ui.notify("Select one run first.", type="warning")
|
||||||
|
return
|
||||||
|
run = run_by_id.get(run_id)
|
||||||
|
if run is None or not run.log_path:
|
||||||
|
ui.notify("Log unavailable for this run.", type="warning")
|
||||||
|
return
|
||||||
|
log_path = run.log_path
|
||||||
|
log_outcome = await run_ui_action(
|
||||||
|
operation="settings.maintenance.log.download",
|
||||||
|
title="Maintenance log unavailable",
|
||||||
|
action=lambda: _read_maintenance_log(maintenance=maintenance, log_path=log_path),
|
||||||
|
)
|
||||||
|
if not log_outcome.ok or log_outcome.value is None:
|
||||||
|
return
|
||||||
|
ui.download(log_outcome.value, filename=f"{run.id}.log", media_type="text/plain")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full justify-end items-center gap-2 mt-2"):
|
||||||
|
ui.button("View Log", icon="visibility", on_click=view_log).props("flat")
|
||||||
|
ui.button("Download Log", icon="download", on_click=download_log).props("flat")
|
||||||
|
|
||||||
|
_schedule_maintenance_refresh_if_active(runs=runs, refresh=render_maintenance.refresh)
|
||||||
|
|
||||||
|
@ui.refreshable
|
||||||
|
async def render_runtime_settings() -> None:
|
||||||
|
with archival_card("Runtime Settings"):
|
||||||
|
ui.label(
|
||||||
|
"Edit non-secret .env settings. Secret fields are intentionally excluded. "
|
||||||
|
"Changes are persisted to .env and apply after restart."
|
||||||
|
).classes("text-xs ui-text-muted mb-3")
|
||||||
|
snapshot_outcome = await run_ui_action(
|
||||||
|
operation="settings.runtime.read",
|
||||||
|
title="Runtime settings unavailable",
|
||||||
|
action=lambda: _read_runtime_settings_snapshot(settings),
|
||||||
|
)
|
||||||
|
if not snapshot_outcome.ok or snapshot_outcome.value is None:
|
||||||
|
return
|
||||||
|
snapshot = snapshot_outcome.value
|
||||||
|
field_controls: dict[str, Any] = {}
|
||||||
|
with ui.column().classes("w-full max-w-3xl mx-auto gap-0.5"):
|
||||||
|
with ui.row().classes("w-full items-center px-2 py-1 border-b ui-border-subtle"):
|
||||||
|
ui.label("Setting").classes("w-56 text-xs font-semibold ui-text-muted")
|
||||||
|
ui.label("Value").classes("text-xs font-semibold ui-text-muted")
|
||||||
|
|
||||||
|
for field in snapshot.fields:
|
||||||
|
with ui.row().classes("w-full items-start gap-3 px-2 py-1 border-b ui-border-subtle"):
|
||||||
|
with ui.column().classes("w-56 gap-0"):
|
||||||
|
ui.label(field.label).classes("text-xs font-semibold")
|
||||||
|
ui.label(f"{field.env_key} · {field.description}").classes(
|
||||||
|
"text-[11px] ui-text-muted"
|
||||||
|
)
|
||||||
|
with ui.column().classes("flex-1 min-w-0"):
|
||||||
|
if field.control == "bool":
|
||||||
|
control = ui.checkbox("", value=bool(field.value)).props("dense")
|
||||||
|
elif field.control == "select":
|
||||||
|
control = (
|
||||||
|
ui.select(
|
||||||
|
list(field.options),
|
||||||
|
label="",
|
||||||
|
value=str(field.value),
|
||||||
|
)
|
||||||
|
.props("outlined dense")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
control = (
|
||||||
|
ui.input("", value=str(field.value))
|
||||||
|
.props("outlined dense")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
field_controls[field.field_name] = control
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-3xl mx-auto gap-2 mt-3"):
|
||||||
|
ui.label("Other settings not shown here").classes("text-sm font-semibold")
|
||||||
|
ui.label(f"Edit these directly in {snapshot.env_file_path}:").classes("text-xs ui-text-muted")
|
||||||
|
table_rows = [
|
||||||
|
f"| {category.title} | {', '.join(f'`{key}`' for key in category.env_keys)} |"
|
||||||
|
for category in HIDDEN_SETTINGS_CATEGORIES
|
||||||
|
]
|
||||||
|
ui.markdown(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"| **Category** | **Settings** |",
|
||||||
|
"|---|---|",
|
||||||
|
*table_rows,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).classes("w-full text-xs")
|
||||||
|
|
||||||
|
async def save_runtime() -> None:
|
||||||
|
updates: dict[str, str | bool] = {}
|
||||||
|
for field in snapshot.fields:
|
||||||
|
control = field_controls[field.field_name]
|
||||||
|
if field.control == "bool":
|
||||||
|
updates[field.field_name] = bool(control.value)
|
||||||
|
else:
|
||||||
|
updates[field.field_name] = str(control.value or "")
|
||||||
|
save_outcome = await run_ui_action(
|
||||||
|
operation="settings.runtime.write",
|
||||||
|
title="Runtime settings save failed",
|
||||||
|
action=lambda: _write_runtime_settings(settings=settings, updates=updates),
|
||||||
|
)
|
||||||
|
if not save_outcome.ok or save_outcome.value is None:
|
||||||
|
return
|
||||||
|
target_path = save_outcome.value.env_file_path
|
||||||
|
ui.notify(
|
||||||
|
f"Runtime settings saved to {target_path}. Restart app/worker to apply.",
|
||||||
|
type="positive",
|
||||||
|
)
|
||||||
|
render_runtime_settings.refresh()
|
||||||
|
|
||||||
|
with ui.row().classes("items-center gap-2 mt-3"):
|
||||||
|
ui.button("Save runtime settings", icon="save", on_click=save_runtime).classes("ui-btn-primary")
|
||||||
|
with ui.column().classes("w-full max-w-3xl mx-auto gap-1 mt-2"):
|
||||||
|
ui.label("Apply saved runtime settings").classes("text-xs font-semibold")
|
||||||
|
ui.label(
|
||||||
|
"Run this on the host from the deployment repo to recreate app/worker with new env values:"
|
||||||
|
).classes("text-[11px] ui-text-muted")
|
||||||
|
ui.code(_runtime_restart_command(), language="bash").classes("w-full text-xs")
|
||||||
|
|
||||||
with ui.tabs().classes("w-full") as tabs:
|
with ui.tabs().classes("w-full") as tabs:
|
||||||
document_types_tab = ui.tab("Document Types")
|
document_types_tab = ui.tab("Document Types")
|
||||||
person_roles_tab = ui.tab("Person Roles")
|
person_roles_tab = ui.tab("Person Roles")
|
||||||
tags_tab = ui.tab("Tags")
|
tags_tab = ui.tab("Tags")
|
||||||
prompts_tab = ui.tab("Prompts")
|
prompts_tab = ui.tab("Prompts")
|
||||||
home_page_text_tab = ui.tab("Home Page Text")
|
home_page_text_tab = ui.tab("Home Page Text")
|
||||||
|
maintenance_tab = ui.tab("Maintenance")
|
||||||
|
runtime_settings_tab = ui.tab("Runtime Settings")
|
||||||
|
|
||||||
with ui.tab_panels(tabs, value=document_types_tab).classes("w-full"):
|
with ui.tab_panels(tabs, value=document_types_tab).classes("w-full"):
|
||||||
with ui.tab_panel(document_types_tab):
|
with ui.tab_panel(document_types_tab):
|
||||||
@@ -515,6 +779,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
|
|||||||
await render_prompts()
|
await render_prompts()
|
||||||
with ui.tab_panel(home_page_text_tab):
|
with ui.tab_panel(home_page_text_tab):
|
||||||
await render_home_page_text()
|
await render_home_page_text()
|
||||||
|
with ui.tab_panel(maintenance_tab):
|
||||||
|
await render_maintenance()
|
||||||
|
with ui.tab_panel(runtime_settings_tab):
|
||||||
|
await render_runtime_settings()
|
||||||
|
|
||||||
|
|
||||||
def _selected_table_row(table: Any) -> dict[str, Any] | None:
|
def _selected_table_row(table: Any) -> dict[str, Any] | None:
|
||||||
@@ -546,3 +814,130 @@ async def _read_home_page_text(settings: Settings) -> str:
|
|||||||
|
|
||||||
async def _write_home_page_text(settings: Settings, markdown_text: str) -> None:
|
async def _write_home_page_text(settings: Settings, markdown_text: str) -> None:
|
||||||
await run_blocking(save_homepage_markdown, markdown_text, settings=settings)
|
await run_blocking(save_homepage_markdown, markdown_text, settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_runtime_settings_snapshot(settings: Settings):
|
||||||
|
return await run_blocking(read_runtime_settings_snapshot, settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_runtime_settings(*, settings: Settings, updates: dict[str, str | bool]):
|
||||||
|
return await run_blocking(save_runtime_settings, settings=settings, updates=updates)
|
||||||
|
|
||||||
|
|
||||||
|
async def _enqueue_maintenance_run(
|
||||||
|
*,
|
||||||
|
maintenance: MaintenanceService,
|
||||||
|
job_type: MaintenanceJobType,
|
||||||
|
request: Request,
|
||||||
|
refresh,
|
||||||
|
) -> None:
|
||||||
|
created_outcome = await run_ui_action(
|
||||||
|
operation="settings.maintenance.enqueue",
|
||||||
|
title="Maintenance run failed",
|
||||||
|
action=lambda: maintenance.enqueue_run(job_type=job_type),
|
||||||
|
)
|
||||||
|
if not created_outcome.ok or created_outcome.value is None:
|
||||||
|
return
|
||||||
|
resolve_worker_notifier(request.app.state).notify()
|
||||||
|
ui.notify(f"Queued {job_type.value.replace('_', ' ')} run", type="positive")
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
|
||||||
|
async def _render_gedcom_import_controls(
|
||||||
|
*,
|
||||||
|
maintenance: MaintenanceService,
|
||||||
|
request: Request,
|
||||||
|
refresh,
|
||||||
|
) -> None:
|
||||||
|
ui.label("GEDCOM Import").classes("text-sm font-semibold")
|
||||||
|
ui.label("Upload a FamilySearch GEDCOM export (.ged), then queue a GEDCOM import run.").classes(
|
||||||
|
"text-xs ui-text-muted mb-2"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_gedcom_upload(event: events.UploadEventArguments) -> None:
|
||||||
|
payload = await event.file.read()
|
||||||
|
upload_outcome = await run_ui_action(
|
||||||
|
operation="settings.maintenance.gedcom.upload",
|
||||||
|
title="GEDCOM upload failed",
|
||||||
|
action=lambda: maintenance.store_gedcom_upload(
|
||||||
|
filename=event.file.name,
|
||||||
|
file_bytes=payload,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not upload_outcome.ok or upload_outcome.value is None:
|
||||||
|
return
|
||||||
|
ui.notify(f"Uploaded GEDCOM file: {event.file.name}", type="positive")
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
render_upload_picker(
|
||||||
|
on_upload=on_gedcom_upload,
|
||||||
|
label="Upload GEDCOM file",
|
||||||
|
extensions=GEDCOM_UPLOAD_EXTENSIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
latest_gedcom_outcome = await run_ui_action(
|
||||||
|
operation="settings.maintenance.gedcom.latest",
|
||||||
|
title="GEDCOM uploads unavailable",
|
||||||
|
action=lambda: _latest_gedcom_upload_path(maintenance=maintenance),
|
||||||
|
)
|
||||||
|
latest_gedcom_path = latest_gedcom_outcome.value if latest_gedcom_outcome.ok else None
|
||||||
|
ui.label(
|
||||||
|
f"Latest GEDCOM upload: {latest_gedcom_path}" if latest_gedcom_path else "Latest GEDCOM upload: none"
|
||||||
|
).classes("text-xs ui-text-muted mb-2")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Run GEDCOM Import",
|
||||||
|
icon="upload_file",
|
||||||
|
on_click=lambda: _enqueue_maintenance_run(
|
||||||
|
maintenance=maintenance,
|
||||||
|
job_type=MaintenanceJobType.GEDCOM_IMPORT,
|
||||||
|
request=request,
|
||||||
|
refresh=refresh,
|
||||||
|
),
|
||||||
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
|
|
||||||
|
async def _latest_gedcom_upload_path(*, maintenance: MaintenanceService) -> str | None:
|
||||||
|
return maintenance.latest_gedcom_upload_path()
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_maintenance_refresh_if_active(*, runs: list[Any], refresh) -> None:
|
||||||
|
active_statuses = frozenset(
|
||||||
|
{
|
||||||
|
MaintenanceRunStatus.QUEUED,
|
||||||
|
MaintenanceRunStatus.PROCESSING,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if any(run.status in active_statuses for run in runs):
|
||||||
|
ui.timer(4.0, refresh, once=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_timestamp(value: datetime | None) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "-"
|
||||||
|
parsed = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||||
|
return parsed.astimezone().strftime("%b %d, %I:%M %p")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(*, started_at: datetime | None, finished_at: datetime | None) -> str:
|
||||||
|
if started_at is None:
|
||||||
|
return "-"
|
||||||
|
if finished_at is None:
|
||||||
|
return "in progress"
|
||||||
|
elapsed = finished_at - started_at
|
||||||
|
seconds = int(elapsed.total_seconds())
|
||||||
|
if seconds < 1:
|
||||||
|
return "<1s"
|
||||||
|
minutes, remainder = divmod(seconds, 60)
|
||||||
|
if minutes:
|
||||||
|
return f"{minutes}m {remainder}s"
|
||||||
|
return f"{remainder}s"
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_maintenance_log(*, maintenance: MaintenanceService, log_path: str) -> bytes:
|
||||||
|
return await run_blocking(maintenance.read_log_bytes, log_path=log_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_restart_command() -> str:
|
||||||
|
return "docker compose -f docker-compose.production.yml up -d --force-recreate app worker"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from PIL import UnidentifiedImageError
|
from PIL import UnidentifiedImageError
|
||||||
@@ -32,6 +33,7 @@ from transcription.ui.components.confirm_delete import render_delete_blocked_not
|
|||||||
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.document_panzoom import render_document_panzoom
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
|
from transcription.ui.components.error_presenter import display_failure_detail
|
||||||
from transcription.ui.components.error_presenter import run_ui_action
|
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
|
||||||
@@ -62,12 +64,15 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
session_factory: SessionFactoryDep,
|
session_factory: SessionFactoryDep,
|
||||||
document_id: str | None = None,
|
document_id: str | None = None,
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
) -> None:
|
) -> RedirectResponse | None:
|
||||||
sources_service = SourceService(session_factory=session_factory)
|
sources_service = SourceService(session_factory=session_factory)
|
||||||
parsed_doc_id = parse_uuid(document_id)
|
parsed_doc_id = parse_uuid(document_id)
|
||||||
parsed_job_id = parse_uuid(job_id)
|
parsed_job_id = parse_uuid(job_id)
|
||||||
|
|
||||||
header_title = "Source Asset Records"
|
if parsed_doc_id is None and parsed_job_id is None:
|
||||||
|
return RedirectResponse(url="/ui/documents")
|
||||||
|
|
||||||
|
header_title = ""
|
||||||
if parsed_doc_id is not None:
|
if parsed_doc_id is not None:
|
||||||
header_title = "Sources for Document"
|
header_title = "Sources for Document"
|
||||||
elif parsed_job_id is not None:
|
elif parsed_job_id is not None:
|
||||||
@@ -101,10 +106,10 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
).props("flat")
|
).props("flat")
|
||||||
else:
|
else:
|
||||||
ui.button(
|
ui.button(
|
||||||
"Create Job",
|
"Back to Documents",
|
||||||
on_click=lambda: ui.navigate.to("/jobs/new"),
|
on_click=lambda: ui.navigate.to("/documents"),
|
||||||
icon="add",
|
icon="arrow_back",
|
||||||
).classes("ui-btn-primary")
|
).props("flat")
|
||||||
|
|
||||||
rows = [
|
rows = [
|
||||||
SourceTableRow(
|
SourceTableRow(
|
||||||
@@ -114,17 +119,12 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
document_id=source.document_id,
|
document_id=source.document_id,
|
||||||
document_name=source.document_name,
|
document_name=source.document_name,
|
||||||
job_source_status=source.latest_status.value if source.latest_status else "unprocessed",
|
job_source_status=source.latest_status.value if source.latest_status else "unprocessed",
|
||||||
job_source_error_detail=source.latest_error_detail,
|
job_source_error_detail=display_failure_detail(source.latest_error_detail),
|
||||||
)
|
)
|
||||||
for source in sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
for source in sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
]
|
]
|
||||||
render_sources_table(rows)
|
render_sources_table(rows)
|
||||||
|
|
||||||
if parsed_doc_id is None and parsed_job_id is None:
|
|
||||||
ui.label("Open a source row to inspect AI output and add human revisions.").classes(
|
|
||||||
"text-xs ui-text-muted"
|
|
||||||
)
|
|
||||||
|
|
||||||
@ui.page("/sources/{source_id}")
|
@ui.page("/sources/{source_id}")
|
||||||
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
sources_service = SourceService(session_factory=session_factory)
|
sources_service = SourceService(session_factory=session_factory)
|
||||||
@@ -164,8 +164,8 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
)
|
)
|
||||||
with ui.row().classes("items-center gap-2"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
ui.button(
|
ui.button(
|
||||||
"Back to Sources",
|
"Back to Document",
|
||||||
on_click=lambda: ui.navigate.to("/sources"),
|
on_click=lambda: ui.navigate.to(f"/documents/{source.document_id}?source_id={source.id}"),
|
||||||
icon="arrow_back",
|
icon="arrow_back",
|
||||||
).props("flat")
|
).props("flat")
|
||||||
ui.button(
|
ui.button(
|
||||||
@@ -267,14 +267,14 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
return
|
return
|
||||||
except TranscriptionNotFoundError:
|
except TranscriptionNotFoundError:
|
||||||
ui.notify("Source not found.", type="warning")
|
ui.notify("Source not found.", type="warning")
|
||||||
ui.navigate.to("/sources")
|
ui.navigate.to("/documents")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
show_error(exc, title="Delete failed", operation="sources.delete")
|
show_error(exc, title="Delete failed", operation="sources.delete")
|
||||||
return
|
return
|
||||||
|
|
||||||
ui.notify("Source deleted", type="positive")
|
ui.notify("Source deleted", type="positive")
|
||||||
ui.navigate.to("/sources")
|
ui.navigate.to("/documents")
|
||||||
|
|
||||||
render_delete_actions(
|
render_delete_actions(
|
||||||
confirm_label="Delete source permanently",
|
confirm_label="Delete source permanently",
|
||||||
@@ -479,10 +479,13 @@ def _render_source_job_metadata_zone(
|
|||||||
else "unknown",
|
else "unknown",
|
||||||
)
|
)
|
||||||
|
|
||||||
if latest_attempt is not None and latest_attempt.attempt.error_detail:
|
failure_detail = (
|
||||||
|
display_failure_detail(latest_attempt.attempt.error_detail) if latest_attempt is not None else None
|
||||||
|
)
|
||||||
|
if failure_detail:
|
||||||
with ui.column().classes("w-full mt-2"):
|
with ui.column().classes("w-full mt-2"):
|
||||||
ui.label("Failure Detail:").classes("ui-text-muted text-xs mb-1")
|
ui.label("Failure Detail:").classes("ui-text-muted text-xs mb-1")
|
||||||
ui.label(latest_attempt.attempt.error_detail).classes("p-2 ui-note-box text-xs")
|
ui.label(failure_detail).classes("p-2 ui-note-box text-xs")
|
||||||
|
|
||||||
_render_provider_evidence(latest_attempt=latest_attempt)
|
_render_provider_evidence(latest_attempt=latest_attempt)
|
||||||
|
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Tags browse and filter page registration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.services.documents import DocumentService
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
|
||||||
from transcription.ui.components.cards import archival_card
|
|
||||||
from transcription.ui.components.error_presenter import run_ui_action
|
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
|
||||||
from transcription.ui.components.primitives import section_header_row
|
|
||||||
from transcription.ui.theme import page_header
|
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
|
||||||
"""Register the tags browse/filter route."""
|
|
||||||
|
|
||||||
@ui.page("/tags")
|
|
||||||
async def tags_page(session_factory: SessionFactoryDep) -> None:
|
|
||||||
document_service = DocumentService(session_factory=session_factory)
|
|
||||||
render_navigation_header(current_path="/tags")
|
|
||||||
|
|
||||||
tags_outcome = await run_ui_action(
|
|
||||||
operation="tags.list",
|
|
||||||
title="Tags unavailable",
|
|
||||||
action=document_service.list_tag_summaries,
|
|
||||||
)
|
|
||||||
if not tags_outcome.ok:
|
|
||||||
return
|
|
||||||
tag_summaries = tags_outcome.value or ()
|
|
||||||
tag_labels = [item.label for item in tag_summaries]
|
|
||||||
|
|
||||||
documents_outcome = await run_ui_action(
|
|
||||||
operation="documents.list",
|
|
||||||
title="Documents unavailable",
|
|
||||||
action=document_service.list_documents,
|
|
||||||
)
|
|
||||||
if not documents_outcome.ok:
|
|
||||||
return
|
|
||||||
documents = documents_outcome.value or ()
|
|
||||||
|
|
||||||
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
|
||||||
with section_header_row():
|
|
||||||
page_header("Tags", subtitle="Browse documents by tag.")
|
|
||||||
|
|
||||||
if not tag_summaries:
|
|
||||||
with archival_card(extra_classes="p-8 text-center"):
|
|
||||||
render_empty_state("No tags are configured yet.")
|
|
||||||
return
|
|
||||||
|
|
||||||
selected_tag = (
|
|
||||||
ui.select(tag_labels, label="Filter by tag")
|
|
||||||
.props("outlined clearable use-input")
|
|
||||||
.classes("w-full md:w-96 ui-form-surface")
|
|
||||||
)
|
|
||||||
|
|
||||||
@ui.refreshable
|
|
||||||
def render_groups() -> None:
|
|
||||||
selected = str(selected_tag.value or "").strip()
|
|
||||||
with ui.column().classes("w-full gap-3"):
|
|
||||||
rendered_any = False
|
|
||||||
for summary in tag_summaries:
|
|
||||||
if selected and summary.label != selected:
|
|
||||||
continue
|
|
||||||
tagged_documents = [
|
|
||||||
document
|
|
||||||
for document in documents
|
|
||||||
if any(
|
|
||||||
link.tag_ref is not None and link.tag_ref.id == summary.id
|
|
||||||
for link in document.document_tags
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if not tagged_documents:
|
|
||||||
continue
|
|
||||||
rendered_any = True
|
|
||||||
with archival_card(title=f"{summary.label} ({len(tagged_documents)})"):
|
|
||||||
for document in sorted(tagged_documents, key=lambda item: item.name.casefold()):
|
|
||||||
ui.button(
|
|
||||||
document.name,
|
|
||||||
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(f"/documents/{doc_id}"),
|
|
||||||
icon="description",
|
|
||||||
).props("flat dense no-caps").classes("self-start ui-link-primary text-xs")
|
|
||||||
|
|
||||||
if not rendered_any:
|
|
||||||
with archival_card(extra_classes="p-6"):
|
|
||||||
render_empty_state("No documents match this tag filter.", italic=True)
|
|
||||||
|
|
||||||
selected_tag.on_value_change(lambda _event: render_groups.refresh())
|
|
||||||
render_groups()
|
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
"""Safe runtime settings catalog and .env persistence helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from errno import EBUSY
|
||||||
|
from errno import EPERM
|
||||||
|
from errno import EXDEV
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from transcription.config import Provider
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import resolve_settings_env_file_path
|
||||||
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.errors import exception_detail
|
||||||
|
|
||||||
|
FieldControl = Literal["text", "bool", "select"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeSettingField:
|
||||||
|
"""UI metadata and value for a safe, editable runtime setting."""
|
||||||
|
|
||||||
|
field_name: str
|
||||||
|
env_key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
control: FieldControl
|
||||||
|
value: str | bool
|
||||||
|
options: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeSettingsSnapshot:
|
||||||
|
"""Typed snapshot of runtime settings editable in UI."""
|
||||||
|
|
||||||
|
env_file_path: Path
|
||||||
|
fields: tuple[RuntimeSettingField, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RuntimeSettingDescriptor:
|
||||||
|
field_name: str
|
||||||
|
env_key: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
control: FieldControl
|
||||||
|
options: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HiddenSettingsCategory:
|
||||||
|
"""Settings excluded from the UI editor and why they are excluded."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
reason: str
|
||||||
|
env_keys: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS_UI_EXCLUDED_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"openrouter_api_key",
|
||||||
|
"database",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
HIDDEN_SETTINGS_CATEGORIES: tuple[HiddenSettingsCategory, ...] = (
|
||||||
|
HiddenSettingsCategory(
|
||||||
|
title="Not safe to expose/edit as plain text in UI (secrets)",
|
||||||
|
reason="These values are credentials and must remain hidden from page rendering and client responses.",
|
||||||
|
env_keys=("OPENROUTER_API_KEY", "DATABASE__PASSWORD"),
|
||||||
|
),
|
||||||
|
HiddenSettingsCategory(
|
||||||
|
title="Non-secret but high-risk (can break runtime/connectivity; still editable with guardrails)",
|
||||||
|
reason="These values control database connectivity and can make the app unavailable if changed incorrectly.",
|
||||||
|
env_keys=(
|
||||||
|
"DATABASE__DRIVER",
|
||||||
|
"DATABASE__PATH",
|
||||||
|
"DATABASE__HOST",
|
||||||
|
"DATABASE__PORT",
|
||||||
|
"DATABASE__DATABASE",
|
||||||
|
"DATABASE__USER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
HiddenSettingsCategory(
|
||||||
|
title="Deployment and backup/tunnel helpers (not Runtime Settings model fields)",
|
||||||
|
reason=(
|
||||||
|
"These keys are consumed by docker-compose, backup scripts, or deployment adapters and are "
|
||||||
|
"intentionally edited outside the Runtime Settings UI."
|
||||||
|
),
|
||||||
|
env_keys=(
|
||||||
|
"CLOUDFLARE_TUNNEL_TOKEN",
|
||||||
|
"RUNTIME_SETTINGS_ENV_FILE",
|
||||||
|
"BACKUP_DIR",
|
||||||
|
"BACKUP_RETENTION_DAYS",
|
||||||
|
"ENV_FILE",
|
||||||
|
"COMPOSE_FILE",
|
||||||
|
"POSTGRES_DB",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"POSTGRES_HOST",
|
||||||
|
"POSTGRES_PORT",
|
||||||
|
"DATABASE_BACKUP_DIR",
|
||||||
|
"APP_DATA_BACKUP_DIR",
|
||||||
|
"UPLOADS_BACKUP_DIR",
|
||||||
|
"SYNOLOGY_BACKUP_DIR",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = (
|
||||||
|
RuntimeSettingDescriptor("host", "HOST", "Host", "Server bind host.", "text"),
|
||||||
|
RuntimeSettingDescriptor("port", "PORT", "Port", "Server bind port.", "text"),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_level",
|
||||||
|
"LOG_LEVEL",
|
||||||
|
"Log level",
|
||||||
|
"Application log verbosity.",
|
||||||
|
"select",
|
||||||
|
options=("critical", "error", "warning", "info", "debug", "trace"),
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor("reload", "RELOAD", "Reload", "Auto-reload on code changes.", "bool"),
|
||||||
|
RuntimeSettingDescriptor("log_dir", "LOG_DIR", "Log directory", "Directory for rotating log files.", "text"),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_file_name",
|
||||||
|
"LOG_FILE_NAME",
|
||||||
|
"Log file name",
|
||||||
|
"Active rotating log filename.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_file_max_bytes",
|
||||||
|
"LOG_FILE_MAX_BYTES",
|
||||||
|
"Log file max bytes",
|
||||||
|
"Maximum bytes per log file before rotation.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"log_file_backup_count",
|
||||||
|
"LOG_FILE_BACKUP_COUNT",
|
||||||
|
"Log file backup count",
|
||||||
|
"Number of rotated log files to keep.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"provider",
|
||||||
|
"PROVIDER",
|
||||||
|
"Provider",
|
||||||
|
"Transcription provider key.",
|
||||||
|
"select",
|
||||||
|
options=tuple(member.value for member in Provider),
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"provider_model",
|
||||||
|
"PROVIDER_MODEL",
|
||||||
|
"Provider model",
|
||||||
|
"Default model id used for transcription jobs.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"provider_models",
|
||||||
|
"PROVIDER_MODELS",
|
||||||
|
"Provider models (JSON array)",
|
||||||
|
"Allowed model ids as a JSON array.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"openrouter_http_referer",
|
||||||
|
"OPENROUTER_HTTP_REFERER",
|
||||||
|
"OpenRouter HTTP referer",
|
||||||
|
"Optional header value sent to OpenRouter.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"openrouter_app_title",
|
||||||
|
"OPENROUTER_APP_TITLE",
|
||||||
|
"OpenRouter app title",
|
||||||
|
"Optional app title sent to OpenRouter.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"default_prompt_name",
|
||||||
|
"DEFAULT_PROMPT_NAME",
|
||||||
|
"Default prompt file",
|
||||||
|
"Prompt filename used for new transcription requests.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"transcription_temperature",
|
||||||
|
"TRANSCRIPTION_TEMPERATURE",
|
||||||
|
"Temperature",
|
||||||
|
"Optional sampling temperature (0.0 to 2.0).",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"transcription_top_p",
|
||||||
|
"TRANSCRIPTION_TOP_P",
|
||||||
|
"Top P",
|
||||||
|
"Optional nucleus sampling probability (0.0 to 1.0).",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"environment",
|
||||||
|
"ENVIRONMENT",
|
||||||
|
"Environment",
|
||||||
|
"Runtime environment mode.",
|
||||||
|
"select",
|
||||||
|
options=("development", "test", "production"),
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"transcription_commit",
|
||||||
|
"TRANSCRIPTION_COMMIT",
|
||||||
|
"Transcription commit",
|
||||||
|
"Optional build or commit identifier for evidence provenance.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"run_embedded_worker",
|
||||||
|
"RUN_EMBEDDED_WORKER",
|
||||||
|
"Run embedded worker",
|
||||||
|
"Run the in-process worker loop in the web app process.",
|
||||||
|
"bool",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"bootstrap_schema_on_startup",
|
||||||
|
"BOOTSTRAP_SCHEMA_ON_STARTUP",
|
||||||
|
"Bootstrap schema on startup",
|
||||||
|
"Create schema on startup when enabled.",
|
||||||
|
"bool",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"sqlite_check_same_thread",
|
||||||
|
"SQLITE_CHECK_SAME_THREAD",
|
||||||
|
"SQLite check same thread",
|
||||||
|
"SQLite engine same-thread flag.",
|
||||||
|
"bool",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"upload_dir",
|
||||||
|
"UPLOAD_DIR",
|
||||||
|
"Upload directory",
|
||||||
|
"Root directory for uploaded media and generated files.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"prompt_dir",
|
||||||
|
"PROMPT_DIR",
|
||||||
|
"Prompt directory",
|
||||||
|
"Directory containing editable markdown prompts.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_max_retries",
|
||||||
|
"WORKER_MAX_RETRIES",
|
||||||
|
"Worker max retries",
|
||||||
|
"Maximum retries per source attempt.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_provider_timeout_seconds",
|
||||||
|
"WORKER_PROVIDER_TIMEOUT_SECONDS",
|
||||||
|
"Worker provider timeout seconds",
|
||||||
|
"Provider call timeout budget in seconds.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_stale_job_seconds",
|
||||||
|
"WORKER_STALE_JOB_SECONDS",
|
||||||
|
"Worker stale job seconds",
|
||||||
|
"Seconds before queued jobs are considered stale.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_retry_backoff_seconds",
|
||||||
|
"WORKER_RETRY_BACKOFF_SECONDS",
|
||||||
|
"Worker retry backoff seconds",
|
||||||
|
"Base backoff delay between retries.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_shutdown_grace_seconds",
|
||||||
|
"WORKER_SHUTDOWN_GRACE_SECONDS",
|
||||||
|
"Worker shutdown grace seconds",
|
||||||
|
"Grace period before forced worker shutdown.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_poll_interval_seconds",
|
||||||
|
"WORKER_POLL_INTERVAL_SECONDS",
|
||||||
|
"Worker poll interval seconds",
|
||||||
|
"Worker polling interval for queued jobs.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_min_transcription_chars",
|
||||||
|
"WORKER_MIN_TRANSCRIPTION_CHARS",
|
||||||
|
"Worker min transcription chars",
|
||||||
|
"Minimum character threshold for successful transcription.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_min_transcription_lines",
|
||||||
|
"WORKER_MIN_TRANSCRIPTION_LINES",
|
||||||
|
"Worker min transcription lines",
|
||||||
|
"Minimum line threshold for successful transcription.",
|
||||||
|
"text",
|
||||||
|
),
|
||||||
|
RuntimeSettingDescriptor(
|
||||||
|
"worker_fail_on_finish_reason_length",
|
||||||
|
"WORKER_FAIL_ON_FINISH_REASON_LENGTH",
|
||||||
|
"Fail on finish reason length",
|
||||||
|
"Treat provider finish_reason=length as failure when enabled.",
|
||||||
|
"bool",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def settings_catalog_field_names() -> frozenset[str]:
|
||||||
|
return frozenset(item.field_name for item in RUNTIME_SETTINGS_CATALOG)
|
||||||
|
|
||||||
|
|
||||||
|
def read_runtime_settings_snapshot(*, settings: Settings, env_file_path: Path | None = None) -> RuntimeSettingsSnapshot:
|
||||||
|
resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path)
|
||||||
|
fields: list[RuntimeSettingField] = []
|
||||||
|
for descriptor in RUNTIME_SETTINGS_CATALOG:
|
||||||
|
value = getattr(settings, descriptor.field_name)
|
||||||
|
fields.append(
|
||||||
|
RuntimeSettingField(
|
||||||
|
field_name=descriptor.field_name,
|
||||||
|
env_key=descriptor.env_key,
|
||||||
|
label=descriptor.label,
|
||||||
|
description=descriptor.description,
|
||||||
|
control=descriptor.control,
|
||||||
|
value=_display_value(value),
|
||||||
|
options=descriptor.options,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return RuntimeSettingsSnapshot(
|
||||||
|
env_file_path=resolved_env_path,
|
||||||
|
fields=tuple(fields),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_runtime_settings(
|
||||||
|
*,
|
||||||
|
settings: Settings,
|
||||||
|
updates: dict[str, str | bool],
|
||||||
|
env_file_path: Path | None = None,
|
||||||
|
) -> RuntimeSettingsSnapshot:
|
||||||
|
descriptors = {item.field_name: item for item in RUNTIME_SETTINGS_CATALOG}
|
||||||
|
unknown_keys = sorted(set(updates) - set(descriptors))
|
||||||
|
if unknown_keys:
|
||||||
|
raise AppError(
|
||||||
|
"One or more settings fields cannot be edited from this page.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Refresh the page and submit only editable fields.",
|
||||||
|
detail=f"Unknown settings keys: {unknown_keys}",
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path)
|
||||||
|
original_lines = _read_env_lines(resolved_env_path)
|
||||||
|
env_map = _parse_env_map(original_lines)
|
||||||
|
mutable_lines = list(original_lines)
|
||||||
|
|
||||||
|
for field_name, raw_value in updates.items():
|
||||||
|
descriptor = descriptors[field_name]
|
||||||
|
encoded = _encode_field_value(raw_value)
|
||||||
|
if encoded == "":
|
||||||
|
_delete_env_key(mutable_lines, descriptor.env_key)
|
||||||
|
env_map.pop(descriptor.env_key, None)
|
||||||
|
continue
|
||||||
|
_upsert_env_key(mutable_lines, descriptor.env_key, encoded)
|
||||||
|
env_map[descriptor.env_key] = encoded
|
||||||
|
|
||||||
|
_validate_candidate_env(env_map=env_map, env_file_path=resolved_env_path)
|
||||||
|
try:
|
||||||
|
_write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines)
|
||||||
|
except OSError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"Runtime settings file is not writable.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify file path and write permissions, then retry.",
|
||||||
|
detail=f"Failed writing runtime env file {resolved_env_path}: {exception_detail(exc)}",
|
||||||
|
) from exc
|
||||||
|
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
|
||||||
|
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> Path:
|
||||||
|
_ = settings
|
||||||
|
if env_file_path is not None:
|
||||||
|
return env_file_path
|
||||||
|
|
||||||
|
override = os.getenv("RUNTIME_SETTINGS_ENV_FILE", "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override)
|
||||||
|
|
||||||
|
return resolve_settings_env_file_path()
|
||||||
|
|
||||||
|
|
||||||
|
def _display_value(value: object) -> str | bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
values = [str(item) for item in value]
|
||||||
|
escaped = ",".join(f'"{_escape_json_string(item)}"' for item in values)
|
||||||
|
return f"[{escaped}]"
|
||||||
|
if isinstance(value, Path):
|
||||||
|
return value.as_posix()
|
||||||
|
if isinstance(value, Provider):
|
||||||
|
return value.value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_field_value(raw_value: str | bool) -> str:
|
||||||
|
if isinstance(raw_value, bool):
|
||||||
|
return "true" if raw_value else "false"
|
||||||
|
text = str(raw_value).strip()
|
||||||
|
if text == "":
|
||||||
|
return ""
|
||||||
|
if text.startswith("[") and text.endswith("]"):
|
||||||
|
return text
|
||||||
|
if " " in text or "#" in text:
|
||||||
|
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
return f'"{escaped}"'
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_candidate_env(*, env_map: dict[str, str], env_file_path: Path) -> None:
|
||||||
|
serialized = "\n".join(f"{key}={value}" for key, value in env_map.items()) + "\n"
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", newline="\n", suffix=".env") as handle:
|
||||||
|
temp_path = Path(handle.name)
|
||||||
|
handle.write(serialized)
|
||||||
|
try:
|
||||||
|
Settings(_env_file=temp_path, _cli_parse_args=False)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"One or more settings values are invalid.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Review the highlighted values and use the same formats shown in .env.production.example.",
|
||||||
|
detail=f"Invalid runtime settings for {env_file_path}: {exc}",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_env_lines(path: Path) -> list[str]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8").splitlines()
|
||||||
|
except OSError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"Runtime settings file is unreadable.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify file path and read permissions, then retry.",
|
||||||
|
detail=f"Failed reading runtime env file {path}: {exception_detail(exc)}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_map(lines: list[str]) -> dict[str, str]:
|
||||||
|
env_map: dict[str, str] = {}
|
||||||
|
for line in lines:
|
||||||
|
parsed = _parse_env_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
key, value = parsed
|
||||||
|
env_map[key] = value
|
||||||
|
return env_map
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_line(line: str) -> tuple[str, str] | None:
|
||||||
|
if not line or line.lstrip().startswith("#"):
|
||||||
|
return None
|
||||||
|
match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)\s*=\s*(.*)$", line)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return match.group(1), match.group(2)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_env_key(lines: list[str], key: str) -> None:
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
parsed = _parse_env_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
current_key, _ = parsed
|
||||||
|
if current_key == key:
|
||||||
|
del lines[index]
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_env_key(lines: list[str], key: str, value: str) -> None:
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
parsed = _parse_env_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
current_key, _ = parsed
|
||||||
|
if current_key == key:
|
||||||
|
lines[index] = f"{key}={value}"
|
||||||
|
return
|
||||||
|
lines.append(f"{key}={value}")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None:
|
||||||
|
temp_path: Path | None = None
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = "\n".join(lines).rstrip("\n")
|
||||||
|
if content:
|
||||||
|
content += "\n"
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
delete=False,
|
||||||
|
dir=path.parent,
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
suffix=".env.tmp",
|
||||||
|
) as handle:
|
||||||
|
temp_path = Path(handle.name)
|
||||||
|
handle.write(content)
|
||||||
|
try:
|
||||||
|
temp_path.replace(path)
|
||||||
|
except OSError as exc:
|
||||||
|
# Single-file bind mounts can reject replace() (cross-device or busy mountpoint).
|
||||||
|
# Fallback to direct write so Runtime Settings can persist to mounted env files.
|
||||||
|
if exc.errno not in {EXDEV, EBUSY, EPERM}:
|
||||||
|
raise
|
||||||
|
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(content)
|
||||||
|
except OSError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"Runtime settings file is not writable.",
|
||||||
|
category=ErrorCategory.INFRA_PERSISTENT,
|
||||||
|
suggestion="Verify file path and write permissions, then retry.",
|
||||||
|
detail=f"Failed writing runtime env file {path}: {exception_detail(exc)}",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
if temp_path is not None:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_json_string(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
@@ -299,7 +299,8 @@ input:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-status--partial_success,
|
.ui-status--partial_success,
|
||||||
.ui-status--transcribed {
|
.ui-status--transcribed,
|
||||||
|
.ui-status--succeeded {
|
||||||
color: var(--theme-text);
|
color: var(--theme-text);
|
||||||
background: var(--theme-secondary);
|
background: var(--theme-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,17 +229,20 @@ async def run_worker_loop(
|
|||||||
|
|
||||||
processed_any = False
|
processed_any = False
|
||||||
while True:
|
while True:
|
||||||
|
processed_job = False
|
||||||
with handle_worker_exceptions(operation="worker.process_next_queued_job"):
|
with handle_worker_exceptions(operation="worker.process_next_queued_job"):
|
||||||
processed = await process_next_queued_job(
|
processed_job = await process_next_queued_job(
|
||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
services=services,
|
services=services,
|
||||||
)
|
)
|
||||||
if not processed:
|
processed_maintenance = False
|
||||||
break
|
if session_factory is not None:
|
||||||
processed_any = True
|
with handle_worker_exceptions(operation="worker.process_next_queued_maintenance"):
|
||||||
continue
|
processed_maintenance = await services.maintenance.process_next_queued_run()
|
||||||
|
|
||||||
break
|
if not processed_job and not processed_maintenance:
|
||||||
|
break
|
||||||
|
processed_any = True
|
||||||
|
|
||||||
if wake_event is None and not processed_any:
|
if wake_event is None and not processed_any:
|
||||||
await asyncio.sleep(poll_interval_seconds)
|
await asyncio.sleep(poll_interval_seconds)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Standalone worker process entrypoint for production deployments."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .config import configure_logging
|
||||||
|
from .config import parse_cli_settings
|
||||||
|
from .db import create_all
|
||||||
|
from .db import dispose_database_runtime
|
||||||
|
from .db import initialize_database_runtime
|
||||||
|
from .worker import run_worker_loop
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run() -> None:
|
||||||
|
settings = parse_cli_settings()
|
||||||
|
configure_logging(settings)
|
||||||
|
runtime = initialize_database_runtime(settings=settings)
|
||||||
|
|
||||||
|
if settings.should_bootstrap_schema:
|
||||||
|
await create_all(engine=runtime.engine)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await run_worker_loop(
|
||||||
|
session_factory=runtime.session_factory,
|
||||||
|
poll_interval_seconds=settings.worker_poll_interval_seconds,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await dispose_database_runtime()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
asyncio.run(_run())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Worker service received shutdown signal")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,6 +4,7 @@ Every test gets a fresh in-memory SQLite database so tests are
|
|||||||
isolated, fast, and leave no artifacts on disk.
|
isolated, fast, and leave no artifacts on disk.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -24,6 +25,35 @@ from transcription.services.documents import DocumentService
|
|||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
|
def isolate_settings_from_local_env_files(tmp_path_factory):
|
||||||
|
"""Point `Settings` at a controlled stub env file instead of a developer one.
|
||||||
|
|
||||||
|
`Settings()` resolves its env file through the shared config seam, so a repository-root
|
||||||
|
pytest run would otherwise read a developer's real `.env.production` into tests that
|
||||||
|
assert declared defaults.
|
||||||
|
|
||||||
|
The stub mirrors what `.github/workflows/quality-gate.yml` writes in CI: only
|
||||||
|
`OPENROUTER_API_KEY`, which is required and which many tests need `get_settings()` to
|
||||||
|
find. Everything else falls back to declared defaults, so local and CI runs agree. This
|
||||||
|
stays a file rather than a process environment variable because
|
||||||
|
`test_config.py::test_requires_api_key` asserts the missing-key failure via
|
||||||
|
`_env_file=None`. Guarded by `tests/test_config_isolation.py`.
|
||||||
|
"""
|
||||||
|
stub = tmp_path_factory.mktemp("settings-env") / ".env.test"
|
||||||
|
stub.write_text("OPENROUTER_API_KEY=test-placeholder-not-a-real-key\n", encoding="utf-8")
|
||||||
|
|
||||||
|
original = os.environ.get("ENV_FILE")
|
||||||
|
os.environ["ENV_FILE"] = str(stub)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if original is None:
|
||||||
|
os.environ.pop("ENV_FILE", None)
|
||||||
|
else:
|
||||||
|
os.environ["ENV_FILE"] = original
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def session():
|
def session():
|
||||||
"""Provide a clean synchronous database session for sync tests."""
|
"""Provide a clean synchronous database session for sync tests."""
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
0 HEAD
|
||||||
|
1 SOUR getmyancestors
|
||||||
|
1 GEDC
|
||||||
|
2 VERS 5.5.1
|
||||||
|
1 CHAR UTF-8
|
||||||
|
0 @I1@ INDI
|
||||||
|
1 NAME John /Doe/
|
||||||
|
1 REFN KWC1-ABC
|
||||||
|
2 TYPE FSFTID
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 1 JAN 1900
|
||||||
|
2 PLAC Springfield, Illinois
|
||||||
|
2 SOUR Birth Register
|
||||||
|
3 PAGE p. 12
|
||||||
|
1 DEAT
|
||||||
|
2 DATE 5 FEB 1970
|
||||||
|
2 PLAC Shelbyville, Illinois
|
||||||
|
2 SOUR Death Certificate
|
||||||
|
3 TEXT County archive
|
||||||
|
0 @I2@ INDI
|
||||||
|
1 NAME Jane /Smith/
|
||||||
|
1 _FSFTID LMN2-XYZ
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 12 MAR 1905
|
||||||
|
2 PLAC Capital City, Illinois
|
||||||
|
0 @I3@ INDI
|
||||||
|
1 NAME Child /Doe/
|
||||||
|
1 REFN CHD3-123
|
||||||
|
2 TYPE FSFTID
|
||||||
|
0 @F1@ FAM
|
||||||
|
1 REFN FAM-001
|
||||||
|
2 TYPE FSFTID
|
||||||
|
1 HUSB @I1@
|
||||||
|
1 WIFE @I2@
|
||||||
|
1 CHIL @I3@
|
||||||
|
2 PEDI adopted
|
||||||
|
1 MARR
|
||||||
|
2 DATE 4 APR 1925
|
||||||
|
2 PLAC Springfield, Illinois
|
||||||
|
2 SOUR Marriage License
|
||||||
|
3 PAGE Book 9
|
||||||
|
0 TRLR
|
||||||
@@ -24,7 +24,7 @@ from transcription.services.workflows import advance_job
|
|||||||
|
|
||||||
|
|
||||||
async def _attempts_for_job(session, job) -> list[ExecutionAttempt]:
|
async def _attempts_for_job(session, job) -> list[ExecutionAttempt]:
|
||||||
"""Load execution attempts for a job; V4.7 moved evidence off JobSource."""
|
"""Load execution attempts for a job; evidence lives on ExecutionAttempt."""
|
||||||
job_source_ids = [job_source.id for job_source in job.job_sources]
|
job_source_ids = [job_source.id for job_source in job.job_sources]
|
||||||
result = await session.exec(select(ExecutionAttempt).where(col(ExecutionAttempt.job_source_id).in_(job_source_ids)))
|
result = await session.exec(select(ExecutionAttempt).where(col(ExecutionAttempt.job_source_id).in_(job_source_ids)))
|
||||||
return list(result.all())
|
return list(result.all())
|
||||||
@@ -99,6 +99,7 @@ class TestPipelineSuccessFlow:
|
|||||||
provider=None,
|
provider=None,
|
||||||
source_reference=None,
|
source_reference=None,
|
||||||
requested_model=None,
|
requested_model=None,
|
||||||
|
evidence_capture=None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
_ = (
|
_ = (
|
||||||
image_path,
|
image_path,
|
||||||
@@ -110,6 +111,7 @@ class TestPipelineSuccessFlow:
|
|||||||
provider,
|
provider,
|
||||||
source_reference,
|
source_reference,
|
||||||
requested_model,
|
requested_model,
|
||||||
|
evidence_capture,
|
||||||
)
|
)
|
||||||
return TranscriptionResult(
|
return TranscriptionResult(
|
||||||
text="Pipeline transcript",
|
text="Pipeline transcript",
|
||||||
@@ -191,9 +193,20 @@ class TestPipelineSuccessFlow:
|
|||||||
provider=None,
|
provider=None,
|
||||||
source_reference=None,
|
source_reference=None,
|
||||||
requested_model=None,
|
requested_model=None,
|
||||||
|
evidence_capture=None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
page_name = Path(image_path).name
|
page_name = Path(image_path).name
|
||||||
_ = (prompt_name, prompt_text, temperature, top_p, settings, provider, source_reference, requested_model)
|
_ = (
|
||||||
|
prompt_name,
|
||||||
|
prompt_text,
|
||||||
|
temperature,
|
||||||
|
top_p,
|
||||||
|
settings,
|
||||||
|
provider,
|
||||||
|
source_reference,
|
||||||
|
requested_model,
|
||||||
|
evidence_capture,
|
||||||
|
)
|
||||||
return TranscriptionResult(
|
return TranscriptionResult(
|
||||||
text=f"Transcript for {page_name}",
|
text=f"Transcript for {page_name}",
|
||||||
provider="openrouter",
|
provider="openrouter",
|
||||||
@@ -260,6 +273,7 @@ class TestPipelineSuccessFlow:
|
|||||||
provider=None,
|
provider=None,
|
||||||
source_reference=None,
|
source_reference=None,
|
||||||
requested_model=None,
|
requested_model=None,
|
||||||
|
evidence_capture=None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
@@ -273,6 +287,7 @@ class TestPipelineSuccessFlow:
|
|||||||
provider,
|
provider,
|
||||||
source_reference,
|
source_reference,
|
||||||
requested_model,
|
requested_model,
|
||||||
|
evidence_capture,
|
||||||
)
|
)
|
||||||
if call_count == 2:
|
if call_count == 2:
|
||||||
raise RuntimeError("simulated page failure")
|
raise RuntimeError("simulated page failure")
|
||||||
@@ -351,6 +366,7 @@ class TestPipelineSuccessFlow:
|
|||||||
provider=None,
|
provider=None,
|
||||||
source_reference=None,
|
source_reference=None,
|
||||||
requested_model=None,
|
requested_model=None,
|
||||||
|
evidence_capture=None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
_ = (
|
_ = (
|
||||||
@@ -363,6 +379,7 @@ class TestPipelineSuccessFlow:
|
|||||||
provider,
|
provider,
|
||||||
source_reference,
|
source_reference,
|
||||||
requested_model,
|
requested_model,
|
||||||
|
evidence_capture,
|
||||||
)
|
)
|
||||||
call_count += 1
|
call_count += 1
|
||||||
return TranscriptionResult(
|
return TranscriptionResult(
|
||||||
@@ -419,6 +436,7 @@ class TestPipelineFailureFlow:
|
|||||||
provider=None,
|
provider=None,
|
||||||
source_reference=None,
|
source_reference=None,
|
||||||
requested_model=None,
|
requested_model=None,
|
||||||
|
evidence_capture=None,
|
||||||
) -> TranscriptionResult:
|
) -> TranscriptionResult:
|
||||||
_ = (
|
_ = (
|
||||||
image_path,
|
image_path,
|
||||||
@@ -430,6 +448,7 @@ class TestPipelineFailureFlow:
|
|||||||
provider,
|
provider,
|
||||||
source_reference,
|
source_reference,
|
||||||
requested_model,
|
requested_model,
|
||||||
|
evidence_capture,
|
||||||
)
|
)
|
||||||
raise RuntimeError("pipeline provider failure")
|
raise RuntimeError("pipeline provider failure")
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
"""Tests for transcription.providers.openrouter."""
|
"""Tests for transcription.providers.openrouter."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from openrouter import OpenRouter
|
from openrouter import OpenRouter
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
|
from transcription.providers.base import ProviderCallEvidence
|
||||||
from transcription.providers.base import ProviderError
|
from transcription.providers.base import ProviderError
|
||||||
from transcription.providers.base import ProviderResponseError
|
from transcription.providers.base import ProviderResponseError
|
||||||
|
from transcription.providers.evidence import SourceEvidenceReference
|
||||||
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
|
from transcription.providers.openrouter import DEFAULT_OPENROUTER_MODEL
|
||||||
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
||||||
|
|
||||||
@@ -226,3 +232,94 @@ class TestOpenRouterProviderTranscribe:
|
|||||||
assert result.text == "Transcript text"
|
assert result.text == "Transcript text"
|
||||||
assert result.metadata_payload() is None
|
assert result.metadata_payload() is None
|
||||||
assert result.raw_api_response == response
|
assert result.raw_api_response == response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_logs_when_request_manifest_is_omitted_without_source_reference(self, caplog):
|
||||||
|
response = {"model": "vendor/model-a", "choices": [{"message": {"content": "Transcript text"}}]}
|
||||||
|
provider = OpenRouterTranscriptionProvider(
|
||||||
|
settings=Settings(openrouter_api_key="test-key"),
|
||||||
|
client=_fake_client(response=response),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
result = await provider.transcribe(
|
||||||
|
prompt_text="Prompt body",
|
||||||
|
image_bytes=b"img-bytes",
|
||||||
|
mime_type="image/png",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.request_manifest is None
|
||||||
|
assert "request manifest omitted" in caplog.text.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_calls_keep_evidence_scoped_to_their_own_capture(self):
|
||||||
|
first_release = asyncio.Event()
|
||||||
|
|
||||||
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
payload = json.loads(request.content.decode("utf-8"))
|
||||||
|
prompt = payload["messages"][0]["content"][0]["text"]
|
||||||
|
if prompt == "First prompt":
|
||||||
|
await first_release.wait()
|
||||||
|
body = b'{"error":{"message":"first failure"}}'
|
||||||
|
else:
|
||||||
|
body = b'{"error":{"message":"second failure"}}'
|
||||||
|
return httpx.Response(
|
||||||
|
500,
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = OpenRouterTranscriptionProvider(
|
||||||
|
settings=Settings(openrouter_api_key="test-key"),
|
||||||
|
async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)),
|
||||||
|
)
|
||||||
|
first_capture = ProviderCallEvidence()
|
||||||
|
second_capture = ProviderCallEvidence()
|
||||||
|
|
||||||
|
first_task = asyncio.create_task(
|
||||||
|
provider.transcribe(
|
||||||
|
prompt_text="First prompt",
|
||||||
|
image_bytes=b"one",
|
||||||
|
mime_type="image/png",
|
||||||
|
evidence_capture=first_capture,
|
||||||
|
source_reference=SourceEvidenceReference(
|
||||||
|
source_id=uuid4(),
|
||||||
|
digest_sha256="1" * 64,
|
||||||
|
byte_size=3,
|
||||||
|
media_type="image/png",
|
||||||
|
page_number=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
second_task = asyncio.create_task(
|
||||||
|
provider.transcribe(
|
||||||
|
prompt_text="Second prompt",
|
||||||
|
image_bytes=b"two",
|
||||||
|
mime_type="image/png",
|
||||||
|
evidence_capture=second_capture,
|
||||||
|
source_reference=SourceEvidenceReference(
|
||||||
|
source_id=uuid4(),
|
||||||
|
digest_sha256="2" * 64,
|
||||||
|
byte_size=3,
|
||||||
|
media_type="image/png",
|
||||||
|
page_number=2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ProviderError):
|
||||||
|
await second_task
|
||||||
|
first_release.set()
|
||||||
|
with pytest.raises(ProviderError):
|
||||||
|
await first_task
|
||||||
|
|
||||||
|
assert first_capture.request_manifest is not None
|
||||||
|
assert first_capture.request_manifest.prompt_content == "First prompt"
|
||||||
|
assert first_capture.transport_evidence is not None
|
||||||
|
assert first_capture.transport_evidence.body == b'{"error":{"message":"first failure"}}'
|
||||||
|
assert second_capture.request_manifest is not None
|
||||||
|
assert second_capture.request_manifest.prompt_content == "Second prompt"
|
||||||
|
assert second_capture.transport_evidence is not None
|
||||||
|
assert second_capture.transport_evidence.body == b'{"error":{"message":"second failure"}}'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""V4.5 retranscription candidate and promotion tests."""
|
"""Retranscription candidate and promotion tests."""
|
||||||
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ def _services(default_session_factory, settings: Settings) -> ServiceBundle:
|
|||||||
|
|
||||||
|
|
||||||
async def _seed_source(services: ServiceBundle) -> Source:
|
async def _seed_source(services: ServiceBundle) -> Source:
|
||||||
document = await services.documents.create_document(Document(name="V4.5 source"))
|
document = await services.documents.create_document(Document(name="candidate source"))
|
||||||
source = Source(
|
source = Source(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
page_number=1,
|
page_number=1,
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from transcription.db.models import GenealogyCitation
|
||||||
|
from transcription.db.models import GenealogyFamily
|
||||||
|
from transcription.db.models import GenealogyFamilyChild
|
||||||
|
from transcription.db.models import GenealogyPerson
|
||||||
|
from transcription.services.gedcom_import import import_gedcom_file
|
||||||
|
from transcription.services.gedcom_import import parse_gedcom
|
||||||
|
|
||||||
|
_FIXTURE_PATH = Path(__file__).resolve().parents[1] / "fixtures" / "gedcom" / "sample_familysearch.ged"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_gedcom_extracts_people_families_and_citations():
|
||||||
|
parsed = parse_gedcom(file_path=_FIXTURE_PATH)
|
||||||
|
|
||||||
|
assert len(parsed.people) == 3
|
||||||
|
john = next(person for person in parsed.people if person.fs_id == "KWC1-ABC")
|
||||||
|
assert john.full_name == "John Doe"
|
||||||
|
assert john.birth_date == date(1900, 1, 1)
|
||||||
|
assert john.birth_place == "Springfield, Illinois"
|
||||||
|
assert john.death_date == date(1970, 2, 5)
|
||||||
|
assert len(john.citations) == 2
|
||||||
|
assert "Birth Register" in john.citations[0].raw_citation_text
|
||||||
|
|
||||||
|
assert len(parsed.families) == 1
|
||||||
|
family = parsed.families[0]
|
||||||
|
assert family.fs_family_id == "FAM-001"
|
||||||
|
assert family.marriage_date == date(1925, 4, 4)
|
||||||
|
assert family.marriage_place == "Springfield, Illinois"
|
||||||
|
assert len(family.children) == 1
|
||||||
|
assert family.children[0].relationship_type == "adopted"
|
||||||
|
assert len(family.citations) == 1
|
||||||
|
assert "Marriage License" in family.citations[0].raw_citation_text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_gedcom_file_is_idempotent(default_session_factory):
|
||||||
|
async with default_session_factory() as session:
|
||||||
|
first = await import_gedcom_file(session=session, file_path=_FIXTURE_PATH)
|
||||||
|
async with default_session_factory() as session:
|
||||||
|
second = await import_gedcom_file(session=session, file_path=_FIXTURE_PATH)
|
||||||
|
async with default_session_factory() as session:
|
||||||
|
people = (await session.exec(select(GenealogyPerson))).all()
|
||||||
|
families = (await session.exec(select(GenealogyFamily))).all()
|
||||||
|
children = (await session.exec(select(GenealogyFamilyChild))).all()
|
||||||
|
citations = (await session.exec(select(GenealogyCitation))).all()
|
||||||
|
|
||||||
|
assert first.new_people == 3
|
||||||
|
assert first.new_families == 1
|
||||||
|
assert first.family_children == 1
|
||||||
|
assert first.citations == 3
|
||||||
|
|
||||||
|
assert second.new_people == 0
|
||||||
|
assert second.updated_people == 0
|
||||||
|
assert second.new_families == 0
|
||||||
|
assert second.updated_families == 0
|
||||||
|
assert second.family_children == 1
|
||||||
|
assert second.citations == 3
|
||||||
|
|
||||||
|
assert len(people) == 3
|
||||||
|
assert len(families) == 1
|
||||||
|
assert len(children) == 1
|
||||||
|
assert len(citations) == 3
|
||||||
@@ -20,9 +20,24 @@ from transcription.services.jobs import JobDeleteBlockedError
|
|||||||
from transcription.services.jobs import JobNotFoundError
|
from transcription.services.jobs import JobNotFoundError
|
||||||
from transcription.services.jobs import JobResubmitBlockedError
|
from transcription.services.jobs import JobResubmitBlockedError
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.jobs import _as_naive_utc
|
||||||
|
from transcription.services.jobs import _utc_now_naive
|
||||||
from transcription.services.sources import SourceService
|
from transcription.services.sources import SourceService
|
||||||
|
|
||||||
|
|
||||||
|
def test_utc_now_naive_returns_naive_datetime():
|
||||||
|
now = _utc_now_naive()
|
||||||
|
assert now.tzinfo is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_as_naive_utc_normalizes_timezone_aware_datetime():
|
||||||
|
aware = datetime.now(UTC)
|
||||||
|
normalized = _as_naive_utc(aware)
|
||||||
|
|
||||||
|
assert normalized.tzinfo is None
|
||||||
|
assert normalized == aware.replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
class TestJobService:
|
class TestJobService:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_and_read_job(self, job_service: JobService, document_service: DocumentService):
|
async def test_create_and_read_job(self, job_service: JobService, document_service: DocumentService):
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests for maintenance run persistence and execution lifecycle."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.db.models import MaintenanceJobType
|
||||||
|
from transcription.db.models import MaintenanceRunStatus
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.maintenance import MaintenanceError
|
||||||
|
from transcription.services.maintenance import MaintenanceExecution
|
||||||
|
from transcription.services.maintenance import MaintenanceService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enqueue_and_list_runs(default_session_factory, default_settings):
|
||||||
|
service = MaintenanceService(session_factory=default_session_factory, settings=default_settings)
|
||||||
|
|
||||||
|
first = await service.enqueue_run(job_type=MaintenanceJobType.BACKUP, triggered_by="test")
|
||||||
|
second = await service.enqueue_run(job_type=MaintenanceJobType.STORAGE_RECONCILIATION, triggered_by="test")
|
||||||
|
third = await service.enqueue_run(job_type=MaintenanceJobType.GEDCOM_IMPORT, triggered_by="test")
|
||||||
|
runs = await service.list_runs(limit=10)
|
||||||
|
|
||||||
|
assert len(runs) == 3
|
||||||
|
assert runs[0].id == third.id
|
||||||
|
assert runs[1].id == second.id
|
||||||
|
assert runs[2].id == first.id
|
||||||
|
assert runs[0].status == MaintenanceRunStatus.QUEUED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_next_queued_run_persists_terminal_result(
|
||||||
|
default_session_factory,
|
||||||
|
default_settings,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
settings = default_settings.model_copy(update={"log_dir": tmp_path / "logs"})
|
||||||
|
settings = Settings.model_validate(settings.model_dump())
|
||||||
|
service = MaintenanceService(session_factory=default_session_factory, settings=settings)
|
||||||
|
queued = await service.enqueue_run(job_type=MaintenanceJobType.BACKUP, triggered_by="test")
|
||||||
|
|
||||||
|
async def _fake_execute(_run):
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.SUCCEEDED,
|
||||||
|
summary="Synthetic success",
|
||||||
|
output="stdout line\nstderr line",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_execute_run", _fake_execute)
|
||||||
|
|
||||||
|
processed = await service.process_next_queued_run()
|
||||||
|
assert processed is True
|
||||||
|
|
||||||
|
runs = await service.list_runs(limit=10)
|
||||||
|
updated = next(run for run in runs if run.id == queued.id)
|
||||||
|
assert updated.status == MaintenanceRunStatus.SUCCEEDED
|
||||||
|
assert updated.summary == "Synthetic success"
|
||||||
|
assert updated.log_path is not None
|
||||||
|
assert (settings.log_dir / Path(updated.log_path)).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_runs_raises_when_table_is_missing(default_session_factory, default_settings):
|
||||||
|
service = MaintenanceService(session_factory=default_session_factory, settings=default_settings)
|
||||||
|
async with default_session_factory() as session:
|
||||||
|
await session.exec(text("DROP TABLE maintenance_run"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(MaintenanceError) as exc:
|
||||||
|
await service.list_runs(limit=10)
|
||||||
|
assert exc.value.category == ErrorCategory.INFRA_PERSISTENT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_run_dispatches_gedcom_import(default_session_factory, default_settings, monkeypatch):
|
||||||
|
service = MaintenanceService(session_factory=default_session_factory, settings=default_settings)
|
||||||
|
run = await service.enqueue_run(job_type=MaintenanceJobType.GEDCOM_IMPORT, triggered_by="test")
|
||||||
|
|
||||||
|
async def _fake_gedcom_import():
|
||||||
|
return MaintenanceExecution(
|
||||||
|
status=MaintenanceRunStatus.SUCCEEDED,
|
||||||
|
summary="GEDCOM imported",
|
||||||
|
output="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_execute_gedcom_import", _fake_gedcom_import)
|
||||||
|
execution = await service._execute_run(run)
|
||||||
|
assert execution.summary == "GEDCOM imported"
|
||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.errors import PromptLoadError
|
||||||
from transcription.services.prompts import PromptStore
|
from transcription.services.prompts import PromptStore
|
||||||
from transcription.services.prompts import PromptStoreError
|
from transcription.services.prompts import PromptStoreError
|
||||||
|
|
||||||
@@ -79,6 +80,13 @@ def test_prompt_creation_and_empty_content_are_rejected(prompt_store):
|
|||||||
assert empty.value.category == ErrorCategory.VALIDATION
|
assert empty.value.category == ErrorCategory.VALIDATION
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_store_failures_are_catchable_as_prompt_load_errors(prompt_store):
|
||||||
|
store, _ = prompt_store
|
||||||
|
|
||||||
|
with pytest.raises(PromptLoadError):
|
||||||
|
store.read_prompt("missing.md")
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_requires_a_backup(prompt_store):
|
def test_recovery_requires_a_backup(prompt_store):
|
||||||
store, _ = prompt_store
|
store, _ = prompt_store
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for deterministic V4.5 transcription warnings."""
|
"""Tests for deterministic transcription warnings."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ from transcription.services.quality import quality_warning_payload
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_quality_analysis_reports_each_v45_warning_without_mutating_text():
|
def test_quality_analysis_reports_each_quality_warning_without_mutating_text():
|
||||||
text = (
|
text = (
|
||||||
"[document body handwritten]\n"
|
"[document body handwritten]\n"
|
||||||
"[document body typewritten]\n"
|
"[document body typewritten]\n"
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class TestWorkflowReliability:
|
|||||||
provider=None,
|
provider=None,
|
||||||
source_reference=None,
|
source_reference=None,
|
||||||
requested_model=None,
|
requested_model=None,
|
||||||
|
evidence_capture=None,
|
||||||
):
|
):
|
||||||
_ = (
|
_ = (
|
||||||
image_path,
|
image_path,
|
||||||
@@ -97,6 +98,7 @@ class TestWorkflowReliability:
|
|||||||
provider,
|
provider,
|
||||||
source_reference,
|
source_reference,
|
||||||
requested_model,
|
requested_model,
|
||||||
|
evidence_capture,
|
||||||
)
|
)
|
||||||
raise TimeoutError("simulated provider timeout")
|
raise TimeoutError("simulated provider timeout")
|
||||||
|
|
||||||
@@ -370,6 +372,64 @@ class TestWorkflowReliability:
|
|||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.status == JobStatus.TRANSCRIBED
|
assert result.status == JobStatus.TRANSCRIBED
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_intermediate_page_commit_advances_job_liveness_timestamp(
|
||||||
|
self,
|
||||||
|
default_session_factory,
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
services = ServiceBundle.from_session_factory(default_session_factory)
|
||||||
|
async with services.jobs._session_scope() as session:
|
||||||
|
document = Document(id=uuid4(), name="heartbeat-doc")
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
for page_number in (1, 2, 3):
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=page_number,
|
||||||
|
upload_name=f"page-{page_number}.jpg",
|
||||||
|
filename=f"page-{page_number}.jpg",
|
||||||
|
file_path=str(Path("tests/fixtures/images/real/Book Two - page 02.jpg")),
|
||||||
|
file_hash=str(page_number) * 64,
|
||||||
|
file_size_bytes=1,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
session.add(JobSource(job_id=job.id, source_id=source.id))
|
||||||
|
await session.commit()
|
||||||
|
loaded = await services.jobs.read_job(job_id=job.id, session=session)
|
||||||
|
|
||||||
|
third_started = asyncio.Event()
|
||||||
|
release_third = asyncio.Event()
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def _transcribe(image_path, **kwargs):
|
||||||
|
nonlocal call_count
|
||||||
|
_ = (image_path, kwargs)
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 3:
|
||||||
|
third_started.set()
|
||||||
|
await release_third.wait()
|
||||||
|
return TranscriptionResult(text=f"page {call_count}", provider="fixture", model="model")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.services.workflows.transcribe_document_image", _transcribe)
|
||||||
|
task = asyncio.create_task(process_queued_job(job=loaded, services=services))
|
||||||
|
await asyncio.wait_for(third_started.wait(), timeout=2)
|
||||||
|
|
||||||
|
async with services.jobs._session_scope() as session:
|
||||||
|
current_job = await session.get(Job, loaded.id)
|
||||||
|
assert current_job is not None
|
||||||
|
attempts = await services.evidence.list_execution_attempts(job_id=loaded.id, session=session)
|
||||||
|
|
||||||
|
assert len(attempts) == 2
|
||||||
|
assert current_job.date_updated >= attempts[1].finished_at
|
||||||
|
|
||||||
|
release_third.set()
|
||||||
|
await task
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_failed_job_with_validation_category_is_not_requeued(self, default_session_factory):
|
async def test_failed_job_with_validation_category_is_not_requeued(self, default_session_factory):
|
||||||
services = ServiceBundle.from_session_factory(default_session_factory)
|
services = ServiceBundle.from_session_factory(default_session_factory)
|
||||||
|
|||||||
@@ -136,3 +136,52 @@ class TestAppLifespan:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
assert calls == ["logging", "schema", "recover", "worker_start", "worker_stop", "dispose_db"]
|
assert calls == ["logging", "schema", "recover", "worker_start", "worker_stop", "dispose_db"]
|
||||||
|
|
||||||
|
def test_startup_skips_embedded_worker_when_disabled(self, monkeypatch, tmp_path):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.configure_logging", lambda _settings: calls.append("logging"))
|
||||||
|
monkeypatch.setattr("transcription.app.register_pages", lambda _app: None)
|
||||||
|
|
||||||
|
async def _create_all(**_kwargs):
|
||||||
|
calls.append("schema")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.create_all", _create_all)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"transcription.app.initialize_database_runtime",
|
||||||
|
lambda **_kwargs: type("_Runtime", (), {"engine": object(), "session_factory": object()})(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _dispose_runtime():
|
||||||
|
calls.append("dispose_db")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.dispose_database_runtime", _dispose_runtime)
|
||||||
|
|
||||||
|
async def _recover_stale(_app):
|
||||||
|
calls.append("recover")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app._recover_stale_processing_jobs", _recover_stale)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _worker_lifespan(**_kwargs):
|
||||||
|
calls.append("worker_start")
|
||||||
|
yield object(), object(), object()
|
||||||
|
calls.append("worker_stop")
|
||||||
|
|
||||||
|
monkeypatch.setattr("transcription.app.worker_consumer_lifespan", _worker_lifespan)
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
environment="test",
|
||||||
|
bootstrap_schema_on_startup=True,
|
||||||
|
run_embedded_worker=False,
|
||||||
|
upload_dir=tmp_path / "uploads",
|
||||||
|
prompt_dir=tmp_path / "prompts",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("transcription.app.get_settings", lambda: settings)
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with TestClient(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert calls == ["logging", "schema", "recover", "dispose_db"]
|
||||||
|
|||||||
+21
-3
@@ -15,7 +15,7 @@ from transcription.config import parse_cli_settings
|
|||||||
|
|
||||||
|
|
||||||
def _make_settings(**overrides: Any) -> Settings:
|
def _make_settings(**overrides: Any) -> Settings:
|
||||||
"""Build a Settings instance with a dummy API key, isolated from any local .env."""
|
"""Build a Settings instance with a dummy API key, isolated from local env files."""
|
||||||
defaults: dict[str, Any] = {"openrouter_api_key": "test-key-abc123", "provider_models": None}
|
defaults: dict[str, Any] = {"openrouter_api_key": "test-key-abc123", "provider_models": None}
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
return Settings(_env_file=None, **defaults)
|
return Settings(_env_file=None, **defaults)
|
||||||
@@ -145,7 +145,6 @@ class TestPathSettings:
|
|||||||
assert isinstance(settings.upload_dir, Path)
|
assert isinstance(settings.upload_dir, Path)
|
||||||
assert isinstance(settings.prompt_dir, Path)
|
assert isinstance(settings.prompt_dir, Path)
|
||||||
assert isinstance(settings.log_dir, Path)
|
assert isinstance(settings.log_dir, Path)
|
||||||
assert isinstance(settings.database_backup_dir, Path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_configure_logging_writes_rotating_file_logs_to_configured_directory(tmp_path):
|
def test_configure_logging_writes_rotating_file_logs_to_configured_directory(tmp_path):
|
||||||
@@ -166,12 +165,31 @@ class TestWorkerReliabilitySettings:
|
|||||||
def test_worker_retry_defaults(self):
|
def test_worker_retry_defaults(self):
|
||||||
"""worker retry settings default to no retries."""
|
"""worker retry settings default to no retries."""
|
||||||
settings = _make_settings()
|
settings = _make_settings()
|
||||||
|
assert settings.run_embedded_worker is True
|
||||||
assert settings.worker_max_retries == 0
|
assert settings.worker_max_retries == 0
|
||||||
assert settings.worker_stale_job_seconds == 30.0
|
assert settings.worker_stale_job_seconds == 90.0
|
||||||
assert settings.worker_retry_backoff_seconds == 1.0
|
assert settings.worker_retry_backoff_seconds == 1.0
|
||||||
assert settings.worker_shutdown_grace_seconds == 5.0
|
assert settings.worker_shutdown_grace_seconds == 5.0
|
||||||
assert settings.worker_poll_interval_seconds == 1.0
|
assert settings.worker_poll_interval_seconds == 1.0
|
||||||
|
|
||||||
|
def test_worker_stale_threshold_defaults_to_three_times_provider_timeout(self):
|
||||||
|
settings = _make_settings(worker_provider_timeout_seconds=45.0)
|
||||||
|
|
||||||
|
assert settings.worker_stale_job_seconds == 135.0
|
||||||
|
|
||||||
|
def test_worker_stale_threshold_derives_from_environment_timeout(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
|
||||||
|
monkeypatch.setenv("WORKER_PROVIDER_TIMEOUT_SECONDS", "45.0")
|
||||||
|
monkeypatch.delenv("WORKER_STALE_JOB_SECONDS", raising=False)
|
||||||
|
|
||||||
|
settings = Settings(_env_file=None)
|
||||||
|
|
||||||
|
assert settings.worker_stale_job_seconds == 135.0
|
||||||
|
|
||||||
|
def test_worker_stale_threshold_must_exceed_provider_timeout(self):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
_make_settings(worker_provider_timeout_seconds=45.0, worker_stale_job_seconds=45.0)
|
||||||
|
|
||||||
|
|
||||||
def test_provider_timeout_is_not_capped_at_twenty_seconds():
|
def test_provider_timeout_is_not_capped_at_twenty_seconds():
|
||||||
"""HIGH-03: vision transcription regularly runs past the old le=20.0 ceiling."""
|
"""HIGH-03: vision transcription regularly runs past the old le=20.0 ceiling."""
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Suite-wide isolation of `Settings` from developer environment files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import transcription.config as config_module
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import resolve_settings_env_file_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_defaults_are_not_overridden_by_a_local_env_file():
|
||||||
|
"""A bare `Settings(...)` must observe declared defaults, not developer machine state."""
|
||||||
|
settings = Settings(openrouter_api_key="test-key")
|
||||||
|
|
||||||
|
assert settings.environment == "development"
|
||||||
|
assert settings.log_dir == Path("./data/logs")
|
||||||
|
assert settings.host == "0.0.0.0"
|
||||||
|
assert settings.port == 8000
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_env_file_still_loads(tmp_path):
|
||||||
|
"""Isolation must not disable env-file loading for tests that opt into it."""
|
||||||
|
env_path = tmp_path / ".env"
|
||||||
|
env_path.write_text("PORT=9123\n", encoding="utf-8")
|
||||||
|
|
||||||
|
settings = Settings(openrouter_api_key="test-key", _env_file=env_path, _cli_parse_args=False)
|
||||||
|
|
||||||
|
assert settings.port == 9123
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_env_file_resolves_from_override_environment_variable(tmp_path, monkeypatch):
|
||||||
|
env_path = tmp_path / "custom.env"
|
||||||
|
env_path.write_text("PORT=9123\n", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("ENV_FILE", str(env_path))
|
||||||
|
|
||||||
|
settings = Settings(openrouter_api_key="test-key", _cli_parse_args=False)
|
||||||
|
|
||||||
|
assert settings.port == 9123
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_default_env_path_is_anchored_to_project_root(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("ENV_FILE", raising=False)
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.setattr(config_module, "PROJECT_ROOT", tmp_path / "project-root")
|
||||||
|
|
||||||
|
resolved = resolve_settings_env_file_path()
|
||||||
|
|
||||||
|
assert resolved == tmp_path / "project-root" / ".env.production"
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""AST guards for user-facing error safety and UI failure-detail rendering."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SOURCE_DIR = PROJECT_ROOT / "src" / "transcription"
|
||||||
|
UI_DIR = SOURCE_DIR / "ui"
|
||||||
|
|
||||||
|
_RAW_DETAIL_ATTRIBUTES = frozenset({"error_detail", "latest_error_detail"})
|
||||||
|
_SUSPICIOUS_FORMATTED_NAMES = frozenset({"path", "root", "dir", "exc", "err", "e"})
|
||||||
|
|
||||||
|
|
||||||
|
def _python_files(root: Path) -> list[Path]:
|
||||||
|
return sorted(root.rglob("*.py"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]:
|
||||||
|
parents: dict[ast.AST, ast.AST] = {}
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
for child in ast.iter_child_nodes(node):
|
||||||
|
parents[child] = node
|
||||||
|
return parents
|
||||||
|
|
||||||
|
|
||||||
|
def _is_wrapped_in_display_failure_detail(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> bool:
|
||||||
|
current = node
|
||||||
|
while current in parents:
|
||||||
|
current = parents[current]
|
||||||
|
if not isinstance(current, ast.Call):
|
||||||
|
continue
|
||||||
|
func = current.func
|
||||||
|
if isinstance(func, ast.Name) and func.id == "display_failure_detail":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _ui_raw_detail_reads() -> dict[str, list[int]]:
|
||||||
|
violations: dict[str, list[int]] = {}
|
||||||
|
for path in _python_files(UI_DIR):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
parents = _parent_map(tree)
|
||||||
|
found = sorted(
|
||||||
|
node.lineno
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Attribute)
|
||||||
|
and node.attr in _RAW_DETAIL_ATTRIBUTES
|
||||||
|
and not _is_wrapped_in_display_failure_detail(node, parents)
|
||||||
|
)
|
||||||
|
if found:
|
||||||
|
violations[str(path.relative_to(PROJECT_ROOT)).replace("\\", "/")] = found
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def _class_bases_by_name() -> dict[str, set[str]]:
|
||||||
|
bases: dict[str, set[str]] = {}
|
||||||
|
for path in _python_files(SOURCE_DIR):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
inherited = set()
|
||||||
|
for base in node.bases:
|
||||||
|
if isinstance(base, ast.Name):
|
||||||
|
inherited.add(base.id)
|
||||||
|
elif isinstance(base, ast.Attribute):
|
||||||
|
inherited.add(base.attr)
|
||||||
|
bases[node.name] = inherited
|
||||||
|
return bases
|
||||||
|
|
||||||
|
|
||||||
|
def _app_error_subclasses() -> set[str]:
|
||||||
|
bases = _class_bases_by_name()
|
||||||
|
subclasses = {"AppError"}
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
for name, inherited in bases.items():
|
||||||
|
if name in subclasses:
|
||||||
|
continue
|
||||||
|
if inherited & subclasses:
|
||||||
|
subclasses.add(name)
|
||||||
|
changed = True
|
||||||
|
subclasses.remove("AppError")
|
||||||
|
return subclasses
|
||||||
|
|
||||||
|
|
||||||
|
def _formatted_name_ids(node: ast.AST) -> set[str]:
|
||||||
|
return {child.id for child in ast.walk(node) if isinstance(child, ast.Name)}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_basename_projection(node: ast.AST) -> bool:
|
||||||
|
return isinstance(node, ast.Attribute) and node.attr == "name"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_suspicious_name(name: str) -> bool:
|
||||||
|
if name in _SUSPICIOUS_FORMATTED_NAMES:
|
||||||
|
return True
|
||||||
|
return any(name.endswith(f"_{suffix}") for suffix in _SUSPICIOUS_FORMATTED_NAMES - {"e"})
|
||||||
|
|
||||||
|
|
||||||
|
def _user_message_interpolation_violations() -> dict[str, list[str]]:
|
||||||
|
violations: dict[str, list[str]] = {}
|
||||||
|
error_types = _app_error_subclasses()
|
||||||
|
for path in _python_files(SOURCE_DIR):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
found: list[str] = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Raise):
|
||||||
|
continue
|
||||||
|
if not isinstance(node.exc, ast.Call):
|
||||||
|
continue
|
||||||
|
func = node.exc.func
|
||||||
|
if not isinstance(func, ast.Name) or func.id not in error_types:
|
||||||
|
continue
|
||||||
|
if not node.exc.args:
|
||||||
|
continue
|
||||||
|
message = node.exc.args[0]
|
||||||
|
if not isinstance(message, ast.JoinedStr):
|
||||||
|
continue
|
||||||
|
formatted_names = {
|
||||||
|
name
|
||||||
|
for value in message.values
|
||||||
|
if isinstance(value, ast.FormattedValue)
|
||||||
|
if not _is_safe_basename_projection(value.value)
|
||||||
|
for name in _formatted_name_ids(value.value)
|
||||||
|
}
|
||||||
|
suspicious = sorted(name for name in formatted_names if _is_suspicious_name(name))
|
||||||
|
if suspicious:
|
||||||
|
found.append(f"L{node.lineno}: {', '.join(suspicious)}")
|
||||||
|
if found:
|
||||||
|
violations[str(path.relative_to(PROJECT_ROOT)).replace("\\", "/")] = found
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_modules_only_render_failure_detail_through_projection():
|
||||||
|
"""HIGH-01: UI must sanitize persisted failure detail before rendering it."""
|
||||||
|
assert _ui_raw_detail_reads() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_facing_app_error_messages_do_not_interpolate_paths_or_exceptions():
|
||||||
|
"""HIGH-02 / MED-07: keep paths and exception text out of AppError.message."""
|
||||||
|
assert _user_message_interpolation_violations() == {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user