generated from john/python-template
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f731cc293 | ||
|
|
0030c521d3 |
+18
-9
@@ -1,7 +1,7 @@
|
||||
## MVP Definition: Historical Document Transcription System
|
||||
|
||||
### 1. MVP Objective
|
||||
Deliver the thinnest possible end-to-end vertical slice — a user uploads an image of a document, the system transcribes it via a configurable AI provider, and the user reads the resulting transcript — with just enough persistence and structure to validate the core value proposition: *can AI-driven transcription, guided by curated prompts, produce useful verbatim transcripts of historical family documents?*
|
||||
Deliver the thinnest possible end-to-end vertical slice — a user uploads an image of a document, the system transcribes it via the OpenRouter Python SDK, and the user reads the resulting transcript — with just enough persistence and structure to validate the core value proposition: *can AI-driven transcription, guided by curated prompts, produce useful verbatim transcripts of historical family documents?*
|
||||
|
||||
The MVP deliberately defers full-text search, export, revision history, MongoDB, and timeline assembly. These are additive features that don't need validation before the core transcription loop is proven.
|
||||
|
||||
@@ -47,7 +47,7 @@ The MVP deliberately defers full-text search, export, revision history, MongoDB,
|
||||
* An in-process background worker (Python asyncio task or BackgroundTasks) that:
|
||||
1. Picks up queued jobs.
|
||||
2. Transitions status to processing.
|
||||
3. Sends the image + the curated Markdown prompt to an AI vision model via the configured provider (OpenRouter or Gemini).
|
||||
3. Sends the image + the curated Markdown prompt to an AI vision model via OpenRouter.
|
||||
4. On success: saves the transcript text, transitions to transcribed.
|
||||
5. On failure: saves the error detail, transitions to failed.
|
||||
|
||||
@@ -71,14 +71,24 @@ The MVP deliberately defers full-text search, export, revision history, MongoDB,
|
||||
|
||||
#### Feature 6: Centralized Configuration
|
||||
* A single config.py (or Pydantic BaseSettings) loading:
|
||||
* PROVIDER (default: openrouter; options: openrouter, gemini)
|
||||
* PROVIDER_API_KEY (required)
|
||||
* PROVIDER_MODEL (default: provider-appropriate default)
|
||||
* PROVIDER_BASE_URL (default: provider-appropriate default; overridable)
|
||||
* PROVIDER (fixed to openrouter for MVP)
|
||||
* OPENROUTER_API_KEY (required)
|
||||
* PROVIDER_MODEL (default: OpenRouter model slug for vision transcription)
|
||||
* OPENROUTER_HTTP_REFERER (optional; app attribution)
|
||||
* OPENROUTER_APP_TITLE (optional; app attribution)
|
||||
* DATABASE_URL (default: sqlite:///./transcription.db)
|
||||
* UPLOAD_DIR (default: ./uploads)
|
||||
* PROMPT_DIR (default: ./prompts)
|
||||
|
||||
#### Feature 7: MVP Dependency Baseline (OpenRouter-Centric)
|
||||
* Runtime dependencies:
|
||||
* openrouter (official OpenRouter Python SDK)
|
||||
* pydantic
|
||||
* pydantic-settings
|
||||
* sqlmodel
|
||||
* Explicitly out of MVP runtime dependencies:
|
||||
* google-genai (deferred until/if Gemini is introduced post-MVP)
|
||||
|
||||
---
|
||||
|
||||
### 5. MVP Architecture (Simplified)
|
||||
@@ -127,8 +137,7 @@ project-root/
|
||||
│ ├── providers/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base.py # provider interface (transcribe contract)
|
||||
│ │ ├── openrouter.py # OpenRouter via openai client
|
||||
│ │ └── gemini.py # Google Gemini
|
||||
│ │ ├── openrouter.py # OpenRouter via official Python SDK
|
||||
│ ├── services/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── upload.py # save file + create records
|
||||
@@ -197,4 +206,4 @@ Recommended build order for the MVP (each step produces a testable increment):
|
||||
| 5 | ui/upload_page.py + ui/jobs_page.py — NiceGUI pages | User-facing interface |
|
||||
| 6 | tests/ — unit + integration tests Automated verification |
|
||||
|
||||
This MVP is deliberately narrow: **one prompt, one configurable provider, one user, one image at a time, SQLite, no containers**. Every omission is intentional — the goal is to get real family documents through the transcription pipeline as fast as possible and let the quality of the output guide every subsequent decision.
|
||||
This MVP is deliberately narrow: **one prompt, one provider (OpenRouter), one user, one image at a time, SQLite, no containers**. Every omission is intentional — the goal is to get real family documents through the transcription pipeline as fast as possible and let the quality of the output guide every subsequent decision.
|
||||
+130
-52
@@ -59,8 +59,7 @@ description = "Historical document transcription system"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"google-genai>=1.0.0",
|
||||
"openai>=2.43.0",
|
||||
"openrouter>=0.7.0",
|
||||
"pydantic>=2.13.4",
|
||||
"pydantic-settings>=2.9.1",
|
||||
"sqlmodel>=0.0.25",
|
||||
@@ -74,6 +73,7 @@ dev = [
|
||||
```
|
||||
|
||||
Key additions:
|
||||
- **`openrouter`** — official OpenRouter Python SDK used for model calls
|
||||
- **`pydantic-settings`** — for `BaseSettings` with env-var loading (this was split out of `pydantic` core in v2)
|
||||
- **`sqlmodel`** — provides SQLModel (which bundles SQLAlchemy + Pydantic model integration) and the SQLite driver
|
||||
- **`pytest` + `pytest-asyncio`** — in `dev` extras for test execution
|
||||
@@ -93,12 +93,13 @@ The placeholder file is no longer needed.
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Use `pydantic-settings` `BaseSettings` | Type-safe, validates on construction, loads from env vars and `.env` files automatically |
|
||||
| `PROVIDER` as a string enum (`openrouter`, `gemini`) | Drives provider factory in Step 3; validated at startup, not at first API call |
|
||||
| `PROVIDER_MODEL` defaults to `None` | Each provider adapter (Step 3) supplies its own sensible default when `None`; avoids config knowing about provider-specific model names |
|
||||
| `PROVIDER_BASE_URL` defaults to `None` | Only needed to override OpenRouter's base URL; Gemini ignores it. `None` means "use provider default" |
|
||||
| `PROVIDER` constrained to `openrouter` for MVP | Keeps configuration explicit while avoiding premature multi-provider complexity |
|
||||
| `OPENROUTER_API_KEY` required | Matches official SDK docs and avoids ambiguous provider-agnostic naming |
|
||||
| `PROVIDER_MODEL` defaults to `None` | OpenRouter adapter (Step 3) supplies a sensible default when `None` |
|
||||
| `OPENROUTER_HTTP_REFERER` and `OPENROUTER_APP_TITLE` optional | Matches SDK optional app-attribution fields |
|
||||
| `DATABASE_URL` defaults to SQLite | Zero-setup local development; PostgreSQL swap is a single env-var change post-MVP |
|
||||
| `UPLOAD_DIR` and `PROMPT_DIR` as `Path` objects | Enables `.mkdir(parents=True, exist_ok=True)` and path validation at startup |
|
||||
| Logging configured in a `setup_logging()` function | Called once at startup; uses stdlib `logging` with a simple format. No third-party logging library needed for MVP |
|
||||
| Logging configured via `logging.config.dictConfig` in `setup_logging()` | Centralized, explicit formatter/handler/root logger topology; called once at startup with `disable_existing_loggers=False` |
|
||||
|
||||
### Proposed Implementation
|
||||
|
||||
@@ -114,13 +115,13 @@ from enum import StrEnum
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Provider(StrEnum):
|
||||
OPENROUTER = "openrouter"
|
||||
GEMINI = "gemini"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -132,9 +133,10 @@ class Settings(BaseSettings):
|
||||
|
||||
# --- AI provider ---
|
||||
provider: Provider = Provider.OPENROUTER
|
||||
provider_api_key: str
|
||||
openrouter_api_key: str
|
||||
provider_model: str | None = None
|
||||
provider_base_url: str | None = None
|
||||
openrouter_http_referer: str | None = None
|
||||
openrouter_app_title: str | None = None
|
||||
|
||||
# --- persistence ---
|
||||
database_url: str = "sqlite:///./transcription.db"
|
||||
@@ -144,6 +146,29 @@ class Settings(BaseSettings):
|
||||
prompt_dir: Path = Path("./prompts")
|
||||
|
||||
|
||||
LOGGING_CONFIG: dict[str, object] = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"standard": {
|
||||
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
}
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "standard",
|
||||
"stream": "ext://sys.stdout",
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return the singleton Settings instance.
|
||||
@@ -155,27 +180,25 @@ def get_settings() -> Settings:
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure root logging once at startup."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
```
|
||||
|
||||
### Key Behaviors
|
||||
|
||||
- **Startup validation**: If `PROVIDER_API_KEY` is missing from the environment, `Settings()` raises a `ValidationError` immediately — the app won't start with a missing key.
|
||||
- **Startup validation**: If `OPENROUTER_API_KEY` is missing from the environment, `Settings()` raises a `ValidationError` immediately — the app won't start with a missing key.
|
||||
- **`.env` support**: Developers can create a `.env` file in the project root for local keys; it's never committed (already covered by the existing `.gitignore` pattern or a new entry).
|
||||
- **`extra="ignore"`**: Unknown env vars don't cause errors, keeping the config resilient to unrelated environment variables.
|
||||
- **`lru_cache`**: `get_settings()` is the single access point. All modules import and call this function rather than constructing `Settings` directly.
|
||||
- **Centralized logging**: `setup_logging()` calls `dictConfig` exactly once at startup; all modules should use `logging.getLogger(__name__)` and avoid `basicConfig`.
|
||||
|
||||
### `.env` template (not committed — add to `.gitignore`)
|
||||
|
||||
```bash .env.example
|
||||
PROVIDER=openrouter
|
||||
PROVIDER_API_KEY=sk-or-...
|
||||
# PROVIDER_MODEL= # optional: provider adapter supplies default
|
||||
# PROVIDER_BASE_URL= # optional: override provider endpoint
|
||||
OPENROUTER_API_KEY=sk-or-...
|
||||
# PROVIDER_MODEL= # optional: OpenRouter adapter supplies default
|
||||
# OPENROUTER_HTTP_REFERER=https://example.com
|
||||
# OPENROUTER_APP_TITLE=Historical Transcription MVP
|
||||
# DATABASE_URL=sqlite:///./transcription.db
|
||||
# UPLOAD_DIR=./uploads
|
||||
# PROMPT_DIR=./prompts
|
||||
@@ -328,7 +351,7 @@ REQ-3 lists six states: `upload`, `queued`, `processing`, `transcribed`, `failed
|
||||
MVP uses SQLite with auto-create-tables at startup.
|
||||
PostgreSQL migration is a post-MVP configuration change.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
@@ -356,7 +379,8 @@ def create_all() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
@contextlib.contextmanager
|
||||
def get_session() -> Generator[Session]:
|
||||
"""Yield a database session and ensure cleanup."""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
@@ -370,6 +394,54 @@ def get_session() -> Generator[Session, None, None]:
|
||||
|
||||
## 5. Test Plan
|
||||
|
||||
### Pytest Hierarchy Rules (Applied)
|
||||
|
||||
Use the following structure consistently across Step 1 tests:
|
||||
|
||||
- Module names: `test_*.py`
|
||||
- Class names: `Test*` (group related scenarios)
|
||||
- Method names: `test_<expected_outcome>` (keep them short and behavior-focused)
|
||||
- Shared fixtures: nearest `conftest.py` at needed scope
|
||||
|
||||
Hierarchy pattern used in this step:
|
||||
|
||||
```text
|
||||
tests/
|
||||
conftest.py
|
||||
test_config.py
|
||||
TestSettingsLoading
|
||||
test_loads_from_env
|
||||
test_requires_api_key
|
||||
TestProviderSettings
|
||||
test_defaults_to_openrouter
|
||||
test_rejects_invalid_value
|
||||
test_optional_fields_default_to_none
|
||||
TestPathSettings
|
||||
test_path_fields_are_path_objects
|
||||
test_models.py
|
||||
TestDocumentModel
|
||||
test_can_be_persisted
|
||||
test_defaults_are_populated
|
||||
TestJobModel
|
||||
test_can_be_created_for_document
|
||||
test_defaults_are_populated
|
||||
test_transitions_to_transcribed
|
||||
test_transitions_to_failed
|
||||
TestTranscriptModel
|
||||
test_success_record_persists
|
||||
test_failure_record_persists
|
||||
test_job_id_is_unique
|
||||
TestRelationships
|
||||
test_document_exposes_jobs
|
||||
test_job_exposes_transcript
|
||||
test_db.py
|
||||
TestSchemaBootstrap
|
||||
test_create_all_creates_expected_tables
|
||||
TestSessionFactory
|
||||
test_get_session_yields_session
|
||||
test_session_is_closed_after_generator_exit
|
||||
```
|
||||
|
||||
### `tests/conftest.py` — Shared Fixtures
|
||||
|
||||
```python tests/conftest.py
|
||||
@@ -395,40 +467,45 @@ def session():
|
||||
yield session
|
||||
```
|
||||
|
||||
### `tests/test_config.py` — Configuration Tests
|
||||
### `tests/test_config.py` — Configuration Hierarchy
|
||||
|
||||
| Test | What It Verifies |
|
||||
|------|------------------|
|
||||
| `test_settings_loads_from_env` | `Settings` constructs successfully when `PROVIDER_API_KEY` is set via env var |
|
||||
| `test_settings_requires_api_key` | `Settings()` raises `ValidationError` when `PROVIDER_API_KEY` is missing |
|
||||
| `test_provider_defaults_to_openrouter` | Default provider is `openrouter` when not explicitly set |
|
||||
| `test_provider_rejects_invalid_value` | Setting `PROVIDER=invalid` raises `ValidationError` |
|
||||
| `test_optional_fields_default_to_none` | `provider_model` and `provider_base_url` are `None` when unset |
|
||||
| `test_path_fields_are_path_objects` | `upload_dir` and `prompt_dir` are `Path` instances |
|
||||
| Class | Method | What It Verifies |
|
||||
|------|--------|------------------|
|
||||
| `TestSettingsLoading` | `test_loads_from_env` | `Settings` constructs successfully when `OPENROUTER_API_KEY` is set via env var |
|
||||
| `TestSettingsLoading` | `test_requires_api_key` | `Settings()` raises `ValidationError` when `OPENROUTER_API_KEY` is missing |
|
||||
| `TestProviderSettings` | `test_defaults_to_openrouter` | Default provider is `openrouter` when not explicitly set |
|
||||
| `TestProviderSettings` | `test_rejects_invalid_value` | Setting `PROVIDER=invalid` raises `ValidationError` |
|
||||
| `TestProviderSettings` | `test_optional_fields_default_to_none` | `provider_model`, `openrouter_http_referer`, and `openrouter_app_title` are `None` when unset |
|
||||
| `TestPathSettings` | `test_path_fields_are_path_objects` | `upload_dir` and `prompt_dir` are `Path` instances |
|
||||
|
||||
### `tests/test_models.py` — Model & Relationship Tests
|
||||
### `tests/test_models.py` — Model & Relationship Hierarchy
|
||||
|
||||
| Test | What It Verifies |
|
||||
|------|------------------|
|
||||
| `test_create_document` | A `Document` can be persisted and read back with correct fields |
|
||||
| `test_document_defaults` | `id` is auto-generated UUID, `uploaded_at` is populated |
|
||||
| `test_create_job_with_document` | A `Job` linked to a `Document` via FK persists correctly |
|
||||
| `test_job_defaults` | Default status is `queued`, `created_at` and `updated_at` are populated |
|
||||
| `test_job_status_transitions` | Status can be updated from `queued` → `processing` → `transcribed` |
|
||||
| `test_job_status_to_failed` | Status can be updated from `processing` → `failed` |
|
||||
| `test_create_transcript_success` | A `Transcript` with `text` set and `error_detail=None` persists correctly |
|
||||
| `test_create_transcript_failure` | A `Transcript` with `text=None` and `error_detail` set persists correctly |
|
||||
| `test_document_jobs_relationship` | `document.jobs` returns the linked `Job` list |
|
||||
| `test_job_transcript_relationship` | `job.transcript` returns the linked `Transcript` |
|
||||
| `test_transcript_job_id_unique` | Inserting two transcripts with the same `job_id` raises an integrity error |
|
||||
| Class | Method | What It Verifies |
|
||||
|------|--------|------------------|
|
||||
| `TestDocumentModel` | `test_can_be_persisted` | A `Document` can be persisted and read back with correct fields |
|
||||
| `TestDocumentModel` | `test_defaults_are_populated` | `id` is auto-generated UUID, `uploaded_at` is populated |
|
||||
| `TestJobModel` | `test_can_be_created_for_document` | A `Job` linked to a `Document` via FK persists correctly |
|
||||
| `TestJobModel` | `test_defaults_are_populated` | Default status is `queued`, `created_at` and `updated_at` are populated |
|
||||
| `TestJobModel` | `test_transitions_to_transcribed` | Status can be updated from `queued` → `processing` → `transcribed` |
|
||||
| `TestJobModel` | `test_transitions_to_failed` | Status can be updated from `processing` → `failed` |
|
||||
| `TestTranscriptModel` | `test_success_record_persists` | A `Transcript` with `text` set and `error_detail=None` persists correctly |
|
||||
| `TestTranscriptModel` | `test_failure_record_persists` | A `Transcript` with `text=None` and `error_detail` set persists correctly |
|
||||
| `TestRelationships` | `test_document_exposes_jobs` | `document.jobs` returns the linked `Job` list |
|
||||
| `TestRelationships` | `test_job_exposes_transcript` | `job.transcript` returns the linked `Transcript` |
|
||||
| `TestTranscriptModel` | `test_job_id_is_unique` | Inserting two transcripts with the same `job_id` raises an integrity error |
|
||||
|
||||
### `tests/test_db.py` — Database Bootstrap Tests
|
||||
### `tests/test_db.py` — Database Bootstrap Hierarchy
|
||||
|
||||
| Test | What It Verifies |
|
||||
|------|------------------|
|
||||
| `test_create_all_creates_tables` | After `create_all()`, the expected tables (`document`, `job`, `transcript`) exist in the database |
|
||||
| `test_get_session_yields_session` | `get_session()` yields a usable `Session` object |
|
||||
| `test_session_cleanup_on_exit` | After the generator is exhausted, the session is closed |
|
||||
| Class | Method | What It Verifies |
|
||||
|------|--------|------------------|
|
||||
| `TestSchemaBootstrap` | `test_create_all_creates_expected_tables` | After `create_all()`, the expected tables (`document`, `job`, `transcript`) exist in the database |
|
||||
| `TestSessionFactory` | `test_get_session_yields_session` | `get_session()` yields a usable `Session` object |
|
||||
| `TestSessionFactory` | `test_session_is_closed_after_generator_exit` | After the generator is exhausted, the session is closed |
|
||||
|
||||
### Marker Strategy (Step 1)
|
||||
|
||||
- Keep all Step 1 tests as default unit-level tests (no custom marker needed yet).
|
||||
- When slower integration or external tests are introduced, add explicit markers (for example `integration`, `external`) and keep names unchanged.
|
||||
|
||||
---
|
||||
|
||||
@@ -440,15 +517,16 @@ When all of the following are true, Step 1 is done and Step 2 can begin:
|
||||
|---|-----------|---------------|
|
||||
| 1 | `src/transcription/` package exists with `config.py`, `models.py`, `db.py` | `ls` / file inspection |
|
||||
| 2 | Empty `__init__.py` stubs exist for `providers/`, `services/`, `ui/` | `ls` / file inspection |
|
||||
| 3 | `Settings` loads from environment and validates `PROVIDER_API_KEY` is present | `test_config.py` passes |
|
||||
| 3 | `Settings` loads from environment and validates `OPENROUTER_API_KEY` is present | `test_config.py` passes |
|
||||
| 4 | `Document`, `Job`, `Transcript` models create tables in SQLite | `test_models.py` passes |
|
||||
| 5 | `JobStatus` enum has exactly four values: `queued`, `processing`, `transcribed`, `failed` | `test_models.py` passes |
|
||||
| 6 | Foreign key relationships work: Document→Job→Transcript | `test_models.py` passes |
|
||||
| 7 | `create_all()` bootstraps the schema; `get_session()` yields a working session | `test_db.py` passes |
|
||||
| 8 | All tests pass: `uv run pytest tests/` | CI / local run |
|
||||
| 9 | `hello.py` is deleted | File inspection |
|
||||
| 10 | `pyproject.toml` includes `sqlmodel`, `pydantic-settings`, `pytest`, `pytest-asyncio` | File inspection |
|
||||
| 10 | `pyproject.toml` includes `openrouter`, `sqlmodel`, `pydantic-settings`, `pytest`, `pytest-asyncio` | File inspection |
|
||||
| 11 | `.env.example` documents all config vars; `.env` is in `.gitignore` | File inspection |
|
||||
| 12 | `setup_logging()` uses `logging.config.dictConfig` with centralized formatter/handler/root config | File inspection |
|
||||
|
||||
---
|
||||
|
||||
@@ -459,7 +537,7 @@ Explicitly out of scope to prevent scope creep:
|
||||
| Excluded | Reason |
|
||||
|----------|--------|
|
||||
| FastAPI / NiceGUI app entrypoint | Step 5 |
|
||||
| Provider adapters (`openrouter.py`, `gemini.py`) | Step 3 |
|
||||
| Additional provider adapters beyond OpenRouter | Post-MVP |
|
||||
| Upload service logic | Step 4 |
|
||||
| Worker / background processing | Step 4 |
|
||||
| Transcription prompt files | Step 2 |
|
||||
|
||||
+3
-2
@@ -5,7 +5,8 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"google-genai>=1.0.0",
|
||||
"openai>=2.43.0",
|
||||
"openrouter>=0.7.0",
|
||||
"pydantic>=2.13.4",
|
||||
"pydantic-settings>=2.9.1",
|
||||
"sqlmodel>=0.0.25",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user