generated from john/python-template
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a818d982a6 |
@@ -1,13 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.vscode
|
|
||||||
.venv
|
|
||||||
.pytest_cache
|
|
||||||
.ruff_cache
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.db
|
|
||||||
.env
|
|
||||||
tests/
|
|
||||||
docs/
|
|
||||||
uploads/
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# --- NiceGUI Server ---
|
|
||||||
# HOST=`0.0.0.0` (default)
|
|
||||||
# PORT=8000 (default)
|
|
||||||
# LOG_LEVEL: [`critical`, `error`, `warning`, `info` (default), `debug`, `trace`]
|
|
||||||
# RELOAD=false (default)
|
|
||||||
|
|
||||||
# --- AI provider ---
|
|
||||||
# PROVIDER=[`openrouter`(default), `google_genai`]
|
|
||||||
PROVIDER=openrouter
|
|
||||||
# OPENROUTER_API_KEY - Required when `PROVIDER=openrouter`
|
|
||||||
OPENROUTER_API_KEY=your-api-key-goes-here
|
|
||||||
# GEMINI_API_KEY - Required when `PROVIDER=google_genai`
|
|
||||||
# PROVIDER_MODEL= specify model. If left blank OpenRouter will supply default.
|
|
||||||
PROVIDER_MODEL=google/gemini-2.5-flash
|
|
||||||
# OPENROUTER_HTTP_REFERER=https://example.com
|
|
||||||
# OPENROUTER_APP_TITLE="Google: Gemini 2.5 Flash (openrouter)"
|
|
||||||
|
|
||||||
# --- runtime environment ---
|
|
||||||
# ENVIRONMENT: [`development`(default), `test`, `production`]
|
|
||||||
|
|
||||||
# --- persistence ---
|
|
||||||
# Use nested settings with double underscore because env_nested_delimiter="__".
|
|
||||||
# SQLite example:
|
|
||||||
# DATABASE__DRIVER=sqlite
|
|
||||||
# DATABASE__PATH=app.db
|
|
||||||
#
|
|
||||||
# SQLite with custom relative path:
|
|
||||||
# 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
|
|
||||||
#
|
|
||||||
# Optional persistence flags:
|
|
||||||
# BOOTSTRAP_SCHEMA_ON_STARTUP=false
|
|
||||||
# SQLITE_CHECK_SAME_THREAD=false
|
|
||||||
|
|
||||||
# --- filesystem paths ---
|
|
||||||
UPLOAD_DIR="./data"
|
|
||||||
PROMPT_DIR="./prompts"
|
|
||||||
|
|
||||||
# --- worker reliability ---
|
|
||||||
WORKER_MAX_RETRIES=0
|
|
||||||
WORKER_RETRY_BACKOFF_SECONDS=0
|
|
||||||
# WORKER_PROVIDER_TIMEOUT_SECONDS=[0-20]
|
|
||||||
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
|
||||||
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
|
||||||
WORKER_MIN_TRANSCRIPTION_LINES=0
|
|
||||||
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
---
|
|
||||||
description: Follow these guidelines when editing the services
|
|
||||||
applyTo: 'src/transcription/services/*.py'
|
|
||||||
---
|
|
||||||
|
|
||||||
# Services
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
- Project core data models defined in [models](../../src/transcription/models.py)
|
|
||||||
- 1 service class per data model
|
|
||||||
- Only services directly interact with the database, and only through async methods
|
|
||||||
- Services are completely independent of one another. Any operation that needs to use more than a single service, which is most of them, needs to have a separate orchestration function.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- Service-specific errors defined at the top of the respective module and inherit from `AppError`
|
|
||||||
- Use a context manager for large `try/except` blocks like in [transcription](../../src/transcription/services/transcription.py)
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] Uses `ServiceBase` for common logic
|
|
||||||
- [ ] CRUD methods created at the top
|
|
||||||
- [ ] Session kwarg for `AsyncSession` to pass in a session object to each method
|
|
||||||
- [ ] Services use `self._session_scope` in their methods to pass the session thru.
|
|
||||||
- Multiple operations on the same object(s) require sharing a session between all the methods used.
|
|
||||||
|
|
||||||
## CRUD Methods
|
|
||||||
|
|
||||||
- Create, read, update, and delete, created in that order
|
|
||||||
- Name format `<operation>_<model >`, for example `create_document` or `update_job`
|
|
||||||
- All services must define these 4 methods first, and in that order
|
|
||||||
|
|
||||||
## Transaction Finalization
|
|
||||||
|
|
||||||
When a service method accepts an optional `session` kwarg, write methods must use `self._finalize` to finalize the transaction properly according to whether or not they are sharing a session.
|
|
||||||
|
|
||||||
- If `session` is `None`: the method owns the transaction and should `commit()`.
|
|
||||||
- If `session` is provided: the method must **not** commit; it should `flush()` so IDs and FK values are available to the caller's transaction.
|
|
||||||
- Use `refresh()` on returned ORM objects when the caller needs DB-populated values (defaults, triggers, merged state).
|
|
||||||
|
|
||||||
Recommended helper behavior:
|
|
||||||
|
|
||||||
- Inputs: active session object, original `session` arg (or a boolean ownership flag), and an optional list of objects to refresh.
|
|
||||||
- Logic: `commit` when service-owned session, `flush` when caller-owned session, then refresh requested objects.
|
|
||||||
|
|
||||||
This keeps orchestration functions atomic: they can pass one shared session across multiple services and commit exactly once at the workflow boundary.
|
|
||||||
|
|
||||||
## Workflow Transaction Boundaries
|
|
||||||
|
|
||||||
For multi-step job lifecycles (for example queued transcription jobs), orchestration functions must use explicit transaction phases.
|
|
||||||
|
|
||||||
Required boundary model:
|
|
||||||
|
|
||||||
- **Transaction A (claim):** transition `JobStatus.QUEUED -> JobStatus.PROCESSING` and commit immediately.
|
|
||||||
- Perform provider/network work **outside** database transactions.
|
|
||||||
- **Transaction B (terminal success):** write transcript content and set `JobStatus.TRANSCRIBED` in the same shared-session commit.
|
|
||||||
- **Transaction B (terminal failure):** write transcript error detail and set `JobStatus.FAILED` in the same shared-session commit.
|
|
||||||
- **Transaction C (retry path):** write transcript error detail, increment retry count, and set `JobStatus.QUEUED` in one shared-session commit.
|
|
||||||
|
|
||||||
Atomicity rules:
|
|
||||||
|
|
||||||
- Never commit transcript updates separately from the paired terminal/retry job status change.
|
|
||||||
- Terminal state (`TRANSCRIBED` or `FAILED`) and transcript row changes must succeed or roll back together.
|
|
||||||
- Retry persistence (`QUEUED` + retry increment + error detail) must succeed or roll back together.
|
|
||||||
|
|
||||||
Separation of concerns:
|
|
||||||
|
|
||||||
- Worker modules should stay lightweight and delegate lifecycle transitions to service/workflow orchestration functions.
|
|
||||||
- In `workflows.py`, `process_queued_job` should own one complete attempt lifecycle: `QUEUED -> PROCESSING -> TRANSCRIBED|FAILED`.
|
|
||||||
- In `workflows.py`, `advance_job` should coordinate broader status progression around attempts (for example retry scheduling from `FAILED -> QUEUED`).
|
|
||||||
- Services should expose session-aware write helpers (flush on caller-owned session) so orchestration controls commit boundaries.
|
|
||||||
- Backoff/sleep behavior must run outside transactional scopes.
|
|
||||||
|
|
||||||
# Service Composition
|
|
||||||
|
|
||||||
Some operations, like uploading a picutre, require modifications to multiple tables, which can be done by composing methods from the service object into a separate function.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Use when modifying the NiceGUI application under src/transcription/ui. Defines ownership and dependency boundaries for UI registration, pages, components, services, persistence, state, and static assets."
|
|
||||||
applyTo: 'src/transcription/ui/**/*.py'
|
|
||||||
---
|
|
||||||
|
|
||||||
# UI Conceptual Boundaries
|
|
||||||
|
|
||||||
Keep dependencies flowing in this direction:
|
|
||||||
|
|
||||||
`ui/__init__.py` -> `pages` -> `components`
|
|
||||||
|
|
||||||
Pages may depend on application services and framework-provided dependencies. Components may depend on smaller components and shared presentation helpers. Services and domain modules must never depend on the UI.
|
|
||||||
|
|
||||||
## Package Root
|
|
||||||
|
|
||||||
- Keep `ui/__init__.py` as the UI composition root: register global assets, register pages, and mount NiceGUI on FastAPI.
|
|
||||||
- Do not put feature rendering, service calls, persistence, or route-specific state in the package root.
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
- Pages own route registration and route-level orchestration.
|
|
||||||
- Resolve request or application dependencies, call [services](../../src/transcription/services/), adapt returned data for presentation when needed, and coordinate refresh, navigation, and notifications here.
|
|
||||||
- Do not query, mutate, commit, or roll back the database from a page. Do not import database engines, sessions, operations, or query-building APIs. Persistence belongs to services or workflow functions.
|
|
||||||
- Framework dependency types may cross into page handlers only to construct or invoke services; do not pass sessions or session factories into components.
|
|
||||||
- Keep business rules, lifecycle transitions, transaction boundaries, and cross-service workflows out of page callbacks.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
- Components own reusable rendering, widget-local state, input normalization, and presentation-only formatting.
|
|
||||||
- Expose user actions through typed callback parameters. The calling page decides which service or workflow runs and what refresh or navigation follows.
|
|
||||||
- Do not register routes, resolve request/app state, instantiate services, or access persistence from components.
|
|
||||||
- Components may accept ORM models returned by services as read-only snapshots. Only use fields and relationships that the service loaded eagerly; never mutate models, trigger lazy loading, or expose session behavior.
|
|
||||||
- A component may compose lower-level components, but it must not import from `pages`.
|
|
||||||
|
|
||||||
## Shared UI Infrastructure
|
|
||||||
|
|
||||||
- Keep app-wide navigation and layout primitives in `components/app_shell.py`.
|
|
||||||
- Keep generic table/event adaptation in `components/table/common.py`; feature-specific columns, row read models, and formatting belong in the feature table module.
|
|
||||||
- Keep exception normalization and user-facing error display in `components/error_presenter.py`; preserve `AppError` details and operation identifiers at page/component boundaries.
|
|
||||||
|
|
||||||
## CSS Assets
|
|
||||||
|
|
||||||
- Keep CSS under `ui/static` and split it into manageable, feature-oriented files. Do not grow a monolithic stylesheet or embed substantial style blocks in Python components.
|
|
||||||
- Load each stylesheet from the page, component, or composition root that needs it with `ui.add_css(...)`. Use shared registration only for genuinely application-wide styles.
|
|
||||||
- Read stylesheet text through `importlib.resources.files(...)` so loading works from installed packages and is independent of the working directory.
|
|
||||||
- Centralize CSS reading in one typed helper cached by relative resource path with `functools.cache` or an equivalent unbounded `lru_cache`. Cache the immutable stylesheet text to avoid repeated resource I/O during component renders; keep NiceGUI registration decisions at the caller.
|
|
||||||
- Do not encode application behavior in CSS or other static assets.
|
|
||||||
|
|
||||||
## State and Side Effects
|
|
||||||
|
|
||||||
- Limit component state to ephemeral interaction state such as loading flags, form values, dialogs, and expansion state.
|
|
||||||
- Application and worker state must be resolved at the page or application boundary and passed through narrow interfaces such as callbacks or notifier protocols.
|
|
||||||
- Keep filesystem, network, provider, and worker orchestration behind application services or dedicated adapters. UI code may trigger those operations but must not implement them.
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
# Python-generated files
|
|
||||||
__pycache__/
|
|
||||||
*.py[oc]
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
wheels/
|
|
||||||
*.egg-info
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
.venv
|
|
||||||
|
|
||||||
# Environment secrets
|
|
||||||
.env
|
|
||||||
|
|
||||||
# SQLite database
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# Document images
|
|
||||||
uploads/*
|
|
||||||
data/*
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
3.12
|
|
||||||
Vendored
-23
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Python: Debug transcription app",
|
|
||||||
"type": "debugpy",
|
|
||||||
"request": "launch",
|
|
||||||
"module": "debugpy",
|
|
||||||
"args": [
|
|
||||||
"-m",
|
|
||||||
"transcription",
|
|
||||||
"--host", "127.0.0.1",
|
|
||||||
"--port", "9999",
|
|
||||||
"--database.driver", "sqlite"
|
|
||||||
],
|
|
||||||
"justMyCode": true,
|
|
||||||
"console": "integratedTerminal",
|
|
||||||
"env": {
|
|
||||||
"PYTHONPATH": "${workspaceFolder}/src"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-47
@@ -1,47 +0,0 @@
|
|||||||
FROM python:3.12-slim AS builder
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
UV_LINK_MODE=copy
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.5.24 /uv /uvx /bin/
|
|
||||||
|
|
||||||
COPY pyproject.toml uv.lock README.md ./
|
|
||||||
RUN uv sync --frozen --no-dev --no-install-project
|
|
||||||
|
|
||||||
COPY src ./src
|
|
||||||
COPY prompts ./prompts
|
|
||||||
RUN uv sync --frozen --no-dev
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.12-slim AS runtime
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
PATH="/app/.venv/bin:$PATH" \
|
|
||||||
PYTHONPATH="/app/src" \
|
|
||||||
UPLOAD_DIR="/app/uploads" \
|
|
||||||
PROMPT_DIR="/app/prompts"
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 appgroup \
|
|
||||||
&& useradd --system --uid 1001 --gid appgroup --create-home appuser
|
|
||||||
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
|
||||||
COPY --from=builder /app/src /app/src
|
|
||||||
COPY --from=builder /app/prompts /app/prompts
|
|
||||||
|
|
||||||
RUN mkdir -p /app/uploads /app/data \
|
|
||||||
&& chown -R appuser:appgroup /app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=3s --retries=3 \
|
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')"
|
|
||||||
|
|
||||||
CMD ["uvicorn", "transcription.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
python-template
|
||||||
|
Copyright (C) 2026 john
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
python-template Copyright (C) 2026 john
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -1,144 +1 @@
|
|||||||
# Transcription
|
# Python Template
|
||||||
|
|
||||||
Historical document transcription system for family-history documents.
|
|
||||||
|
|
||||||
The app lets you upload a document image/PDF, queues a background transcription job, and then shows job status and results in a web UI.
|
|
||||||
|
|
||||||
## What the app does
|
|
||||||
|
|
||||||
- Upload document files (`.jpg`, `.jpeg`, `.png`, `.tif`, `.tiff`, `.pdf`)
|
|
||||||
- Persist document + job records in SQLite
|
|
||||||
- Process jobs in a background worker (`queued -> processing -> transcribed/failed`)
|
|
||||||
- Store transcript text (or failure detail)
|
|
||||||
- Show status and results in the NiceGUI interface
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
### 1) Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) Configure environment
|
|
||||||
|
|
||||||
Create a `.env` file in the project root with the required OpenRouter API key:
|
|
||||||
|
|
||||||
```env
|
|
||||||
OPENROUTER_API_KEY=your_openrouter_api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
Settings are read from CLI arguments first, then environment variables, then `.env`, then the defaults below.
|
|
||||||
|
|
||||||
### Configuration Source Precedence
|
|
||||||
|
|
||||||
When the same setting is provided in multiple places, the value is chosen in this order (highest priority first):
|
|
||||||
|
|
||||||
1. CLI arguments (for example `--port 9999`)
|
|
||||||
2. Settings constructor arguments (used mainly in tests)
|
|
||||||
3. Environment variables
|
|
||||||
4. `.env` file values
|
|
||||||
5. Model defaults in code
|
|
||||||
|
|
||||||
Practical examples:
|
|
||||||
|
|
||||||
- `--port 9999` overrides both `PORT=8000` in the shell and `PORT=7000` in `.env`.
|
|
||||||
- `DATABASE__PATH=prod.db` in the shell overrides `DATABASE__PATH=dev.db` in `.env`.
|
|
||||||
|
|
||||||
#### Server and runtime
|
|
||||||
|
|
||||||
| Environment variable | Default | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `HOST` | `0.0.0.0` | Address on which the server listens. |
|
|
||||||
| `PORT` | `8000` | Server port. |
|
|
||||||
| `LOG_LEVEL` | `info` | Uvicorn and application log level. |
|
|
||||||
| `RELOAD` | `false` | Restart the development server when source files change. |
|
|
||||||
| `ENVIRONMENT` | `development` | Runtime environment: `development`, `test`, or `production`. |
|
|
||||||
|
|
||||||
#### Provider
|
|
||||||
|
|
||||||
| Environment variable | Default | Description |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `PROVIDER` | `openrouter` | Transcription provider. |
|
|
||||||
| `OPENROUTER_API_KEY` | Required | OpenRouter API key. |
|
|
||||||
| `PROVIDER_MODEL` | Provider default | Optional model override. |
|
|
||||||
| `OPENROUTER_HTTP_REFERER` | Unset | Optional OpenRouter attribution URL. |
|
|
||||||
| `OPENROUTER_APP_TITLE` | Unset | Optional OpenRouter attribution title. |
|
|
||||||
|
|
||||||
#### Database and files
|
|
||||||
|
|
||||||
Use nested env vars for database settings (recommended):
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE__DRIVER=sqlite
|
|
||||||
DATABASE__PATH=app.db
|
|
||||||
# BOOTSTRAP_SCHEMA_ON_STARTUP=true
|
|
||||||
SQLITE_CHECK_SAME_THREAD=false
|
|
||||||
UPLOAD_DIR=./uploads
|
|
||||||
PROMPT_DIR=./prompts
|
|
||||||
```
|
|
||||||
|
|
||||||
For PostgreSQL:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE__DRIVER=postgres
|
|
||||||
DATABASE__HOST=localhost
|
|
||||||
DATABASE__PORT=5432
|
|
||||||
DATABASE__DATABASE=transcription
|
|
||||||
DATABASE__USER=postgres
|
|
||||||
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.
|
|
||||||
|
|
||||||
`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`.
|
|
||||||
|
|
||||||
#### Worker
|
|
||||||
|
|
||||||
```env
|
|
||||||
WORKER_MAX_RETRIES=0
|
|
||||||
WORKER_RETRY_BACKOFF_SECONDS=0
|
|
||||||
WORKER_PROVIDER_TIMEOUT_SECONDS=20
|
|
||||||
WORKER_MIN_TRANSCRIPTION_CHARS=0
|
|
||||||
WORKER_MIN_TRANSCRIPTION_LINES=0
|
|
||||||
WORKER_FAIL_ON_FINISH_REASON_LENGTH=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3) Run the app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run python -m transcription --port 9999 --reload --database.driver sqlite --bootstrap-schema-on-startup
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the development server with SQLite, creates missing tables, and enables automatic reload. Run `uv run python -m transcription --help` for all CLI options; CLI names use kebab case and nested database options use dot notation, such as `--database.path ./data/transcription.db`.
|
|
||||||
|
|
||||||
### 4) Open in browser
|
|
||||||
|
|
||||||
- GUI: [http://localhost:9999/ui](http://localhost:9999/ui)
|
|
||||||
- Health check: [http://localhost:9999/healthz](http://localhost:9999/healthz)
|
|
||||||
|
|
||||||
Replace `localhost` with the server's hostname or IP address when connecting from another machine.
|
|
||||||
|
|
||||||
## How to navigate the GUI
|
|
||||||
|
|
||||||
- **Upload page** (`/ui`)
|
|
||||||
- Select a supported file to upload.
|
|
||||||
- The app creates a queued transcription job.
|
|
||||||
- Use the **View jobs** link to inspect progress.
|
|
||||||
|
|
||||||
- **Jobs page** (`/ui/jobs`)
|
|
||||||
- See all jobs and their status.
|
|
||||||
- Use **Refresh** to reload current states.
|
|
||||||
- Open a specific job to see details.
|
|
||||||
|
|
||||||
- **Job detail page** (`/ui/jobs/{job_id}`)
|
|
||||||
- Shows job metadata and status.
|
|
||||||
- Displays transcript text when successful.
|
|
||||||
- Displays failure detail when transcription fails.
|
|
||||||
|
|
||||||
## Prompt artifacts
|
|
||||||
|
|
||||||
Prompt files are stored in `prompts/` and loaded from `PROMPT_DIR` (default: `./prompts`).
|
|
||||||
|
|
||||||
The canonical MVP prompt is:
|
|
||||||
- `prompts/transcribe_document.md`
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
services:
|
|
||||||
transcription:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: transcription-app
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: sqlite:////app/data/transcription.db
|
|
||||||
UPLOAD_DIR: /app/uploads
|
|
||||||
PROMPT_DIR: /app/prompts
|
|
||||||
ports:
|
|
||||||
- "8002:8000"
|
|
||||||
volumes:
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
- transcription_data:/app/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
transcription_data:
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# System Architecture (Version 2)
|
|
||||||
|
|
||||||
This document describes the V2 production architecture of the personal historical-document transcription system.
|
|
||||||
|
|
||||||
## Architecture Objectives
|
|
||||||
|
|
||||||
* Preserve source material as immutable transcribed text alongside page-level spatial AI metadata.
|
|
||||||
* Support batching multi-image and folder uploads cleanly into sequential pages (`page_number`).
|
|
||||||
* Leverage asynchronous worker pools (`asyncio`) for parallel single-image API execution bounded by rate limiters (`asyncio.Semaphore`).
|
|
||||||
* Migrate persistence to PostgreSQL using native `UUID`, `TIMESTAMPTZ`, and `JSONB` document storage.
|
|
||||||
* Standardize all data validation, API parsing, and database models on **Pydantic V2**.
|
|
||||||
* Support rich historical attribution (multi-author and multi-recipient relationships).
|
|
||||||
|
|
||||||
## Runtime Topology
|
|
||||||
|
|
||||||
The V2 runtime operates as an asynchronous Python application:
|
|
||||||
|
|
||||||
* FastAPI + NiceGUI web application process.
|
|
||||||
* In-process `asyncio` background task orchestrator for parallel API execution.
|
|
||||||
* Relational persistence via PostgreSQL (using `asyncpg` or `psycopg3`).
|
|
||||||
* Pydantic V2 validation layer wrapping API payloads and PostgreSQL `JSONB` schemas.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
U[Browser User] --> A[FastAPI + NiceGUI App]
|
|
||||||
A --> W[Asyncio Worker Engine]
|
|
||||||
A --> DB[(PostgreSQL Database)]
|
|
||||||
W --> P[Vision Provider APIs\nOpenAI / Claude]
|
|
||||||
W --> DB
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lifecycle Ownership
|
|
||||||
|
|
||||||
Application lifespan owns runtime setup/teardown:
|
|
||||||
|
|
||||||
* Initialize environment logging and Pydantic configuration.
|
|
||||||
* Manage asynchronous PostgreSQL connection pools (`asyncpg` / `psycopg3`).
|
|
||||||
* Execute database migrations and index initialization.
|
|
||||||
* Recover stale processing jobs on startup.
|
|
||||||
* Manage graceful shutdown of active `asyncio` worker pools.
|
|
||||||
|
|
||||||
## Layered Module Structure
|
|
||||||
|
|
||||||
### Interface Layer
|
|
||||||
|
|
||||||
* `src/transcription/ui/**` (NiceGUI pages, multi-page renderers, person cards)
|
|
||||||
* `src/transcription/api/**` (FastAPI routes and JSON error handlers)
|
|
||||||
|
|
||||||
### Application & Async Worker Layer
|
|
||||||
|
|
||||||
* `src/transcription/services/workflows.py`
|
|
||||||
* `src/transcription/worker.py`
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
* Batch orchestration and status transitions (`queued` -> `processing` -> `completed` | `partial_success` | `failed`).
|
|
||||||
* Parallel single-image API execution using `asyncio.gather` bounded by `asyncio.Semaphore`.
|
|
||||||
* Pydantic schema parsing (`PageAIMetadata`) and validation prior to database storage.
|
|
||||||
|
|
||||||
### Domain & Service Layer
|
|
||||||
|
|
||||||
* `src/transcription/db/models.py` (SQLModel/Pydantic V2 schema definitions for the current implementation)
|
|
||||||
* `src/transcription/services/*.py` (Transactional operations for `Document`, `Person`, `Source`, `Job`, and `JobSource`)
|
|
||||||
|
|
||||||
### Infrastructure Layer
|
|
||||||
|
|
||||||
* `src/transcription/db/**` (PostgreSQL connection pooling and raw parameterized SQL execution)
|
|
||||||
* `src/transcription/providers/**` (OpenAI & Anthropic Vision SDK adapters)
|
|
||||||
|
|
||||||
## Processing Workflow
|
|
||||||
|
|
||||||
1. User uploads a folder or batch of images for a `Document`.
|
|
||||||
2. System creates `Document`, `Job(status='queued')`, and ordered `Source` pages (`page_number = 1..N`).
|
|
||||||
3. Worker claims job, sets `Job.status = 'processing'`, and spawns parallel `asyncio` tasks bounded by semaphore.
|
|
||||||
4. Each task calls Vision API for a **single** `Source` image.
|
|
||||||
5. On task completion:
|
|
||||||
* Writes a `JobSource` record containing `status='transcribed'`, `raw_transcription`, `ai_metadata` (bounding boxes/confidence), and `raw_api_response`.
|
|
||||||
* Caches active text to `Source.raw_transcription`.
|
|
||||||
|
|
||||||
|
|
||||||
6. On page failure:
|
|
||||||
* Writes `JobSource` record with `status='failed'` and `error_detail`.
|
|
||||||
|
|
||||||
|
|
||||||
7. Once all page tasks resolve:
|
|
||||||
* Marks `Job.status` as `completed` (100% success), `partial_success` (at least 1 success, 1 failure), or `failed` (all failed).
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Domain Ownership & Invariants
|
|
||||||
|
|
||||||
* **Immutable AI Outputs:** `source.raw_transcription` and `job_source.raw_transcription` store original, point-in-time machine output and are immutable.
|
|
||||||
* **Inlined Revisions:** Human corrections occur on `source.revised_text`. UI renders `COALESCE(revised_text, raw_transcription)`.
|
|
||||||
* **Sequential Integrity:** Multi-page documents are strictly ordered by `source.page_number ASC`.
|
|
||||||
* **Page Execution Isolation:** A failure on one page image does not invalidate successful transcriptions on sister pages in the same batch job.
|
|
||||||
|
|
||||||
## Data Model Summary
|
|
||||||
|
|
||||||
* `Document` has many `Source` pages, many `Job` runs, and many `Person` records via `DocumentPerson` junction (`author` or `recipient`).
|
|
||||||
* `Source` belongs to one `Document` and can be processed across many `JobSource` executions.
|
|
||||||
* `Job` has many `JobSource` execution records.
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
* Unit tests for Pydantic V2 schemas, custom validators, and JSONB serialization.
|
|
||||||
* Integration tests for async PostgreSQL connection handling and parameterized queries.
|
|
||||||
* Async workflow tests using mock AI providers to verify `partial_success` and retry logic.
|
|
||||||
* UI integration tests for multi-page rendering and person management.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technology References
|
|
||||||
|
|
||||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
|
||||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
|
||||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
|
||||||
- [Python asyncio](https://docs.python.org/3/library/asyncio.html#module-asyncio)
|
|
||||||
- [Pydantic Validation](https://pydantic.dev/docs/validation/latest/get-started/)
|
|
||||||
- [Pydantic AI](https://pydantic.dev/docs/ai/overview/)
|
|
||||||
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v2.md)
|
|
||||||
- [System Design Intent](invariant/intent.md)
|
|
||||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
|
||||||
- System Architecture (this document)
|
|
||||||
- [System Requirements](requirements_v2.md)
|
|
||||||
- [Data model](schema_v2.md)
|
|
||||||
- [Error Handling Policy](error_handling_v2.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v2.md)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# Error Handling Policy (Version 2)
|
|
||||||
|
|
||||||
This document defines the canonical error-handling policy for the V2 document transcription system.
|
|
||||||
|
|
||||||
## Error Handling Objectives
|
|
||||||
|
|
||||||
* Make failures visible in clear, actionable language at both the document and individual page levels.
|
|
||||||
* Support **isolated failure handling** in multi-image batches so single page errors do not crash an entire batch job.
|
|
||||||
* Preserve diagnostic detail (Pydantic validation errors, raw provider responses) in PostgreSQL `JSONB` for fast troubleshooting.
|
|
||||||
* Ensure consistent error envelope structure across API, UI, and async worker boundaries.
|
|
||||||
|
|
||||||
## Scope And Authority
|
|
||||||
|
|
||||||
Governs error behavior across NiceGUI pages, FastAPI routes, service orchestration, `asyncio` background tasks, PostgreSQL interactions, and AI provider adapters.
|
|
||||||
|
|
||||||
## Error Taxonomy
|
|
||||||
|
|
||||||
| Category | Definition | Retriable |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `validation_error` | Pydantic payload or parameter schema validation failure | no |
|
|
||||||
| `user_input_error` | Unacceptable user file (unsupported image type, corrupt file) | no |
|
|
||||||
| `not_found_error` | Requested resource (`Document`, `Source`, `Person`, `Job`) missing | no |
|
|
||||||
| `conflict_error` | Operation violates state constraints (e.g., duplicate `document_person` role) | no |
|
|
||||||
| `external_provider_error` | AI Provider API failure (rate limit, vision execution error) | yes |
|
|
||||||
| `infrastructure_transient_error` | Temporary DB connection reset or HTTP timeout | yes |
|
|
||||||
| `infrastructure_persistent_error` | Database down, missing API credentials, misconfiguration | no |
|
|
||||||
| `internal_unexpected_error` | Uncaught Python exception or logic defect | no |
|
|
||||||
|
|
||||||
## Async Batch & Page-Level Error Behavior
|
|
||||||
|
|
||||||
In multi-image `asyncio` batch processing:
|
|
||||||
|
|
||||||
1. **Page Isolation:** Exceptions caught during individual page calls are caught within the `asyncio` task wrapper.
|
|
||||||
2. **Page Record Logging:** Page failure detail is written directly to `job_source.error_detail` and `job_source.status = 'failed'`.
|
|
||||||
3. **Batch Aggregate State:**
|
|
||||||
* If **all** page tasks succeed -> `job.status = 'completed'`.
|
|
||||||
* If **some** page tasks fail -> `job.status = 'partial_success'`.
|
|
||||||
* If **all** page tasks fail -> `job.status = 'failed'`.
|
|
||||||
|
|
||||||
|
|
||||||
4. **Retry Strategy:** The UI exposes a "Retry Failed Pages" option for `partial_success` jobs, which spawns a new targeted `Job` containing *only* the `Source` IDs marked as `failed`.
|
|
||||||
|
|
||||||
## API Error Response Contract
|
|
||||||
|
|
||||||
API error responses return a structured JSON envelope:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error_id": "err_uuid_12345",
|
|
||||||
"category": "validation_error",
|
|
||||||
"message": "The uploaded payload failed schema validation.",
|
|
||||||
"suggestion": "Check file format and metadata fields, then try again.",
|
|
||||||
"details": {
|
|
||||||
"pydantic_errors": [...]
|
|
||||||
},
|
|
||||||
"timestamp": "2026-07-31T07:55:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
HTTP Status Mappings:
|
|
||||||
|
|
||||||
* `validation_error`, `user_input_error` -> `400`
|
|
||||||
* `not_found_error` -> `404`
|
|
||||||
* `conflict_error` -> `409`
|
|
||||||
* `external_provider_error` -> `502` / `503`
|
|
||||||
* `infrastructure_transient_error` -> `503`
|
|
||||||
* `infrastructure_persistent_error`, `internal_unexpected_error` -> `500`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technology References
|
|
||||||
|
|
||||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
|
||||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
|
||||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
|
||||||
- [Python asyncio](https://docs.python.org/3/library/asyncio.html#module-asyncio)
|
|
||||||
- [Pydantic Validation](https://pydantic.dev/docs/validation/latest/get-started/)
|
|
||||||
- [Pydantic AI](https://pydantic.dev/docs/ai/overview/)
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v2.md)
|
|
||||||
- [System Design Intent](invariant/intent.md)
|
|
||||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v2.md)
|
|
||||||
- [System Requirements](requirements_v2.md)
|
|
||||||
- [Data model](schema_v2.md)
|
|
||||||
- Error Handling Policy (this document)
|
|
||||||
- [Implementation Plan](implementation_plan_v2.md)
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# implementation_plan_v2
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Replace the current V1 SQLModel schema with the approved V2 schema and make sure every database operation works through the existing async SQLAlchemy/SQLModel session layer.
|
|
||||||
|
|
||||||
Use a fresh database. There will be no migrations, data conversion, legacy compatibility shims, or parallel V1/V2 code paths.
|
|
||||||
|
|
||||||
## Current Project Impact
|
|
||||||
|
|
||||||
- `src/transcription/db/models.py` still defines the V1 `Document`, `Source`, `Job`, and `Revision` tables.
|
|
||||||
- The V2 target adds `Person`, `DocumentPerson`, and `JobSource`, moves revisions onto `Source`, and removes the direct `Source.job_id` relationship.
|
|
||||||
- The engine, session factory, transaction handling, and PostgreSQL async support already exist and do not need to be rewritten.
|
|
||||||
- Async CRUD currently lives in `DocumentService`, `JobService`, `TranscriptionService`, and the upload record helper. Their queries and eager-loading options depend on V1 relationships.
|
|
||||||
- Existing tests cover only part of the schema and CRUD surface.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
### 1. Update the schema
|
|
||||||
|
|
||||||
- Replace the models in `src/transcription/db/models.py` with the approved V2 tables, enums, relationships, foreign keys, constraints, and indexes.
|
|
||||||
- Remove `Revision`, `Source.job_id`, and the transcription fields that no longer belong on `Job`.
|
|
||||||
- Keep `create_all()` as the schema bootstrap for a fresh database.
|
|
||||||
- Delete `_ensure_sqlite_compat_columns()` and all schema patching from `src/transcription/db/operations.py`.
|
|
||||||
- Keep the Python models and `docs/schema_v2.md` consistent.
|
|
||||||
|
|
||||||
### 2. Align the async CRUD methods
|
|
||||||
|
|
||||||
- Keep the existing `ServiceBase` session and transaction pattern.
|
|
||||||
- Update document CRUD to load and manage its ordered `Source` rows and `DocumentPerson` links.
|
|
||||||
- Update job CRUD and queue queries to use `JobSource` instead of `Source.job_id`.
|
|
||||||
- Add the missing async CRUD operations for `Person`, `Source`, `DocumentPerson`, and `JobSource` using the existing service style. Do not add another repository abstraction.
|
|
||||||
- Replace revision CRUD with direct updates to `Source.revised_text` and `Source.date_revised`.
|
|
||||||
- Remove the temporary transcript compatibility aliases instead of redirecting them.
|
|
||||||
- Update only direct database call sites that construct or query these records; UI and worker feature changes are not part of this work.
|
|
||||||
|
|
||||||
### 3. Verify the schema and CRUD
|
|
||||||
|
|
||||||
- Update the schema bootstrap test to expect `person`, `document`, `document_person`, `source`, `job`, and `job_source`, with no `revision` table.
|
|
||||||
- Add async create, read, update, delete, list, and filtered-query tests for each entity that exposes those operations.
|
|
||||||
- Test relationship loading, page ordering, uniqueness constraints, delete behavior, status values, and `JobSource` JSON fields.
|
|
||||||
- Test both service-owned sessions and caller-provided sessions so flush/commit behavior remains correct.
|
|
||||||
- Run the focused database and service tests, then the full suite with `uv run pytest`.
|
|
||||||
|
|
||||||
### 4. Update the UI for the V2 schema
|
|
||||||
|
|
||||||
- Review the UI components and views that display document, job, person, and source data so they reference the V2 schema instead of V1 relationships.
|
|
||||||
- Update upload, detail, and listing screens to show the new person and source associations, revised-source fields, and the revised status values.
|
|
||||||
- Keep the UI behavior aligned with the updated service layer and ensure the existing UI tests continue to pass with the V2 data model.
|
|
||||||
- Consider the guidance in `docs/ui_style_guide.md` when making UI changes so the updated views remain consistent with the project’s visual and interaction conventions.
|
|
||||||
|
|
||||||
## Done When
|
|
||||||
|
|
||||||
- A fresh database is created directly from the V2 SQLModel metadata.
|
|
||||||
- All async CRUD methods pass against the V2 relationships and fields.
|
|
||||||
- No code references `Revision`, `Source.job_id`, removed `Job` transcription fields, or compatibility aliases.
|
|
||||||
- The focused tests and full test suite pass.
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Database migrations or preservation of V1 data
|
|
||||||
- Legacy compatibility code
|
|
||||||
- Database engine or session-layer rewrites
|
|
||||||
- UI redesign, batch orchestration, worker concurrency, deployment, and operational runbooks
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Document Transcription System Overview (Version 2)
|
|
||||||
|
|
||||||
This project is a personal-scale application for transcribing, indexing, and preserving historical family documents, letters, postcards, and journals.
|
|
||||||
|
|
||||||
## Start Here
|
|
||||||
|
|
||||||
Read [architecture_v2.md](architecture_v2.md) first for technical overview and system design.
|
|
||||||
|
|
||||||
## Core V2 Capabilities
|
|
||||||
|
|
||||||
* **Folder & Multi-Image Ingestion:** Upload whole folders or image batches that map sequentially (`page_number`) under a single `Document`.
|
|
||||||
* **Parallel Async AI Vision Engine:** Concurrently process single-page image transcriptions using Python `asyncio` bounded by rate limiters.
|
|
||||||
* **Robust PostgreSQL Storage:** Relational storage for entities with native `UUID`, `TIMESTAMPTZ`, and `JSONB` for deep AI spatial metadata and raw envelopes.
|
|
||||||
* **Pydantic V2 Validation:** End-to-end type safety, DB row mapping, and JSONB payload validation.
|
|
||||||
* **Historical Person Management:** Track authors and recipients across documents with rich biographical entities (`Person`).
|
|
||||||
* **Page-Level Execution Auditing & Revisions:** Store immutable point-in-time machine output per run while enabling inline human corrections (`revised_text`).
|
|
||||||
* **Partial Failure Recovery:** Bounded batch execution that isolates single-page API errors (`partial_success`) for simple retries.
|
|
||||||
|
|
||||||
## Technical Stack
|
|
||||||
|
|
||||||
* **Application Web Framework:** FastAPI + NiceGUI
|
|
||||||
* **Persistence Engine:** PostgreSQL 18+
|
|
||||||
* **Data Validation & Schemas:** Pydantic V2
|
|
||||||
* **Concurrency & Workers:** Python `asyncio` worker pool with `asyncio.Semaphore`
|
|
||||||
* **Vision Providers:** OpenAI (GPT-4o) and Anthropic (Claude 3.5 Sonnet) via native SDKs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technology References
|
|
||||||
|
|
||||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
|
||||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
|
||||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
|
||||||
- [Python asyncio](https://docs.python.org/3/library/asyncio.html#module-asyncio)
|
|
||||||
- [Pydantic Validation](https://pydantic.dev/docs/validation/latest/get-started/)
|
|
||||||
- [Pydantic AI](https://pydantic.dev/docs/ai/overview/)
|
|
||||||
|
|
||||||
## Documentation Index
|
|
||||||
|
|
||||||
- System Overview (this document)
|
|
||||||
- [System Design Intent](invariant/intent.md)
|
|
||||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v2.md)
|
|
||||||
- [System Requirements](requirements_v2.md)
|
|
||||||
- [Data model](schema_v2.md)
|
|
||||||
- [Error Handling Policy](error_handling_v2.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v2.md)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Historical Document Transcription Design Intent
|
|
||||||
I have several thousand pages of family history told through letters, postcards, books, and other documents that I want to transcribe to text.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
1. Preserve our family history
|
|
||||||
2. Unburden my family (and descendants) from having to store and care for the physical media. Once the documents have been transcribed and organized, they can be donated (or kept by a family member that wants to retain and preserve them).
|
|
||||||
3. Make the document text easily available and easily searchable.
|
|
||||||
4. Ability create timelines for individuals and/or families through document dates or the data contained in them. Perhaps even use AI to generate biographies or family histories.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Source material
|
|
||||||
1. **letters, cards, diaries** - handwritten; mostly stored in boxes and tubs with little organization
|
|
||||||
2. **books** - typed or typeset; mostly self-published books 50-100 pages in length. This may be expanded to include selected pages from other publications.
|
|
||||||
3. **photos** - notes written on the backs of photos and the pages of photo albums
|
|
||||||
4. **other ephemera** - newspaper clippings, event programs, invitations, military records, immigration records, etc
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Methodology
|
|
||||||
|
|
||||||
1. Follow current best practices per **A Guide to Documentary Editing** by Mary-Jo Kline. (See [Transcription Methodology](transcription_methodology.md))
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Transcription Methodology & Style Guide
|
|
||||||
|
|
||||||
## 1. Overview & Core Philosophy
|
|
||||||
|
|
||||||
This document defines the formal transcription standard for processing historical manuscripts, letters, diaries, and printed ephemera.
|
|
||||||
|
|
||||||
Following the principles established by Mary-Jo Kline in A Guide to Documentary Editing, this project adheres to a Strict Literal Transcription (Verbatim) model as its foundational layer. The primary goal is total textual fidelity—capturing what the author wrote, not what they intended to write—while ensuring the output remains machine-readable and indexable for downstream digital query and search systems.
|
|
||||||
|
|
||||||
## 2. Textual Policy
|
|
||||||
|
|
||||||
Transcribers (human or AI) must record the exact text of the source document without silent corrections, modernizations, or stylistic smoothing except where explicitly instructed in this guide.
|
|
||||||
|
|
||||||
* **Substantives:** Words, letter forms, structural layout, and semantic content must be recorded strictly as presented in the original document.
|
|
||||||
|
|
||||||
* **Accidentals:** Punctuation, capitalization, misspellings, and archaic character representations must be preserved unless an explicit rule below allows for standardization.
|
|
||||||
|
|
||||||
## 3. Standard Transcription Rules & Markup
|
|
||||||
|
|
||||||
The following rules map directly to editorial conventions for handling common manuscript anomalies and physical document features.
|
|
||||||
|
|
||||||
### 3.1 Textual Anomalies & Corrections
|
|
||||||
|
|
||||||
| Document Feature | Rule | Standard Markup Format | Output Example |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| **Misspellings & Errors** | Retain original spelling verbatim. Insert an italicized [sic] immediately following the error. Do not correct spelling silently. | [sic] | The weather was very cold and publick [sic] business delayed. |
|
|
||||||
| **Missing Words / Omissions** | Insert necessary words required to restore basic grammatical sense inside square brackets. | [word] | We went [to] the store to buy supplies. |
|
|
||||||
| **Uncertain / Conjectural** | Place best hypothesis followed by a question mark inside square brackets when handwriting is doubtful. | [word?] | He went to [Boston?] yesterday to meet the governor. |
|
|
||||||
| **Completely Illegible** | Use [illegible] for unreadable script. Use explicit damage descriptors when physical impairment prevents reading. | [illegible] or [reason] | The total cost was [illegible] dollars. or The letter ends here [remainder of page torn]. |
|
|
||||||
| **Canceled / Struck-through** | Wrap text removed by the author inside a [deleted: ...] tag to preserve authorial revisions. | [deleted: text] | We left at [deleted: noon] one o'clock instead. |
|
|
||||||
| **Interlineations / Additions** | Wrap text inserted above, below, or in margins into the narrative flow inside an [inserted: ...] tag. | [inserted: text] | The [inserted: red] house on the hill was abandoned. |
|
|
||||||
|
|
||||||
### 3.2 Typography, Characters & Layout
|
|
||||||
|
|
||||||
| Document Feature | Rule | Standard Markup Format | Output Example |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| **Superscripts & Abbreviations** | Bring raised letters down to the main line. Optionally expand abbreviations within square brackets based on project configuration. | [expanded] | Gen^l becomes Genl or Gen[era]l. |
|
|
||||||
| **Line-End Hyphenation** | Rejoin words split across a page or line boundary silently, dropping the soft hyphen. | Silently rejoin | Original: "estab- / lishment" becomes establishment |
|
|
||||||
| **Capitalization** | Preserve explicit capitalization. Default to modern capitalization rules only when authorial intent is ambiguous or archaic forms confuse sentence structure. | Literal / Contextual | If a standard noun like 'Farm' is clearly capitalized, record 'Farm'. If ambiguous, default to 'farm'. |
|
|
||||||
| **Hierarchical Outlines** | Preserve exact numbering characters (including lowercase Roman numerals or terminal 'j'). Replicate indentation levels using standard spacing. Do not correct sequence or mathematical errors. | Preserve syntax | I. Main Topic a. Sub-point b. Next pointIII. [sic] Third Topic |
|
|
||||||
|
|
||||||
### 3.3 Visual & Spatial Elements
|
|
||||||
|
|
||||||
| Document Feature | Rule | Standard Markup Format | Output Example |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| **Non-Textual Artifacts** | Record non-textual elements (seals, stamps, sketches, physical damage) using brief descriptive text inside square brackets. | [description] | [wax notary seal attached here] or [sketch of a fort layout] |
|
|
||||||
| **Marginalia & Addenda** | Explicitly indicate spatial transitions before transcribing content located in margins or non-standard orientations. | [location:] | [written in left margin:] Do not share this with anyone. |
|
|
||||||
|
|
||||||
## 4. Prompt Asset Integration
|
|
||||||
|
|
||||||
When executing programmatic transcriptions via LLM APIs or local models, processing instructions must be packaged into single-purpose system prompts aligned with these rules.
|
|
||||||
|
|
||||||
1. **Isolation:** Each transcription prompt file exists as an independent Markdown asset in the repository.
|
|
||||||
2. **Deterministic Output:** Prompts must explicitly instruct models to follow the markup standards in Section 3 without introducing conversational wrappers, extra prose, or structural markdown outside the source document's native layout.
|
|
||||||
3. **Iterative Scoping:** Rule modifications or edge-case additions must be submitted as isolated delta commits to individual prompt files to maintain clean revision tracking.
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# UI Style Guide (Invariant)
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
This guide defines non-negotiable UI styling rules for the transcription application.
|
|
||||||
|
|
||||||
The design system is token-first and class-driven:
|
|
||||||
1. Theme tokens are defined in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
|
|
||||||
2. Python UI code composes semantic classes instead of inline color values.
|
|
||||||
3. Pages and components should share a single visual language across Documents, Jobs, People, and Sources flows.
|
|
||||||
|
|
||||||
## 2. Source of Truth
|
|
||||||
Use these files as the style authority:
|
|
||||||
1. [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) for color tokens, semantic utility classes, table styles, and viewer surfaces.
|
|
||||||
2. [src/transcription/ui/theme.py](src/transcription/ui/theme.py) for runtime NiceGUI theme bridge and shared UI helpers.
|
|
||||||
|
|
||||||
If this document conflicts with implementation, update this document to match the code immediately after intentional style changes.
|
|
||||||
|
|
||||||
## 3. Core Design Invariants
|
|
||||||
1. Flat, high-density surfaces over decorative depth.
|
|
||||||
2. Strong content hierarchy with subdued backgrounds and border-based separation.
|
|
||||||
3. Viewer area remains the highest contrast region in image/transcription workflows.
|
|
||||||
4. Primary actions are consistent and visually recognizable.
|
|
||||||
5. Accessible focus rings are always visible for keyboard users.
|
|
||||||
|
|
||||||
## 4. Token System
|
|
||||||
|
|
||||||
### 4.1 Palette Tokens
|
|
||||||
Base palette variables live under :root in [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css):
|
|
||||||
1. --palette-carbon-black: #1c2321
|
|
||||||
2. --palette-cool-steel: #7d98a1
|
|
||||||
3. --palette-blue-slate: #5e6572
|
|
||||||
4. --palette-powder-blue: #a9b4c2
|
|
||||||
5. --palette-platinum: #eef1ef
|
|
||||||
|
|
||||||
### 4.2 Semantic Theme Tokens
|
|
||||||
Do not style components directly with palette tokens when a semantic token exists.
|
|
||||||
|
|
||||||
Semantic tokens currently include:
|
|
||||||
1. --theme-text and --theme-text-muted
|
|
||||||
2. --theme-page, --theme-surface, --theme-surface-raised, --theme-surface-muted
|
|
||||||
3. --theme-border
|
|
||||||
4. --theme-primary and --theme-primary-hover
|
|
||||||
5. --theme-secondary and --theme-focus
|
|
||||||
6. --theme-inverse-text
|
|
||||||
7. --theme-viewer, --theme-viewer-border, --theme-viewer-muted
|
|
||||||
|
|
||||||
## 5. Approved Semantic Classes
|
|
||||||
|
|
||||||
### 5.1 Text and Background
|
|
||||||
1. ui-text-primary
|
|
||||||
2. ui-text-muted
|
|
||||||
3. ui-text-inverse
|
|
||||||
4. ui-bg-page
|
|
||||||
5. ui-bg-surface
|
|
||||||
6. ui-bg-surface-raised
|
|
||||||
7. ui-bg-surface-muted
|
|
||||||
8. ui-bg-viewer
|
|
||||||
9. ui-bg-viewer-overlay
|
|
||||||
10. ui-bg-viewer-overlay-soft
|
|
||||||
|
|
||||||
### 5.2 Borders and Surfaces
|
|
||||||
1. ui-border-subtle
|
|
||||||
2. ui-border-viewer
|
|
||||||
3. ui-header-divider
|
|
||||||
4. ui-card-surface
|
|
||||||
5. ui-row-surface
|
|
||||||
6. ui-note-box
|
|
||||||
|
|
||||||
### 5.3 Interactive Elements
|
|
||||||
1. ui-btn-primary
|
|
||||||
2. ui-btn-secondary
|
|
||||||
3. ui-link-primary
|
|
||||||
4. ui-text-accent
|
|
||||||
|
|
||||||
### 5.4 Table Patterns
|
|
||||||
1. ui-table
|
|
||||||
2. ui-table-header
|
|
||||||
3. ui-table-body
|
|
||||||
|
|
||||||
Use existing class combinations from [src/transcription/ui/components](src/transcription/ui/components) and [src/transcription/ui/pages](src/transcription/ui/pages) as reference implementations.
|
|
||||||
|
|
||||||
## 6. Legacy Class Policy
|
|
||||||
Legacy classes with vibe- prefix still exist in a few components and are allowed only for compatibility while migrating:
|
|
||||||
1. Existing usage may remain temporarily.
|
|
||||||
2. New usage of vibe- classes is not allowed.
|
|
||||||
3. When touching a file that uses vibe- classes, prefer migrating it to ui- semantic classes in the same change when safe.
|
|
||||||
|
|
||||||
Current legacy usage examples are in:
|
|
||||||
1. [src/transcription/ui/components/document_panzoom.py](src/transcription/ui/components/document_panzoom.py)
|
|
||||||
2. [src/transcription/ui/components/error_presenter.py](src/transcription/ui/components/error_presenter.py)
|
|
||||||
3. [src/transcription/ui/components/transcript.py](src/transcription/ui/components/transcript.py)
|
|
||||||
|
|
||||||
## 7. Prohibited Patterns
|
|
||||||
1. Inline hex colors in Python UI class strings or style blocks, except in isolated bridge code explicitly marked for migration.
|
|
||||||
2. Ad-hoc one-off class names that duplicate existing semantic class intent.
|
|
||||||
3. Page-specific palette forks that bypass theme tokens.
|
|
||||||
4. Hidden or low-contrast focus states on interactive controls.
|
|
||||||
|
|
||||||
## 8. Implementation Rules For Contributors
|
|
||||||
1. Prefer composing existing semantic classes before creating new ones.
|
|
||||||
2. If a new class is required, add it to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css) with a semantic name, then reuse it.
|
|
||||||
3. Keep behavior ownership in Python and appearance ownership in CSS.
|
|
||||||
4. Update UI tests that assert exact text or labels when intentional copy changes are made.
|
|
||||||
5. Avoid introducing class churn unrelated to the feature being changed.
|
|
||||||
|
|
||||||
## 9. Verification Checklist
|
|
||||||
Before merging UI changes, verify:
|
|
||||||
1. No new inline hex colors were introduced in UI pages/components.
|
|
||||||
2. New styles are token-backed and added to [src/transcription/ui/static/theme.css](src/transcription/ui/static/theme.css).
|
|
||||||
3. Primary buttons, links, cards, and tables still render with consistent semantics.
|
|
||||||
4. Keyboard focus ring visibility is preserved.
|
|
||||||
5. Relevant UI and integration tests pass.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Document Transcription System Requirements (Version 2)
|
|
||||||
|
|
||||||
This document captures the **Version 2 baseline requirements** for the production implementation.
|
|
||||||
|
|
||||||
## Requirements Model
|
|
||||||
|
|
||||||
| ID | Category | Requirement | Verify Method |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| REQ-0 | System | Provide end-to-end multi-page document transcription with persistent, inspectable async job states. | demonstration |
|
|
||||||
| REQ-1 | Functional | Allow users to upload folders or multi-image batches as sequential `Source` pages under a `Document`. | test |
|
|
||||||
| REQ-2 | Functional | Process multi-page jobs asynchronously using an `asyncio` worker pool bounded by rate limits. | test |
|
|
||||||
| REQ-3 | Functional | Persist page-level execution outputs (`raw_transcription`, `ai_metadata`, `raw_api_response`) on `JobSource`. | test |
|
|
||||||
| REQ-4 | Functional | Support job states (`queued`, `processing`, `completed`, `partial_success`, `failed`) and page states (`pending`, `transcribed`, `failed`). | inspection |
|
|
||||||
| REQ-5 | Functional | Allow users to manage historical `Person` records and link multiple authors/recipients to a `Document` via `DocumentPerson`. | test |
|
|
||||||
| REQ-6 | Functional | Maintain immutable original machine output on `Source.raw_transcription` while permitting inline human edits on `Source.revised_text`. | test |
|
|
||||||
| REQ-7 | Data Constraint | Store all persistent domain data in PostgreSQL using native `UUID`, `TIMESTAMPTZ`, and `JSONB` columns. | inspection |
|
|
||||||
| REQ-8 | Data Constraint | Validate all API requests, database rows, and JSONB structures using Pydantic V2 schemas. | test |
|
|
||||||
| REQ-9 | Interface | Render multi-page transcriptions sequentially by `page_number` in the web UI with author/recipient metadata. | demonstration |
|
|
||||||
| REQ-10 | Operations | Allow operators to retry only failed pages for jobs in a `partial_success` state. | test |
|
|
||||||
|
|
||||||
## Element Satisfaction Mapping
|
|
||||||
|
|
||||||
* **UI (NiceGUI):** Satisfies REQ-1, REQ-5, REQ-6, REQ-9, REQ-10.
|
|
||||||
* **API (FastAPI):** Satisfies REQ-1, REQ-4, REQ-5, REQ-8.
|
|
||||||
* **WORKER (asyncio):** Satisfies REQ-2, REQ-3, REQ-4, REQ-10.
|
|
||||||
* **PERSISTENCE (PostgreSQL):** Satisfies REQ-3, REQ-6, REQ-7.
|
|
||||||
* **MODELS (Pydantic V2):** Satisfies REQ-8.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v2.md)
|
|
||||||
- [System Design Intent](invariant/intent.md)
|
|
||||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v2.md)
|
|
||||||
- System Requirements (this document)
|
|
||||||
- [Data model](schema_v2.md)
|
|
||||||
- [Error Handling Policy](error_handling_v2.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v2.md)
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# Database Schema (Version 2)
|
|
||||||
|
|
||||||
This document describes the PostgreSQL relational schema for the transcription platform. It incorporates multi-image batch orchestration via `asyncio`, page-level execution tracking, many-to-many author/recipient attribution, and JSONB document storage for AI vision outputs.
|
|
||||||
|
|
||||||
All primary and foreign keys are PostgreSQL native UUIDs (`gen_random_uuid()`).
|
|
||||||
|
|
||||||
## Entity Relationship Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
PERSON {
|
|
||||||
UUID id PK
|
|
||||||
TEXT full_name
|
|
||||||
TEXT display_name
|
|
||||||
TEXT maiden_name
|
|
||||||
DATE birth_date
|
|
||||||
TEXT birth_date_raw
|
|
||||||
TEXT birth_place
|
|
||||||
DATE death_date
|
|
||||||
TEXT death_date_raw
|
|
||||||
TEXT death_place
|
|
||||||
TEXT biography
|
|
||||||
TEXT portrait_path
|
|
||||||
JSONB metadata
|
|
||||||
TIMESTAMPTZ created_at
|
|
||||||
TIMESTAMPTZ updated_at
|
|
||||||
}
|
|
||||||
|
|
||||||
DOCUMENT {
|
|
||||||
UUID id PK
|
|
||||||
TEXT name
|
|
||||||
TEXT document_type
|
|
||||||
DATE document_date
|
|
||||||
TEXT document_date_raw
|
|
||||||
TEXT location_created
|
|
||||||
TEXT notes
|
|
||||||
TEXT archive_identifier
|
|
||||||
TIMESTAMPTZ created_at
|
|
||||||
TIMESTAMPTZ updated_at
|
|
||||||
}
|
|
||||||
|
|
||||||
DOCUMENT_PERSON {
|
|
||||||
UUID id PK
|
|
||||||
UUID document_id FK
|
|
||||||
UUID person_id FK
|
|
||||||
VARCHAR role "author | recipient"
|
|
||||||
TIMESTAMPTZ created_at
|
|
||||||
}
|
|
||||||
|
|
||||||
JOB {
|
|
||||||
UUID id PK
|
|
||||||
UUID document_id FK
|
|
||||||
VARCHAR status "queued | processing | transcribed | completed | partial_success | failed"
|
|
||||||
INTEGER retry_count
|
|
||||||
TEXT provider
|
|
||||||
TEXT model
|
|
||||||
TEXT prompt_name
|
|
||||||
TIMESTAMPTZ date_created
|
|
||||||
TIMESTAMPTZ date_updated
|
|
||||||
}
|
|
||||||
|
|
||||||
SOURCE {
|
|
||||||
UUID id PK
|
|
||||||
UUID document_id FK
|
|
||||||
INTEGER page_number
|
|
||||||
TEXT upload_name
|
|
||||||
TEXT filename
|
|
||||||
TEXT file_path
|
|
||||||
TEXT raw_transcription
|
|
||||||
TEXT revised_text
|
|
||||||
TIMESTAMPTZ date_uploaded
|
|
||||||
TIMESTAMPTZ date_revised
|
|
||||||
}
|
|
||||||
|
|
||||||
JOB_SOURCE {
|
|
||||||
UUID id PK
|
|
||||||
UUID job_id FK
|
|
||||||
UUID source_id FK
|
|
||||||
VARCHAR status "pending | transcribed | failed"
|
|
||||||
TEXT raw_transcription
|
|
||||||
JSONB ai_metadata
|
|
||||||
JSONB raw_api_response
|
|
||||||
TEXT error_detail
|
|
||||||
TIMESTAMPTZ executed_at
|
|
||||||
}
|
|
||||||
|
|
||||||
DOCUMENT ||--o{ DOCUMENT_PERSON : "has_people"
|
|
||||||
PERSON ||--o{ DOCUMENT_PERSON : "participates_in"
|
|
||||||
DOCUMENT ||--o{ JOB : "has_jobs"
|
|
||||||
DOCUMENT ||--o{ SOURCE : "contains_pages"
|
|
||||||
JOB ||--o{ JOB_SOURCE : "executes"
|
|
||||||
SOURCE ||--o{ JOB_SOURCE : "processed_in"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Domain Invariants & Rules
|
|
||||||
|
|
||||||
### Page-Level Execution & AI Outputs
|
|
||||||
|
|
||||||
* Execution Granularity: Every single image execution by an AI model produces a dedicated record in job_source.
|
|
||||||
* Source vs Execution Status: `source` does not carry a `status` column. Per-source execution state is tracked in `job_source.status` (`pending`, `transcribed`, `failed`).
|
|
||||||
* Point-in-Time Auditability: job_source.raw_api_response stores the unparsed REST response envelope for that specific image page call. job_source.ai_metadata stores spatial bounding boxes, token usage, and layout details for that specific image page call.
|
|
||||||
* Active Output Caching: Upon successful completion of an image call, source.raw_transcription is updated with the latest output string from job_source.raw_transcription for fast UI rendering.
|
|
||||||
|
|
||||||
### Page Ordering & Revisions
|
|
||||||
|
|
||||||
* Sequential Integrity: source.page_number dictates page ordering within a document. Reads assembling full documents must query ORDER BY source.document_id, source.page_number ASC.
|
|
||||||
* Inlined Human Corrections: User edits occur at the page level inside source.revised_text. source.raw_transcription remains immutable. If source.revised_text is non-null, application frontends must render source.revised_text.
|
|
||||||
|
|
||||||
### Async Job Lifecycle & Failure Isolation
|
|
||||||
|
|
||||||
* Batch Orchestrator: A job represents an overarching execution run across one or more source images belonging to a document.
|
|
||||||
* Isolated Failures: API requests run concurrently (e.g., using asyncio). A failure on page 3 does not invalidate successful transcriptions on page 1 or 2.
|
|
||||||
* Job States:
|
|
||||||
- queued: Created, awaiting worker execution.
|
|
||||||
- processing: Concurrent HTTP tasks actively running.
|
|
||||||
- completed: 100% of linked job_source tasks succeeded (transcribed).
|
|
||||||
- partial_success: At least one job_source succeeded and at least one failed.
|
|
||||||
- failed: All linked job_source tasks failed or a job-level runtime error occurred.
|
|
||||||
|
|
||||||
### Attribution & Person Roles
|
|
||||||
|
|
||||||
* Multi-Person Roles: Documents support zero, one, or many authors and recipients linked via document_person.
|
|
||||||
* Role Uniqueness: (document_id, person_id, role) must be unique to prevent duplicate role tagging.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v2.md)
|
|
||||||
- [System Design Intent](invariant/intent.md)
|
|
||||||
- [Transcription Methodology](invariant/transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v2.md)
|
|
||||||
- [System Requirements](requirements_v2.md)
|
|
||||||
- Data model (this document)
|
|
||||||
- [Error Handling Policy](error_handling_v2.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v2.md)
|
|
||||||
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from nicegui import ui
|
|
||||||
|
|
||||||
# 1. Mature Dark Mode Setup
|
|
||||||
ui.dark_mode(True)
|
|
||||||
|
|
||||||
# Define a refined dark palette using expanded dictionary styling
|
|
||||||
theme_colors = {
|
|
||||||
'primary': '#6366f1',
|
|
||||||
'secondary': '#8b5cf6',
|
|
||||||
'accent': '#ec4899',
|
|
||||||
'dark': '#0f172a',
|
|
||||||
'dark_page': '#020617',
|
|
||||||
'positive': '#10b981',
|
|
||||||
'negative': '#ef4444',
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.colors(**theme_colors)
|
|
||||||
|
|
||||||
# Optional: Add custom CSS for subtle noise overlays or kinetic typography
|
|
||||||
ui.add_css('''
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
}
|
|
||||||
''')
|
|
||||||
|
|
||||||
# 2. Bento Grid Layout
|
|
||||||
with ui.element('div').classes('grid grid-cols-1 md:grid-cols-4 gap-6 w-full max-w-6xl mx-auto p-8'):
|
|
||||||
|
|
||||||
# Header spanning all columns
|
|
||||||
with ui.element('div').classes('col-span-1 md:col-span-4 mb-4'):
|
|
||||||
ui.label('Analytics Dashboard').classes('text-4xl font-extrabold tracking-tight text-white')
|
|
||||||
ui.label('AI-driven insights for Q3').classes('text-lg text-slate-400 mt-1')
|
|
||||||
|
|
||||||
# Large Feature Card (Glassmorphism + Functional Motion)
|
|
||||||
with ui.element('div').classes('glass-card col-span-1 md:col-span-2 p-6 transition-transform duration-300 hover:scale-[1.02]'):
|
|
||||||
ui.icon('monitoring', size='2rem').classes('text-primary mb-4')
|
|
||||||
ui.label('Revenue Prediction').classes('text-xl font-semibold text-slate-100')
|
|
||||||
ui.label('$45,231.00').classes('text-5xl font-bold text-white mt-2')
|
|
||||||
# Placeholder for an interactive EChart
|
|
||||||
ui.echart({
|
|
||||||
'xAxis': {
|
|
||||||
'type': 'category',
|
|
||||||
'data': [
|
|
||||||
'Mon',
|
|
||||||
'Tue',
|
|
||||||
'Wed',
|
|
||||||
'Thu',
|
|
||||||
'Fri',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'yAxis': {
|
|
||||||
'type': 'value',
|
|
||||||
},
|
|
||||||
'series': [
|
|
||||||
{
|
|
||||||
'data': [
|
|
||||||
120,
|
|
||||||
200,
|
|
||||||
150,
|
|
||||||
80,
|
|
||||||
70,
|
|
||||||
],
|
|
||||||
'type': 'bar',
|
|
||||||
'itemStyle': {
|
|
||||||
'color': '#6366f1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).classes('w-full h-48 mt-4')
|
|
||||||
|
|
||||||
# Smaller Metric Cards
|
|
||||||
metric_cards = [
|
|
||||||
{
|
|
||||||
'title': 'Active Users',
|
|
||||||
'value': '1,204',
|
|
||||||
'icon': 'group',
|
|
||||||
'color': 'text-secondary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'title': 'Server Load',
|
|
||||||
'value': '34%',
|
|
||||||
'icon': 'memory',
|
|
||||||
'color': 'text-accent',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for card in metric_cards:
|
|
||||||
with ui.element('div').classes('glass-card col-span-1 p-6 flex flex-col justify-between transition-transform duration-300 hover:-translate-y-1'):
|
|
||||||
ui.icon(card['icon'], size='2rem').classes(card['color'])
|
|
||||||
ui.element('div').classes('flex-grow')
|
|
||||||
ui.label(card['value']).classes('text-4xl font-bold text-white mt-4')
|
|
||||||
ui.label(card['title']).classes('text-sm font-medium text-slate-400 uppercase tracking-wider')
|
|
||||||
|
|
||||||
# AI Assistant Module (Adaptive Interface)
|
|
||||||
with ui.element('div').classes('glass-card col-span-1 md:col-span-4 p-6 flex items-center gap-4'):
|
|
||||||
ui.icon('smart_toy', size='2rem').classes('text-positive animate-pulse')
|
|
||||||
with ui.element('div'):
|
|
||||||
ui.label('Ambient AI Suggestion').classes('text-sm font-bold text-positive uppercase tracking-wider')
|
|
||||||
ui.label('Based on current server load, scaling up instances in the EU-West region is recommended.').classes('text-slate-300')
|
|
||||||
ui.space()
|
|
||||||
ui.button('Apply Now', color='positive').classes('rounded-full px-6 py-2 shadow-lg shadow-positive/20')
|
|
||||||
|
|
||||||
ui.run(title='2026 UI Dashboard')
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
```mermaid
|
|
||||||
block-beta
|
|
||||||
columns 3
|
|
||||||
|
|
||||||
%% UI Component Column
|
|
||||||
block:UI["UI COMPONENTS / WIREFRAME"]:1
|
|
||||||
columns 1
|
|
||||||
|
|
||||||
block:HeaderUI["Header & Nav"]:1
|
|
||||||
columns 1
|
|
||||||
h_title["[Text] Document Name & Type"]
|
|
||||||
h_date["[Text] Date & Origin Location"]
|
|
||||||
end
|
|
||||||
|
|
||||||
block:EditorUI["Page Transcription Editor"]:1
|
|
||||||
columns 1
|
|
||||||
ed_img["[Image Viewer] Source Image"]
|
|
||||||
ed_page["[Badge] Page Number"]
|
|
||||||
ed_raw["[Read-Only] AI Raw Output"]
|
|
||||||
ed_rev["[Textarea] Human Revised Text"]
|
|
||||||
end
|
|
||||||
|
|
||||||
block:PeopleUI["Attribution Sidebar"]:1
|
|
||||||
columns 1
|
|
||||||
p_author["[List] Authors (Full Name)"]
|
|
||||||
p_recip["[List] Recipients (Full Name)"]
|
|
||||||
p_bio["[Card] Person Biography & Dates"]
|
|
||||||
end
|
|
||||||
|
|
||||||
block:JobUI["AI Processing Drawer"]:1
|
|
||||||
columns 1
|
|
||||||
j_status["[Badge] Job Status"]
|
|
||||||
j_model["[Text] Provider & Model"]
|
|
||||||
j_tokens["[JSON View] AI Token Usage"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
%% Directional Mapping / Connectors
|
|
||||||
block:FLOW["MAPPING / FLOW"]:1
|
|
||||||
columns 1
|
|
||||||
f1["Reads / Updates -->"]
|
|
||||||
f2["Renders Active Page -->"]
|
|
||||||
f3["Joins via Role -->"]
|
|
||||||
f4["Executes & Logs -->"]
|
|
||||||
end
|
|
||||||
|
|
||||||
%% Postgres Schema Column
|
|
||||||
block:DB["POSTGRES SQL SCHEMA"]:1
|
|
||||||
columns 1
|
|
||||||
|
|
||||||
block:DocTbl["Table: document"]:1
|
|
||||||
columns 1
|
|
||||||
d_id["id : UUID (PK)"]
|
|
||||||
d_name["name : TEXT"]
|
|
||||||
d_type["document_type : TEXT"]
|
|
||||||
d_date["document_date : DATE"]
|
|
||||||
end
|
|
||||||
|
|
||||||
block:SrcTbl["Table: source"]:1
|
|
||||||
columns 1
|
|
||||||
s_id["id : UUID (PK)"]
|
|
||||||
s_page["page_number : INT"]
|
|
||||||
s_path["file_path : TEXT"]
|
|
||||||
s_raw["raw_transcription : TEXT"]
|
|
||||||
s_rev["revised_text : TEXT"]
|
|
||||||
end
|
|
||||||
|
|
||||||
block:PersonTbl["Table: person & document_person"]:1
|
|
||||||
columns 1
|
|
||||||
p_id["id : UUID (PK)"]
|
|
||||||
p_name["full_name : TEXT"]
|
|
||||||
p_role["role : 'author' | 'recipient'"]
|
|
||||||
end
|
|
||||||
|
|
||||||
block:JobTbl["Table: job & job_source"]:1
|
|
||||||
columns 1
|
|
||||||
j_id["id : UUID (PK)"]
|
|
||||||
j_stat["status : VARCHAR"]
|
|
||||||
j_prov["provider / model : TEXT"]
|
|
||||||
j_meta["ai_metadata : JSONB"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
%% Connections
|
|
||||||
HeaderUI --> DocTbl
|
|
||||||
ed_img --> s_path
|
|
||||||
ed_page --> s_page
|
|
||||||
ed_raw --> s_raw
|
|
||||||
ed_rev --> s_rev
|
|
||||||
PeopleUI --> PersonTbl
|
|
||||||
JobUI --> JobTbl
|
|
||||||
```
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph UI["UI Components / Wireframe"]
|
|
||||||
direction TB
|
|
||||||
subgraph HeaderUI["Header & Nav"]
|
|
||||||
h_title["[Text] Document Name & Type"]
|
|
||||||
h_date["[Text] Date & Origin Location"]
|
|
||||||
end
|
|
||||||
subgraph EditorUI["Page Transcription Editor"]
|
|
||||||
ed_img["[Image Viewer] Source Image"]
|
|
||||||
ed_page["[Badge] Page Number"]
|
|
||||||
ed_raw["[Read-Only] AI Raw Output"]
|
|
||||||
ed_rev["[Textarea] Human Revised Text"]
|
|
||||||
end
|
|
||||||
subgraph PeopleUI["Attribution Sidebar"]
|
|
||||||
p_author["[List] Authors / Recipients"]
|
|
||||||
end
|
|
||||||
subgraph JobUI["AI Processing Drawer"]
|
|
||||||
j_status["[Badge] Job Status"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph DB["Postgres SQL Schema"]
|
|
||||||
direction TB
|
|
||||||
subgraph DocTbl["Table: document"]
|
|
||||||
d_name["name : TEXT"]
|
|
||||||
d_type["document_type : TEXT"]
|
|
||||||
end
|
|
||||||
subgraph SrcTbl["Table: source"]
|
|
||||||
s_path["file_path : TEXT"]
|
|
||||||
s_page["page_number : INT"]
|
|
||||||
s_raw["raw_transcription : TEXT"]
|
|
||||||
s_rev["revised_text : TEXT"]
|
|
||||||
end
|
|
||||||
subgraph PersonTbl["Table: person & document_person"]
|
|
||||||
p_name["full_name : TEXT"]
|
|
||||||
p_role["role : author | recipient"]
|
|
||||||
end
|
|
||||||
subgraph JobTbl["Table: job & job_source"]
|
|
||||||
j_stat["status : VARCHAR"]
|
|
||||||
j_meta["ai_metadata : JSONB"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
%% Mappings
|
|
||||||
HeaderUI --> DocTbl
|
|
||||||
ed_img --> s_path
|
|
||||||
ed_page --> s_page
|
|
||||||
ed_raw --> s_raw
|
|
||||||
ed_rev --> s_rev
|
|
||||||
PeopleUI --> PersonTbl
|
|
||||||
JobUI --> JobTbl
|
|
||||||
```
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# UI Documentation
|
|
||||||
|
|
||||||
This folder contains UI-focused design and mapping documents that connect the database schema to user-facing workflows.
|
|
||||||
|
|
||||||
## Document Types
|
|
||||||
|
|
||||||
### user-journey.md
|
|
||||||
|
|
||||||
A product and UX contract for a user-facing entity.
|
|
||||||
|
|
||||||
Use this document to describe:
|
|
||||||
- what the user is trying to do
|
|
||||||
- which screen or action starts the workflow
|
|
||||||
- which fields the user sees and edits
|
|
||||||
- validation rules
|
|
||||||
- expected success and failure outcomes
|
|
||||||
- where the user goes next
|
|
||||||
|
|
||||||
### schema-mapping.md
|
|
||||||
|
|
||||||
A field-level mapping between schema, UI, and implementation.
|
|
||||||
|
|
||||||
Use this document to describe:
|
|
||||||
- the authoritative schema fields for an entity
|
|
||||||
- which fields are shown, hidden, editable, or system-managed
|
|
||||||
- current implementation behavior
|
|
||||||
- intended target behavior
|
|
||||||
- implementation gaps between current code and intended UX
|
|
||||||
|
|
||||||
### acceptance-criteria.md
|
|
||||||
|
|
||||||
An implementation-ready checklist for CRUD behavior and quality gates.
|
|
||||||
|
|
||||||
Use this document to describe:
|
|
||||||
- testable acceptance statements by flow (Create, Read, Update, Delete)
|
|
||||||
- success and failure behaviors
|
|
||||||
- first-release constraints
|
|
||||||
- cross-criteria quality gates
|
|
||||||
|
|
||||||
### traceability-matrix.md
|
|
||||||
|
|
||||||
A criteria-to-code mapping that identifies implementation anchors and status.
|
|
||||||
|
|
||||||
Use this document to describe:
|
|
||||||
- acceptance criteria group to implementation file mapping
|
|
||||||
- delivery status (implemented, partial, planned)
|
|
||||||
- ordered implementation priorities
|
|
||||||
|
|
||||||
## Organization Rules
|
|
||||||
|
|
||||||
- Store documents under `docs/ui/entities/<entity-name>/`.
|
|
||||||
- Create both `user-journey.md` and `schema-mapping.md` for user-facing entities.
|
|
||||||
- Create `acceptance-criteria.md` for user-facing entities.
|
|
||||||
- Create only `schema-mapping.md` for supporting tables that do not currently have standalone UI.
|
|
||||||
- Keep one shared `traceability-matrix.md` under `docs/ui/entities/` to map criteria to implementation anchors.
|
|
||||||
- Keep top-level `docs/` reserved for core architecture, requirements, schema, and system-wide reference material.
|
|
||||||
|
|
||||||
## Current Entity Plan
|
|
||||||
|
|
||||||
User-facing entities:
|
|
||||||
- `document`
|
|
||||||
- `person`
|
|
||||||
- `source`
|
|
||||||
- `job`
|
|
||||||
|
|
||||||
Supporting entities:
|
|
||||||
- `document-person`
|
|
||||||
- `job-source`
|
|
||||||
|
|
||||||
## Relationship to Core Docs
|
|
||||||
|
|
||||||
These UI docs complement, but do not replace:
|
|
||||||
- `docs/schema_v2.md`
|
|
||||||
- `docs/requirements_v2.md`
|
|
||||||
- `docs/architecture_v2.md`
|
|
||||||
|
|
||||||
When there is a conflict:
|
|
||||||
- schema definitions come from the database model and schema docs
|
|
||||||
- user interaction intent comes from the user-journey docs
|
|
||||||
- implementation truth comes from code and is recorded in schema-mapping docs as current-state evidence
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
# DocumentPerson Schema-to-UI Mapping
|
|
||||||
|
|
||||||
Purpose: Map the DocumentPerson schema to UI-facing workflows, while separating intended target behavior from current implementation.
|
|
||||||
|
|
||||||
Supporting entity note: DocumentPerson does not currently have a standalone UI surface.
|
|
||||||
|
|
||||||
## 1. Entity Snapshot
|
|
||||||
|
|
||||||
- Table: document_person
|
|
||||||
- Primary key: id (UUID)
|
|
||||||
- Related entities: Document, Person
|
|
||||||
- Canonical schema references:
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
- docs/schema_v2.md
|
|
||||||
|
|
||||||
## 2. Mapping Rules
|
|
||||||
|
|
||||||
This document uses three lenses:
|
|
||||||
1. Intended behavior: what user-facing workflows should support indirectly.
|
|
||||||
2. Current behavior: what code supports today.
|
|
||||||
3. Gap to target: what must change to align implementation with intended UX.
|
|
||||||
|
|
||||||
## 3. Field Inventory
|
|
||||||
|
|
||||||
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
|
|
||||||
| document_id | UUID FK | No | None | Context-managed | Selected Document context |
|
|
||||||
| person_id | UUID FK | No | None | Context-managed | Selected Person context |
|
|
||||||
| role | enum DocumentPersonRole | No | author | Visible in relationship context | First-release behavior may default to author |
|
|
||||||
| created_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed timestamp |
|
|
||||||
|
|
||||||
Constraint behavior:
|
|
||||||
1. document_id, person_id, and role are unique as a tuple.
|
|
||||||
2. duplicate links for the same document, person, and role must be rejected.
|
|
||||||
|
|
||||||
## 4. CREATE Mapping
|
|
||||||
|
|
||||||
### 4.1 Intended Create Flow
|
|
||||||
|
|
||||||
Entry points are indirect through user-facing entities:
|
|
||||||
1. Document create or update workflows may create one or more DocumentPerson links.
|
|
||||||
2. Person relationship workflows may create DocumentPerson links.
|
|
||||||
|
|
||||||
| Field | Intended User Input | Required | Visible | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| document_id | None | Yes | No | Derived from selected Document |
|
|
||||||
| person_id | None | Yes | No | Derived from selected Person |
|
|
||||||
| role | Select or default | Yes | Indirectly | Defaults to author in first-release behavior |
|
|
||||||
| created_at | None | No | No | System-generated |
|
|
||||||
|
|
||||||
### 4.2 Current Implementation
|
|
||||||
|
|
||||||
Current entry point: Document create/edit flows
|
|
||||||
Current user action: select an existing Person from the Document author dropdown
|
|
||||||
Current backend path: Document page submit callback -> `DocumentService.create_document_person()` or `delete_document_person()` as the author selection changes
|
|
||||||
|
|
||||||
| Field | Current Value at Create | Source | Visible to User | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| id | Generated UUID | System | No | src/transcription/db/models.py |
|
|
||||||
| document_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
|
|
||||||
| person_id | Caller-provided | Document UI | Indirectly | src/transcription/ui/pages/documents_page.py |
|
|
||||||
| role | Default author in current UI | Service/model default | No | src/transcription/db/models.py, src/transcription/services/documents.py |
|
|
||||||
| created_at | Current UTC timestamp | System | No | src/transcription/db/models.py |
|
|
||||||
|
|
||||||
### 4.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended supporting behavior, implementation must add:
|
|
||||||
1. explicit UI relationship controls in Document and/or Person detail flows.
|
|
||||||
2. duplicate-link handling with clear user feedback.
|
|
||||||
3. role-selection UX when role expansion is enabled beyond default author.
|
|
||||||
|
|
||||||
## 5. READ Mapping
|
|
||||||
|
|
||||||
### 5.1 Intended Read Behavior
|
|
||||||
|
|
||||||
Users should see DocumentPerson relationships indirectly in user-facing surfaces:
|
|
||||||
1. Document detail shows linked people.
|
|
||||||
2. Person detail shows linked documents.
|
|
||||||
3. Relationship role is shown where relevant.
|
|
||||||
|
|
||||||
### 5.2 Current Implementation
|
|
||||||
|
|
||||||
Current read behavior is mainly service-level.
|
|
||||||
|
|
||||||
| Field | Current Rendering | Visible to User | Notes | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| document_id/person_id link | Indirect relationship usage in workflows | Partial | Document/Person dedicated relationship surfaces are planned | docs/ui/entities/document/*, docs/ui/entities/person/* |
|
|
||||||
| role | Not shown in current job-centric pages | No | Role expansion is deferred in user-facing workflows | docs/ui/entities/person/user-journey.md |
|
|
||||||
| created_at | Not rendered | No | Operational metadata only | current UI pages |
|
|
||||||
|
|
||||||
Service read/query coverage:
|
|
||||||
1. read_document_person() returns one link by id.
|
|
||||||
2. list_document_people() supports filtering by document_id and person_id.
|
|
||||||
|
|
||||||
### 5.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended read behavior, implementation must add:
|
|
||||||
1. linked-people and linked-documents UI sections backed by list_document_people().
|
|
||||||
2. relationship role display where role context is required.
|
|
||||||
|
|
||||||
## 6. UPDATE Mapping
|
|
||||||
|
|
||||||
### 6.1 Intended Update Behavior
|
|
||||||
|
|
||||||
DocumentPerson updates are limited to relationship role or relationship-management actions.
|
|
||||||
|
|
||||||
Intended editable fields:
|
|
||||||
- role (when role management is enabled)
|
|
||||||
|
|
||||||
Intended read-only fields:
|
|
||||||
- id
|
|
||||||
- document_id
|
|
||||||
- person_id
|
|
||||||
- created_at
|
|
||||||
|
|
||||||
### 6.2 Current Implementation
|
|
||||||
|
|
||||||
| Field | Updatable via UI | Updatable via Service | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| role | No | Yes | DocumentService.update_document_person() supports updates |
|
|
||||||
| document_id/person_id | No | Technically yes via full-row update | Should generally be treated as immutable link identity |
|
|
||||||
| created_at | No | Technically yes | Should remain system-managed |
|
|
||||||
|
|
||||||
### 6.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation should add:
|
|
||||||
1. explicit relationship-role edit controls when product scope enables them.
|
|
||||||
2. safeguards against mutating link identity instead of recreating links.
|
|
||||||
|
|
||||||
## 7. DELETE Mapping
|
|
||||||
|
|
||||||
### 7.1 Intended Delete Behavior
|
|
||||||
|
|
||||||
Deletion of DocumentPerson should be exposed as unlink behavior in Document and Person flows.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. unlink should remove only the selected relationship.
|
|
||||||
2. unlink must not delete the underlying Document or Person records.
|
|
||||||
|
|
||||||
### 7.2 Current Implementation
|
|
||||||
|
|
||||||
| Action | UI Exposed | Backend Capability | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Delete DocumentPerson link | No | Yes | DocumentService.delete_document_person() exists |
|
|
||||||
|
|
||||||
### 7.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation must add:
|
|
||||||
1. unlink controls in relationship sections.
|
|
||||||
2. confirmation and success feedback for relationship removal.
|
|
||||||
3. blocked-delete guidance if policy constraints are added later.
|
|
||||||
|
|
||||||
## 8. Hidden and System-Managed Fields
|
|
||||||
|
|
||||||
| Field | Category | Why Hidden or Protected |
|
|
||||||
|---|---|---|
|
|
||||||
| id | System-managed | Internal identifier |
|
|
||||||
| document_id | Context-managed | Derived from selected Document |
|
|
||||||
| person_id | Context-managed | Derived from selected Person |
|
|
||||||
| created_at | System-managed | Audit timestamp |
|
|
||||||
|
|
||||||
## 9. Traceability Anchors
|
|
||||||
|
|
||||||
Schema and models:
|
|
||||||
- docs/schema_v2.md
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
- src/transcription/services/documents.py
|
|
||||||
- tests/services/test_v2_crud.py
|
|
||||||
|
|
||||||
Related user-facing workflows:
|
|
||||||
- docs/ui/entities/document/user-journey.md
|
|
||||||
- docs/ui/entities/person/user-journey.md
|
|
||||||
|
|
||||||
## 10. Coverage Summary
|
|
||||||
|
|
||||||
- Every DocumentPerson schema field appears in the field inventory.
|
|
||||||
- Intended behavior is defined as supporting workflow behavior rather than standalone UI.
|
|
||||||
- Current behavior reflects UI-backed CRUD through Document create/edit flows and Person detail rendering, with no standalone DocumentPerson UI.
|
|
||||||
- Gaps between intended and current behavior are explicit.
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# Document Acceptance Criteria
|
|
||||||
|
|
||||||
Purpose: Define implementation-ready acceptance criteria for Document Read, Update, and Delete workflows.
|
|
||||||
|
|
||||||
Companion documents:
|
|
||||||
- docs/ui/entities/document/user-journey.md
|
|
||||||
- docs/ui/entities/document/schema-mapping.md
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This checklist covers:
|
|
||||||
1. Read flow
|
|
||||||
2. Update flow
|
|
||||||
3. Delete flow
|
|
||||||
|
|
||||||
This checklist does not cover:
|
|
||||||
1. Source upload workflow details
|
|
||||||
2. Job execution internals
|
|
||||||
3. Revision editor behavior
|
|
||||||
|
|
||||||
## Read Acceptance Criteria
|
|
||||||
|
|
||||||
### RD-1 Document detail retrieval
|
|
||||||
1. Given a valid Document id
|
|
||||||
2. When the user opens the Document detail page
|
|
||||||
3. Then the system displays Document metadata for that record only
|
|
||||||
|
|
||||||
### RD-2 Metadata visibility
|
|
||||||
1. The page shows name, document_type, document_date, document_date_raw, location_created, notes, archive_identifier
|
|
||||||
2. created_at and updated_at are displayed as system-managed, read-only values
|
|
||||||
|
|
||||||
### RD-3 Related people section
|
|
||||||
1. Given zero linked people
|
|
||||||
2. Then the page shows a no linked people yet empty state
|
|
||||||
3. Given one linked person
|
|
||||||
4. Then the page shows that linked person
|
|
||||||
|
|
||||||
### RD-4 Sources section empty state
|
|
||||||
1. The page shows a Sources action for the current Document
|
|
||||||
2. The page shows a primary + Add Source action that opens job-create flow for this Document
|
|
||||||
3. The action routes to a document-scoped Sources view
|
|
||||||
|
|
||||||
### RD-5 Jobs section empty state
|
|
||||||
1. The page shows a Jobs action for the current Document
|
|
||||||
2. The page shows a primary + Add Job action for the current Document
|
|
||||||
3. The action routes to a document-scoped Jobs view
|
|
||||||
|
|
||||||
### RD-6 Filtered navigation readiness
|
|
||||||
1. The detail page provides links or actions that can route to document-scoped Sources and Jobs views
|
|
||||||
2. Target views are filtered to the current Document id
|
|
||||||
|
|
||||||
### RD-7 Failure state
|
|
||||||
1. Given a nonexistent Document id
|
|
||||||
2. Then the UI shows a clear not found state without crashing
|
|
||||||
|
|
||||||
## Update Acceptance Criteria
|
|
||||||
|
|
||||||
### UP-1 Edit entry
|
|
||||||
1. Given a loaded Document detail page
|
|
||||||
2. When the user chooses Edit document
|
|
||||||
3. Then editable controls are shown for allowed fields only, including the author relationship selector
|
|
||||||
4. The author selector includes No author, existing Person options, and a Create new item option
|
|
||||||
5. Selecting Create new item routes to Person create
|
|
||||||
|
|
||||||
### UP-2 Editable fields
|
|
||||||
1. Editable: name, document_type, document_date, document_date_raw, location_created, notes, archive_identifier
|
|
||||||
2. Not editable: id, created_at, updated_at
|
|
||||||
3. The edit flow may also change the associated author Person link
|
|
||||||
|
|
||||||
### UP-3 Required validation
|
|
||||||
1. name is required
|
|
||||||
2. document_type is required
|
|
||||||
3. Save is blocked with inline feedback when either required field is missing
|
|
||||||
|
|
||||||
### UP-4 Date handling rule
|
|
||||||
1. document_date only is allowed
|
|
||||||
2. document_date_raw only is allowed
|
|
||||||
3. both fields together are allowed
|
|
||||||
4. if both are present, document_date is treated as canonical exact date and document_date_raw is retained as descriptive context
|
|
||||||
|
|
||||||
### UP-5 Successful save
|
|
||||||
1. Given valid input
|
|
||||||
2. When the user saves
|
|
||||||
3. Then changes persist
|
|
||||||
4. Then success feedback is shown
|
|
||||||
5. Then the user remains on Document detail with refreshed values
|
|
||||||
6. Then updated_at reflects update policy
|
|
||||||
|
|
||||||
### UP-6 Save failure
|
|
||||||
1. Given backend failure during save
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then the user-entered values remain available for retry where possible
|
|
||||||
4. Then no false success feedback is shown
|
|
||||||
|
|
||||||
## Delete Acceptance Criteria
|
|
||||||
|
|
||||||
### DL-1 Delete entry and confirmation
|
|
||||||
1. Given a Document detail page
|
|
||||||
2. When the user chooses Delete document
|
|
||||||
3. Then a confirmation dialog appears with permanent-action wording
|
|
||||||
|
|
||||||
### DL-2 Dependency guardrails
|
|
||||||
1. Delete is allowed only when the Document has no related Source records and no related Job records
|
|
||||||
2. Delete is blocked when at least one related Source or Job exists
|
|
||||||
|
|
||||||
### DL-3 Blocked delete behavior
|
|
||||||
1. When blocked
|
|
||||||
2. Then the UI explains why deletion is blocked
|
|
||||||
3. Then the UI identifies dependency categories present: Sources, Jobs, or both
|
|
||||||
4. Then the UI provides navigation to dependency cleanup paths
|
|
||||||
|
|
||||||
### DL-4 Successful delete
|
|
||||||
1. Given no blocking dependencies
|
|
||||||
2. When the user confirms delete
|
|
||||||
3. Then the Document is removed
|
|
||||||
4. Then success feedback is shown
|
|
||||||
5. Then the user is returned to the Document list page
|
|
||||||
|
|
||||||
### DL-5 Delete failure
|
|
||||||
1. Given backend failure during delete
|
|
||||||
2. Then a clear error message is shown
|
|
||||||
3. Then the user remains on Document detail with retry path
|
|
||||||
|
|
||||||
## Cross-Criteria Quality Gates
|
|
||||||
|
|
||||||
### QG-1 Separation of intent and implementation
|
|
||||||
1. UX intent remains in user-journey.md
|
|
||||||
2. Current versus target implementation mapping remains in schema-mapping.md
|
|
||||||
|
|
||||||
### QG-2 Traceability
|
|
||||||
1. Each accepted behavior maps to at least one future UI action or service call path
|
|
||||||
2. No acceptance criterion contradicts the current deferred-item policy
|
|
||||||
|
|
||||||
### QG-3 First-release constraints
|
|
||||||
1. Linked person during create remains optional
|
|
||||||
2. Recipient and multi-person expansion remain deferred
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
# Document Schema-to-UI Mapping
|
|
||||||
|
|
||||||
Purpose: Map the Document schema to the UI, while clearly separating intended target behavior from current implementation.
|
|
||||||
|
|
||||||
Companion document: user-journey.md
|
|
||||||
Acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Entity Snapshot
|
|
||||||
|
|
||||||
- Table: Document
|
|
||||||
- Primary key: `id` (UUID)
|
|
||||||
- Related entities: `Source`, `Job`, `DocumentPerson`, `Person`
|
|
||||||
- Canonical schema references:
|
|
||||||
- `src/transcription/db/models.py`
|
|
||||||
- `docs/schema_v2.md`
|
|
||||||
|
|
||||||
## 2. Mapping Rules
|
|
||||||
|
|
||||||
This document uses three lenses:
|
|
||||||
1. Intended behavior: what the UX should support.
|
|
||||||
2. Current behavior: what the code supports today.
|
|
||||||
3. Gap to target: what must change to align implementation with the intended UX.
|
|
||||||
|
|
||||||
## 3. Field Inventory
|
|
||||||
|
|
||||||
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| id | UUID | No | `uuid4()` | Hidden, system-managed | Primary key |
|
|
||||||
| name | str | No | None | Shown, editable on create and edit | Required |
|
|
||||||
| document_type | str | Yes | None | Shown, editable on create and edit | Required by intended UX |
|
|
||||||
| document_date | date | Yes | None | Shown, editable | Canonical exact date when present |
|
|
||||||
| document_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
|
|
||||||
| location_created | str | Yes | None | Shown, editable | Optional metadata |
|
|
||||||
| notes | str | Yes | None | Shown, editable | Optional metadata |
|
|
||||||
| archive_identifier | str | Yes | None | Shown, editable | Free text in first release |
|
|
||||||
| created_at | datetime | No | `datetime.now(UTC)` | Hidden or read-only | System-managed |
|
|
||||||
| updated_at | datetime | No | `datetime.now(UTC)` | Hidden or read-only | System-managed |
|
|
||||||
|
|
||||||
## 4. CREATE Mapping
|
|
||||||
|
|
||||||
### 4.1 Intended Create Flow
|
|
||||||
|
|
||||||
Entry point: Document page
|
|
||||||
User action: Create new document
|
|
||||||
Success destination: new Document detail page
|
|
||||||
|
|
||||||
| Field | Intended User Input | Required | Visible | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| name | Text input | Yes | Yes | Primary identifier used by the user |
|
|
||||||
| document_type | Text input | Yes | Yes | Free text in first release |
|
|
||||||
| document_date | Date input | No | Yes | Structured exact date |
|
|
||||||
| document_date_raw | Text input | No | Yes | Approximate or uncertain date |
|
|
||||||
| location_created | Text input | No | Yes | Optional |
|
|
||||||
| notes | Text area | No | Yes | Optional |
|
|
||||||
| archive_identifier | Text input | No | Yes | Free text |
|
|
||||||
| created_at | None | No | No | System-generated |
|
|
||||||
| updated_at | None | No | No | Not used during initial create |
|
|
||||||
|
|
||||||
Related records during intended create:
|
|
||||||
- A related person may optionally be selected or created.
|
|
||||||
- If present, the system creates a `DocumentPerson` link.
|
|
||||||
- Jobs are not created during Document create.
|
|
||||||
- Sources are not created during Document create.
|
|
||||||
|
|
||||||
### 4.2 Current Implementation
|
|
||||||
|
|
||||||
Current entry point: `/documents` page
|
|
||||||
Current user action: open create form, fill metadata, optionally select an existing Person
|
|
||||||
Current backend path: document page submit callback -> `DocumentService.create_document()` -> optional `DocumentService.create_document_person()`
|
|
||||||
|
|
||||||
| Field | Current Value at Create | Source | Visible to User | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| id | Generated UUID | System | No | `Document` default factory in `src/transcription/db/models.py` |
|
|
||||||
| name | User-provided | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| document_type | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| document_date | Parsed from date input or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| document_date_raw | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| location_created | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| notes | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| archive_identifier | User-provided or None | User input | Yes | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| created_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
|
|
||||||
| updated_at | Current UTC timestamp | System | No | Default factory in `src/transcription/db/models.py` |
|
|
||||||
|
|
||||||
Current related-record behavior:
|
|
||||||
- User may optionally select an existing `Person`.
|
|
||||||
- If selected, `DocumentPerson` is created with role `author`.
|
|
||||||
- `Job` is not created during Document create.
|
|
||||||
- `Source` is not created during Document create.
|
|
||||||
|
|
||||||
### 4.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy the intended Create flow, implementation now includes:
|
|
||||||
1. a Document page and dedicated create form
|
|
||||||
2. user-entered metadata fields for `document_type`, `document_date`, `document_date_raw`, `location_created`, `notes`, and `archive_identifier`
|
|
||||||
3. optional Person lookup through a dropdown of existing people
|
|
||||||
4. optional `DocumentPerson` link creation when a person is chosen
|
|
||||||
5. post-submit routing to a Document detail page
|
|
||||||
|
|
||||||
## 5. READ Mapping
|
|
||||||
|
|
||||||
### 5.1 Intended Read Behavior
|
|
||||||
|
|
||||||
On the Document detail page, the user should be able to see:
|
|
||||||
1. Document metadata
|
|
||||||
2. linked people
|
|
||||||
3. a Sources section with empty-state behavior when no sources exist
|
|
||||||
4. a Jobs section with empty-state behavior when no jobs exist
|
|
||||||
5. filtered Jobs and Sources views for the current document
|
|
||||||
|
|
||||||
### 5.2 Current Implementation
|
|
||||||
|
|
||||||
Current Document visibility in the UI is direct.
|
|
||||||
|
|
||||||
| Field | Current Rendering | Visible to User | Notes | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| name | Rendered as title and detail heading | Yes | Dedicated Document detail page | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| id | Not shown as raw id | No | Internal identifier remains hidden | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| document_type | Rendered | Yes | Shown on detail and editable on create/edit | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| document_date | Rendered | Yes | Exact date shown when present | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| document_date_raw | Rendered | Yes | Approximate date shown when present | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| location_created | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| notes | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| archive_identifier | Rendered | Yes | Optional metadata shown | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| created_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
| updated_at | Rendered read-only | Yes | System timestamp shown on detail | `src/transcription/ui/pages/documents_page.py` |
|
|
||||||
|
|
||||||
### 5.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy the intended Read flow, implementation now includes:
|
|
||||||
1. metadata rendering for Document fields
|
|
||||||
2. linked people rendering
|
|
||||||
3. document-scoped Sources and Jobs navigation views
|
|
||||||
|
|
||||||
## 6. UPDATE Mapping
|
|
||||||
|
|
||||||
### 6.1 Intended Update Behavior
|
|
||||||
|
|
||||||
The user should eventually be able to edit Document metadata from the Document detail page or a dedicated edit flow.
|
|
||||||
|
|
||||||
Intended editable fields:
|
|
||||||
- `name`
|
|
||||||
- `document_type`
|
|
||||||
- `document_date`
|
|
||||||
- `document_date_raw`
|
|
||||||
- `location_created`
|
|
||||||
- `notes`
|
|
||||||
- `archive_identifier`
|
|
||||||
|
|
||||||
Intended system-managed fields:
|
|
||||||
- `id`
|
|
||||||
- `created_at`
|
|
||||||
- `updated_at`
|
|
||||||
|
|
||||||
### 6.2 Current Implementation
|
|
||||||
|
|
||||||
| Field | Updatable via UI | Updatable via Service | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| id | No | Practically no | Primary key should be treated as immutable |
|
|
||||||
| name | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
|
|
||||||
| document_type | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
|
|
||||||
| document_date | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
|
|
||||||
| document_date_raw | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
|
|
||||||
| location_created | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
|
|
||||||
| notes | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
|
|
||||||
| archive_identifier | Yes | Yes | Editable from dedicated document edit page via `DocumentService.update_document()` |
|
|
||||||
| created_at | No | Technically yes | Should remain system-managed |
|
|
||||||
| updated_at | No | Technically yes | Should remain system-managed |
|
|
||||||
|
|
||||||
### 6.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation now includes:
|
|
||||||
1. Document edit controls in the UI
|
|
||||||
2. validation and save behavior for Document metadata
|
|
||||||
3. author relationship controls through the edit flow
|
|
||||||
|
|
||||||
## 7. DELETE Mapping
|
|
||||||
|
|
||||||
### 7.1 Intended Delete Behavior
|
|
||||||
|
|
||||||
The UI should eventually provide a delete action for Document with guardrails.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. A Document can be deleted when it has no attached Jobs and no attached Sources.
|
|
||||||
2. If dependent Jobs or Sources exist, the UI should block deletion and explain that those related records must be removed first.
|
|
||||||
3. Delete confirmation should make it clear that the action is permanent.
|
|
||||||
|
|
||||||
### 7.2 Current Implementation
|
|
||||||
|
|
||||||
| Action | UI Exposed | Backend Capability | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Delete Document | Yes | Yes | `DocumentService.delete_document()` exists and the UI blocks dependent deletes |
|
|
||||||
|
|
||||||
### 7.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation includes:
|
|
||||||
1. a Document delete control in the UI
|
|
||||||
2. pre-delete dependency checks for Jobs and Sources
|
|
||||||
3. user-facing messaging when deletion is blocked
|
|
||||||
4. confirmation UX for successful delete attempts
|
|
||||||
|
|
||||||
## 8. Hidden and System-Managed Fields
|
|
||||||
|
|
||||||
| Field | Category | Why Hidden or Protected |
|
|
||||||
|---|---|---|
|
|
||||||
| id | System-managed | Internal identifier |
|
|
||||||
| created_at | System-managed | Audit timestamp |
|
|
||||||
| updated_at | System-managed | Audit timestamp |
|
|
||||||
|
|
||||||
## 9. Traceability Anchors
|
|
||||||
|
|
||||||
Schema and models:
|
|
||||||
- `docs/schema_v2.md`
|
|
||||||
- `src/transcription/db/models.py`
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
- `src/transcription/ui/pages/documents_page.py`
|
|
||||||
- `src/transcription/services/documents.py`
|
|
||||||
- `src/transcription/services/store.py`
|
|
||||||
- `src/transcription/ui/pages/jobs_page.py`
|
|
||||||
- `src/transcription/ui/components/transcript.py`
|
|
||||||
|
|
||||||
Companion UX spec:
|
|
||||||
- `docs/ui/entities/document/user-journey.md`
|
|
||||||
|
|
||||||
## 10. Acceptance Checklist Summary
|
|
||||||
|
|
||||||
- Every Document schema field appears in the field inventory.
|
|
||||||
- Intended Create behavior matches the companion user journey.
|
|
||||||
- Current Create behavior reflects the existing upload-driven implementation.
|
|
||||||
- Gaps between intended and current behavior are explicit.
|
|
||||||
- Read, Update, and Delete sections distinguish target behavior from current code.
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
# Document User Journey
|
|
||||||
|
|
||||||
Purpose: Define how a user should interact with the UI to create and manage a Document record, including expected inputs, validation, results, and related record creation.
|
|
||||||
|
|
||||||
Scope: This document describes intended user interaction for the Document UI. It is the UX contract for the Document entity.
|
|
||||||
|
|
||||||
Companion schema mapping: schema-mapping.md
|
|
||||||
Companion acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
A Document represents a real historical artifact the user wants to describe, organize, and eventually transcribe. The user should be able to create a Document before uploading or linking any source files.
|
|
||||||
|
|
||||||
Creating a Document is a metadata-first workflow:
|
|
||||||
1. The user opens the Document page.
|
|
||||||
2. The user selects Create new document.
|
|
||||||
3. The user enters descriptive metadata about the document.
|
|
||||||
4. The user optionally selects one related person from the existing Person list.
|
|
||||||
5. The system creates the Document.
|
|
||||||
6. If a person was selected, the system links that Person to the Document through DocumentPerson with author role.
|
|
||||||
7. The user sees a success state and lands on the new Document detail page.
|
|
||||||
|
|
||||||
## 2. User Goal
|
|
||||||
|
|
||||||
The user wants to create a new Document record that:
|
|
||||||
1. Has enough metadata to identify the historical artifact.
|
|
||||||
2. Can optionally be linked to a person.
|
|
||||||
3. Exists independently of transcription jobs and source uploads.
|
|
||||||
4. Is ready for later steps such as adding sources, starting jobs, and reviewing transcriptions.
|
|
||||||
|
|
||||||
## 3. Page Model
|
|
||||||
|
|
||||||
### 3.1 Document Page
|
|
||||||
|
|
||||||
The Document page is the general UI surface where users manage documents.
|
|
||||||
|
|
||||||
It should support:
|
|
||||||
1. listing or locating existing documents
|
|
||||||
2. starting the Create new document flow
|
|
||||||
3. navigating into a specific Document after it exists
|
|
||||||
|
|
||||||
### 3.2 Document Detail Page
|
|
||||||
|
|
||||||
The Document detail page is the page for one specific Document after it has been created.
|
|
||||||
|
|
||||||
It should show:
|
|
||||||
1. the Document metadata
|
|
||||||
2. related people linked to the Document
|
|
||||||
3. a linked-author summary when available
|
|
||||||
4. document-scoped navigation links for Sources and Jobs
|
|
||||||
5. filtered views for sources and jobs linked to the current document
|
|
||||||
6. primary actions + Add Source and + Add Job
|
|
||||||
|
|
||||||
## 4. Entry Point
|
|
||||||
|
|
||||||
Entry point: Document page
|
|
||||||
|
|
||||||
Primary action: Create new document
|
|
||||||
|
|
||||||
Expected UI affordance:
|
|
||||||
1. A visible button, link, or primary action labeled Create new document.
|
|
||||||
2. Activation opens a dedicated form view, modal, or detail panel for creating a Document.
|
|
||||||
|
|
||||||
Preferred first implementation:
|
|
||||||
1. A dedicated Document create page or panel.
|
|
||||||
2. A simple form with explicit labels.
|
|
||||||
3. Existing Person records should be selectable through a dropdown.
|
|
||||||
4. Text inputs are acceptable for the remaining fields in first release.
|
|
||||||
|
|
||||||
## 5. Create Document Form
|
|
||||||
|
|
||||||
The Create Document form should contain the following fields.
|
|
||||||
|
|
||||||
### 5.1 Required Fields
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Document name | name | Text input | Yes | Examples: Pioneer Days, Letter from Zenna to Omie |
|
|
||||||
| Document type | document_type | Text input | Yes | Examples: book, letter, enlistment papers, military record, other |
|
|
||||||
|
|
||||||
### 5.2 Date Fields
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Exact date | document_date | Date input | No | Use when the exact date is known |
|
|
||||||
| Approximate date | document_date_raw | Text input | No | Use when exact date is uncertain, approximate, or unknown |
|
|
||||||
|
|
||||||
Date handling rule:
|
|
||||||
1. The form may allow both fields to be entered.
|
|
||||||
2. If both fields are entered, `document_date` is the canonical structured date.
|
|
||||||
3. `document_date_raw` may still be retained as the user-entered descriptive form.
|
|
||||||
4. The UI should explain the distinction clearly.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
1. Exact date: `07/13/1885`
|
|
||||||
2. Approximate date: `c. 1885`
|
|
||||||
3. Approximate date: `Fall 1925`
|
|
||||||
4. Approximate date: `unknown`
|
|
||||||
|
|
||||||
### 5.3 Optional Metadata Fields
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Document location | location_created | Text input | No | Where the document was created |
|
|
||||||
| Notes | notes | Multiline text area | No | Freeform notes about the document |
|
|
||||||
| Archive identifier | archive_identifier | Text input | No | Free text for now; may represent inventory code, storage reference, or repository note |
|
|
||||||
|
|
||||||
Archive identifier guidance:
|
|
||||||
1. First implementation should treat this as free text.
|
|
||||||
2. Helper text may explain that this can store a repository code, box or folder reference, or storage note.
|
|
||||||
|
|
||||||
### 5.4 System Fields
|
|
||||||
|
|
||||||
| Schema Field | User Editable | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| created_at | No | System-generated at creation time |
|
|
||||||
| updated_at | No | Not user-entered during creation |
|
|
||||||
|
|
||||||
### 5.5 Optional Related Person
|
|
||||||
|
|
||||||
The Create Document flow may optionally link one related person during first release.
|
|
||||||
|
|
||||||
| UI Label | Schema Area | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Related person | Person -> DocumentPerson | Dropdown select | No | Selects an existing Person and links as author when saved |
|
|
||||||
|
|
||||||
First release behavior:
|
|
||||||
1. The user may save a Document without linking any person.
|
|
||||||
2. If a person is linked during create, only one person is supported in first release.
|
|
||||||
3. The selected person is linked as author.
|
|
||||||
4. Additional people and recipient workflows are deferred to a future revision.
|
|
||||||
|
|
||||||
### 5.6 Related Records Not Created Directly Here
|
|
||||||
|
|
||||||
| Related Area | Included in Document Create | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Jobs | No | Jobs are created later when transcription work begins |
|
|
||||||
| Sources | No | Sources are added later as uploaded pages or files |
|
|
||||||
|
|
||||||
## 6. Related Person Workflow
|
|
||||||
|
|
||||||
### 6.1 User Intent
|
|
||||||
|
|
||||||
The user should be able to:
|
|
||||||
1. select an existing Person to associate with the Document
|
|
||||||
2. change the associated Person from the Document edit flow
|
|
||||||
3. save the Document even if no person is linked
|
|
||||||
|
|
||||||
### 6.2 Data Model Interpretation
|
|
||||||
|
|
||||||
Person selection source:
|
|
||||||
1. The UI should select from Person records.
|
|
||||||
2. If a person is linked, the system should create a DocumentPerson record.
|
|
||||||
3. Role handling for non-author document relationships is deferred.
|
|
||||||
4. If the first release needs a persisted role immediately, the role can default to `author` until the relationship model is broadened.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
1. The user does not choose from DocumentPerson records.
|
|
||||||
2. DocumentPerson is the relationship created after the Person is chosen or created.
|
|
||||||
|
|
||||||
### 6.3 Related Person UI Behavior
|
|
||||||
|
|
||||||
Minimum acceptable first implementation:
|
|
||||||
1. Dropdown of existing Person records.
|
|
||||||
2. Clear display of the selected related person before submit.
|
|
||||||
3. Ability to change or clear the selected person in the Document edit flow.
|
|
||||||
4. A Create new item option in the author selector that routes to Person create.
|
|
||||||
5. A visible Create new person link near the selector.
|
|
||||||
|
|
||||||
If the person does not exist:
|
|
||||||
1. The user can use Create new item from the author selector and continue from Person create.
|
|
||||||
2. The Document create flow links existing Person records after selection.
|
|
||||||
|
|
||||||
## 7. Validation Rules
|
|
||||||
|
|
||||||
### 7.1 Required Field Validation
|
|
||||||
|
|
||||||
The form must reject submission if:
|
|
||||||
1. `name` is empty
|
|
||||||
2. `document_type` is empty
|
|
||||||
|
|
||||||
### 7.2 Date Validation
|
|
||||||
|
|
||||||
The form should allow:
|
|
||||||
1. `document_date` only
|
|
||||||
2. `document_date_raw` only
|
|
||||||
3. both `document_date` and `document_date_raw`
|
|
||||||
4. neither date field
|
|
||||||
|
|
||||||
If both are present:
|
|
||||||
1. `document_date` is treated as the canonical exact date
|
|
||||||
2. `document_date_raw` is retained as descriptive context
|
|
||||||
|
|
||||||
### 7.3 Related Person Validation
|
|
||||||
|
|
||||||
The form must not require a linked person in first release.
|
|
||||||
|
|
||||||
If a related person is selected or created:
|
|
||||||
1. the selected value must resolve to a valid Person record before final save
|
|
||||||
2. the DocumentPerson link must not be partially persisted on failure
|
|
||||||
|
|
||||||
## 8. Submission Behavior
|
|
||||||
|
|
||||||
When the user submits the form, the system should perform these logical steps:
|
|
||||||
1. validate form inputs
|
|
||||||
2. create the Document record
|
|
||||||
3. create one DocumentPerson record only if an existing related person was selected
|
|
||||||
4. persist intended records successfully before reporting success to the user
|
|
||||||
|
|
||||||
Expected write sequence:
|
|
||||||
1. insert Document
|
|
||||||
2. insert DocumentPerson link only if a person is linked
|
|
||||||
|
|
||||||
Recommended transactional behavior:
|
|
||||||
1. Document and optional DocumentPerson writes should succeed or fail together
|
|
||||||
2. Person creation is a separate workflow reached from the author selector and is not part of the same transaction
|
|
||||||
|
|
||||||
## 9. Expected Result After Success
|
|
||||||
|
|
||||||
After successful creation, the user should expect to see:
|
|
||||||
1. confirmation that the Document was created successfully
|
|
||||||
2. the Document name displayed in the resulting UI state
|
|
||||||
3. the Document metadata displayed on the new Document detail page
|
|
||||||
4. any linked person displayed in the resulting UI state
|
|
||||||
5. a Sources section showing an empty state when no sources exist yet
|
|
||||||
6. a Jobs section showing an empty state when no jobs exist yet
|
|
||||||
7. a clear next step, such as adding source files
|
|
||||||
|
|
||||||
Recommended success route:
|
|
||||||
1. navigate to the new Document detail page
|
|
||||||
2. show Document summary metadata
|
|
||||||
3. show linked people section
|
|
||||||
4. show empty-state placeholders for Sources and Jobs
|
|
||||||
|
|
||||||
## 10. Expected Result After Failure
|
|
||||||
|
|
||||||
If submission fails, the user should expect:
|
|
||||||
1. clear error messaging
|
|
||||||
2. field-level validation feedback where applicable
|
|
||||||
3. no false success message
|
|
||||||
4. preservation of entered form values when possible
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
1. missing required name
|
|
||||||
2. missing required document type
|
|
||||||
3. failed person creation
|
|
||||||
4. failed DocumentPerson link creation
|
|
||||||
5. database or server error
|
|
||||||
|
|
||||||
## 11. Read Document Journey
|
|
||||||
|
|
||||||
### 11.1 User Intent
|
|
||||||
|
|
||||||
The user wants to open a specific Document and quickly understand:
|
|
||||||
1. what the document is
|
|
||||||
2. which people are linked to it
|
|
||||||
3. whether sources exist
|
|
||||||
4. whether jobs exist
|
|
||||||
5. what the next action should be
|
|
||||||
|
|
||||||
### 11.2 Entry Points
|
|
||||||
|
|
||||||
A user can reach a Document detail page by:
|
|
||||||
1. selecting a document from the Document page list
|
|
||||||
2. being redirected after successfully creating a new document
|
|
||||||
3. following a direct link to a known Document record
|
|
||||||
|
|
||||||
### 11.3 Document Detail Layout
|
|
||||||
|
|
||||||
The Document detail page should include:
|
|
||||||
1. a header area with document name, document type, and key date values
|
|
||||||
2. a metadata section with location_created, notes, and archive_identifier
|
|
||||||
3. System metadata where created_at and updated_at are shown as read-only values
|
|
||||||
4. a related people section
|
|
||||||
5. a Sources section
|
|
||||||
6. a Jobs section
|
|
||||||
|
|
||||||
The Document detail page should support:
|
|
||||||
1. empty-state messaging when no related records exist
|
|
||||||
2. clear next actions from each empty state
|
|
||||||
3. filtered Sources and Jobs views scoped to the current document
|
|
||||||
|
|
||||||
### 11.4 Read Empty States
|
|
||||||
|
|
||||||
If no related records exist:
|
|
||||||
1. People section says no linked people yet
|
|
||||||
2. Sources section says no sources added yet
|
|
||||||
3. Jobs section says no jobs created yet
|
|
||||||
4. each section presents one clear next action
|
|
||||||
|
|
||||||
### 11.5 Read Success Criteria
|
|
||||||
|
|
||||||
A successful Read experience means:
|
|
||||||
1. The user can identify the Document immediately
|
|
||||||
2. The user can see whether work has started
|
|
||||||
3. The user can navigate directly to document-scoped Jobs and Sources workflows
|
|
||||||
|
|
||||||
## 12. Update Document Journey
|
|
||||||
|
|
||||||
### 12.1 User Intent
|
|
||||||
|
|
||||||
The user wants to correct or enrich metadata after creation without touching jobs or source transcriptions directly.
|
|
||||||
|
|
||||||
### 12.2 Update Entry Point
|
|
||||||
|
|
||||||
From the Document detail page:
|
|
||||||
1. The user selects Edit document
|
|
||||||
2. UI opens edit mode or a dedicated edit view
|
|
||||||
|
|
||||||
### 12.3 Editable Fields
|
|
||||||
|
|
||||||
First release editable fields:
|
|
||||||
1. name
|
|
||||||
2. document_type
|
|
||||||
3. document_date
|
|
||||||
4. document_date_raw
|
|
||||||
5. location_created
|
|
||||||
6. notes
|
|
||||||
7. archive_identifier
|
|
||||||
|
|
||||||
Read-only or system-managed fields:
|
|
||||||
1. id
|
|
||||||
2. created_at
|
|
||||||
3. updated_at
|
|
||||||
|
|
||||||
### 12.4 Update Validation Rules
|
|
||||||
|
|
||||||
1. name remains required
|
|
||||||
2. document_type remains required
|
|
||||||
3. document_date and document_date_raw may both be present
|
|
||||||
4. if both date fields are present, document_date remains canonical
|
|
||||||
5. validation errors should be shown inline and block save
|
|
||||||
|
|
||||||
### 12.5 Update Save Behavior
|
|
||||||
|
|
||||||
On save:
|
|
||||||
1. system validates form data
|
|
||||||
2. system persists Document updates
|
|
||||||
3. updated_at is refreshed by system policy
|
|
||||||
4. UI shows a confirmation message
|
|
||||||
5. user remains on Document detail page with refreshed values
|
|
||||||
|
|
||||||
### 12.6 Update Failure Behavior
|
|
||||||
|
|
||||||
If save fails:
|
|
||||||
1. Show a clear error message
|
|
||||||
2. keep user edits in form where possible
|
|
||||||
3. do not show stale success messaging
|
|
||||||
4. Allow retry without losing context
|
|
||||||
|
|
||||||
## 13. Delete Document Journey
|
|
||||||
|
|
||||||
### 13.1 User Intent
|
|
||||||
|
|
||||||
The user wants to remove a Document only when it is safe and unambiguous.
|
|
||||||
|
|
||||||
### 13.2 Delete Entry Point
|
|
||||||
|
|
||||||
From the Document detail page:
|
|
||||||
1. The user selects Delete document
|
|
||||||
2. UI opens a confirmation dialog explaining permanence
|
|
||||||
|
|
||||||
### 13.3 Delete Guardrails
|
|
||||||
|
|
||||||
Delete is allowed only when:
|
|
||||||
1. the Document has no related Source records
|
|
||||||
2. the Document has no related Job records
|
|
||||||
|
|
||||||
Delete is blocked when:
|
|
||||||
1. any Source exists for the Document
|
|
||||||
2. any Job exists for the Document
|
|
||||||
|
|
||||||
### 13.4 Blocked Delete UX
|
|
||||||
|
|
||||||
When blocked:
|
|
||||||
1. Show an explicit reason that related Jobs or Sources exist
|
|
||||||
2. Show which dependency types are present
|
|
||||||
3. provide links to filtered Sources and Jobs for cleanup
|
|
||||||
4. keep the Document unchanged
|
|
||||||
|
|
||||||
### 13.5 Allowed Delete UX
|
|
||||||
|
|
||||||
When allowed:
|
|
||||||
1. Show final confirmation with document name
|
|
||||||
2. perform delete
|
|
||||||
3. show success confirmation
|
|
||||||
4. return user to Document page list
|
|
||||||
|
|
||||||
### 13.6 Delete Failure Behavior
|
|
||||||
|
|
||||||
If delete fails due to system error:
|
|
||||||
1. Show a clear error message
|
|
||||||
2. keep user on Document detail page
|
|
||||||
3. preserve ability to retry
|
|
||||||
|
|
||||||
## 14. Non-Goals for This Flow
|
|
||||||
|
|
||||||
The Document journey does not define:
|
|
||||||
1. Source upload field-level UX
|
|
||||||
2. Job execution internals
|
|
||||||
3. revision editor behavior for transcriptions
|
|
||||||
4. multi-person recipient workflows in first release
|
|
||||||
|
|
||||||
## 15. Relationship to Other Workflows
|
|
||||||
|
|
||||||
This Document workflow integrates with:
|
|
||||||
1. Sources workflow for adding pages or files to the document
|
|
||||||
2. Jobs workflow for transcription execution
|
|
||||||
3. Person workflow for future expansion beyond one optional linked person
|
|
||||||
|
|
||||||
## 16. Relationship to Schema Mapping
|
|
||||||
|
|
||||||
This document is the intended UX contract.
|
|
||||||
|
|
||||||
The companion schema-mapping document should answer:
|
|
||||||
1. which schema field appears on which screen
|
|
||||||
2. whether the field is currently implemented
|
|
||||||
3. whether the field is hidden, editable, or system-managed
|
|
||||||
4. what the implementation gap is between intended UX and current code
|
|
||||||
|
|
||||||
## 17. Deferred Items
|
|
||||||
|
|
||||||
These topics are intentionally deferred to future revisions:
|
|
||||||
1. multiple linked people during create and update
|
|
||||||
2. recipient support during create and update
|
|
||||||
3. a broader role model for non-author document relationships
|
|
||||||
4. filtered Jobs and Sources list navigation details
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
# JobSource Schema-to-UI Mapping
|
|
||||||
|
|
||||||
Purpose: Map the JobSource schema to UI-facing workflows, while separating intended target behavior from current implementation.
|
|
||||||
|
|
||||||
Supporting entity note: JobSource does not currently have a standalone UI surface.
|
|
||||||
|
|
||||||
## 1. Entity Snapshot
|
|
||||||
|
|
||||||
- Table: job_source
|
|
||||||
- Primary key: id (UUID)
|
|
||||||
- Related entities: Job, Source
|
|
||||||
- Canonical schema references:
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
- docs/schema_v2.md
|
|
||||||
|
|
||||||
## 2. Mapping Rules
|
|
||||||
|
|
||||||
This document uses three lenses:
|
|
||||||
1. Intended behavior: what user-facing workflows should support indirectly.
|
|
||||||
2. Current behavior: what code supports today.
|
|
||||||
3. Gap to target: what must change to align implementation with intended UX.
|
|
||||||
|
|
||||||
## 3. Field Inventory
|
|
||||||
|
|
||||||
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
|
|
||||||
| job_id | UUID FK | No | None | Context-managed | Selected Job context |
|
|
||||||
| source_id | UUID FK | No | None | Context-managed | Selected Source context |
|
|
||||||
| status | enum JobSourceStatus | No | pending | Shown in job detail source context | Per-source execution state |
|
|
||||||
| raw_transcription | str | Yes | None | Shown read-only in review context | Machine output per source |
|
|
||||||
| ai_metadata | JSONB/JSON | Yes | None | Hidden or advanced diagnostics | Provider metadata |
|
|
||||||
| raw_api_response | JSONB/JSON | Yes | None | Hidden or advanced diagnostics | Low-level provider payload |
|
|
||||||
| error_detail | str | Yes | None | Shown when status is failed | Execution failure details |
|
|
||||||
| executed_at | datetime | No | datetime.now(UTC) | Shown read-only | Execution timestamp |
|
|
||||||
|
|
||||||
## 4. CREATE Mapping
|
|
||||||
|
|
||||||
### 4.1 Intended Create Flow
|
|
||||||
|
|
||||||
JobSource creation is indirect through Job and transcription workflows:
|
|
||||||
1. Job create flow should create a JobSource row for each uploaded source page.
|
|
||||||
2. Processing workflow may create missing JobSource rows when persisting transcription output.
|
|
||||||
|
|
||||||
| Field | Intended User Input | Required | Visible | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| job_id | None | Yes | No | Derived from active Job |
|
|
||||||
| source_id | None | Yes | No | Derived from created/selected Source |
|
|
||||||
| status | None | No | Indirectly | Defaults to pending at create |
|
|
||||||
| raw_transcription | None | No | No at create | Filled after processing |
|
|
||||||
| ai_metadata | None | No | No | Operational metadata |
|
|
||||||
| raw_api_response | None | No | No | Operational payload |
|
|
||||||
| error_detail | None | No | No at create | Filled on failure |
|
|
||||||
| executed_at | None | No | No | System-generated |
|
|
||||||
|
|
||||||
### 4.2 Current Implementation
|
|
||||||
|
|
||||||
Current entry points:
|
|
||||||
1. upload create path adds pending JobSource link in _create_upload_records().
|
|
||||||
2. transcription update path creates or updates JobSource row during output persistence.
|
|
||||||
|
|
||||||
Current backend paths:
|
|
||||||
1. src/transcription/services/store.py -> _create_upload_records()
|
|
||||||
2. src/transcription/services/transcription.py -> update_job_transcription()
|
|
||||||
|
|
||||||
| Field | Current Value at Create/Update | Source | Visible to User | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| id | Generated UUID | System | No | src/transcription/db/models.py |
|
|
||||||
| job_id | Caller or workflow derived | Service/workflow | Indirectly | store.py, transcription.py |
|
|
||||||
| source_id | Caller or workflow derived | Service/workflow | Indirectly | store.py, transcription.py |
|
|
||||||
| status | pending at create, transcribed or failed on update | Workflow logic | Partial | transcription.py |
|
|
||||||
| raw_transcription | Set on successful transcription update | Workflow/provider result | Yes in review context | transcription.py, jobs UI |
|
|
||||||
| ai_metadata | Available in model; not currently filled in update path | Workflow potential | No | models.py, transcription.py |
|
|
||||||
| raw_api_response | Available in model; not currently filled in update path | Workflow potential | No | models.py, transcription.py |
|
|
||||||
| error_detail | Set on failed transcription update | Workflow/provider error | Partial | transcription.py |
|
|
||||||
| executed_at | Set at row creation and refreshed on updates | System/workflow | Partial | models.py, transcription.py |
|
|
||||||
|
|
||||||
### 4.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended supporting behavior, implementation must add:
|
|
||||||
1. explicit per-source status display for all linked sources in Job detail.
|
|
||||||
2. clear surfaced error_detail for failed source executions.
|
|
||||||
3. optional diagnostics surface for ai_metadata/raw_api_response when needed.
|
|
||||||
4. first-class multi-source create path from Job create flow.
|
|
||||||
|
|
||||||
## 5. READ Mapping
|
|
||||||
|
|
||||||
### 5.1 Intended Read Behavior
|
|
||||||
|
|
||||||
Users should see JobSource data indirectly in job detail and review workflows:
|
|
||||||
1. per-source execution status.
|
|
||||||
2. per-source raw transcription output.
|
|
||||||
3. per-source failure details where applicable.
|
|
||||||
4. execution timestamp context.
|
|
||||||
|
|
||||||
### 5.2 Current Implementation
|
|
||||||
|
|
||||||
Current read behavior is partial and job-detail-centric.
|
|
||||||
|
|
||||||
| Field | Current Rendering | Visible to User | Notes | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| status | Job-level status is visible; source-level status is limited | Partial | Source-level status not fully surfaced as a dedicated list | src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| raw_transcription | Original transcription card is visible | Yes | Primary source is shown in current detail flow | src/transcription/ui/components/transcript.py |
|
|
||||||
| error_detail | Not prominently surfaced in current detail UI | Partial | Stored in JobSource rows during failures | src/transcription/services/transcription.py |
|
|
||||||
| executed_at | Not first-class rendered | Partial | Available in model for future display | src/transcription/db/models.py |
|
|
||||||
|
|
||||||
Service read/query coverage:
|
|
||||||
1. read_job_source() reads one row with source relation.
|
|
||||||
2. list_job_sources() lists rows and supports job_id filtering.
|
|
||||||
|
|
||||||
### 5.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended read behavior, implementation must add:
|
|
||||||
1. source-level execution table in Job detail.
|
|
||||||
2. explicit failed-source messaging from error_detail.
|
|
||||||
3. multi-source navigation in job review UI.
|
|
||||||
|
|
||||||
## 6. UPDATE Mapping
|
|
||||||
|
|
||||||
### 6.1 Intended Update Behavior
|
|
||||||
|
|
||||||
JobSource updates are workflow-managed, not directly user-edited.
|
|
||||||
|
|
||||||
Intended user-editable fields:
|
|
||||||
- none in first-release behavior
|
|
||||||
|
|
||||||
Workflow-managed fields:
|
|
||||||
- status
|
|
||||||
- raw_transcription
|
|
||||||
- error_detail
|
|
||||||
- executed_at
|
|
||||||
- optional diagnostics payload fields
|
|
||||||
|
|
||||||
### 6.2 Current Implementation
|
|
||||||
|
|
||||||
| Field | Updatable via UI | Updatable via Service/Workflow | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| status | No | Yes | Set by transcription update and job lifecycle handling |
|
|
||||||
| raw_transcription | No | Yes | Persisted in update_job_transcription() |
|
|
||||||
| error_detail | No | Yes | Persisted on transcription failure |
|
|
||||||
| executed_at | No | Yes | Updated when existing JobSource rows are changed |
|
|
||||||
| ai_metadata/raw_api_response | No | Potentially yes | Model supports them; active population is limited |
|
|
||||||
|
|
||||||
### 6.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation should add:
|
|
||||||
1. clearer job-detail visualization of per-source execution updates.
|
|
||||||
2. optional operator diagnostics views for advanced troubleshooting.
|
|
||||||
|
|
||||||
## 7. DELETE Mapping
|
|
||||||
|
|
||||||
### 7.1 Intended Delete Behavior
|
|
||||||
|
|
||||||
JobSource deletion should be policy-driven and usually tied to Job/Source lifecycle operations.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. direct user deletion is not required in first-release behavior.
|
|
||||||
2. cleanup should occur through Job or Source deletion policies.
|
|
||||||
|
|
||||||
### 7.2 Current Implementation
|
|
||||||
|
|
||||||
| Action | UI Exposed | Backend Capability | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Delete JobSource row | No | Yes | TranscriptionService.delete_job_source() exists |
|
|
||||||
|
|
||||||
### 7.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation may add:
|
|
||||||
1. maintenance tooling for cleanup operations.
|
|
||||||
2. policy-aware cascade guidance in Job and Source delete flows.
|
|
||||||
|
|
||||||
## 8. Hidden and System-Managed Fields
|
|
||||||
|
|
||||||
| Field | Category | Why Hidden or Protected |
|
|
||||||
|---|---|---|
|
|
||||||
| id | System-managed | Internal identifier |
|
|
||||||
| job_id | Context-managed | Derived from Job context |
|
|
||||||
| source_id | Context-managed | Derived from Source context |
|
|
||||||
| ai_metadata | Operational metadata | Advanced diagnostics payload |
|
|
||||||
| raw_api_response | Operational metadata | Raw provider response payload |
|
|
||||||
| executed_at | System-managed | Execution timestamp |
|
|
||||||
|
|
||||||
## 9. Traceability Anchors
|
|
||||||
|
|
||||||
Schema and models:
|
|
||||||
- docs/schema_v2.md
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
- src/transcription/services/store.py
|
|
||||||
- src/transcription/services/transcription.py
|
|
||||||
- src/transcription/services/workflows.py
|
|
||||||
- src/transcription/ui/pages/jobs_page.py
|
|
||||||
- src/transcription/ui/components/transcript.py
|
|
||||||
- tests/services/test_v2_crud.py
|
|
||||||
|
|
||||||
Related user-facing workflows:
|
|
||||||
- docs/ui/entities/job/user-journey.md
|
|
||||||
- docs/ui/entities/source/user-journey.md
|
|
||||||
|
|
||||||
## 10. Coverage Summary
|
|
||||||
|
|
||||||
- Every JobSource schema field appears in the field inventory.
|
|
||||||
- Intended behavior is defined as supporting workflow behavior rather than standalone UI.
|
|
||||||
- Current behavior reflects workflow/service-driven CRUD with partial job-detail visibility.
|
|
||||||
- Gaps between intended and current behavior are explicit.
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# Job Acceptance Criteria
|
|
||||||
|
|
||||||
Purpose: Define implementation-ready acceptance criteria for Job Create, Read, Update, and Delete workflows.
|
|
||||||
|
|
||||||
Companion documents:
|
|
||||||
- docs/ui/entities/job/user-journey.md
|
|
||||||
- docs/ui/entities/job/schema-mapping.md
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This checklist covers:
|
|
||||||
1. Create flow
|
|
||||||
2. Read flow
|
|
||||||
3. Update flow
|
|
||||||
4. Delete flow
|
|
||||||
|
|
||||||
This checklist does not cover:
|
|
||||||
1. provider-specific transcription internals
|
|
||||||
2. advanced workflow scheduling and queue orchestration controls
|
|
||||||
3. multi-job bulk operations
|
|
||||||
|
|
||||||
## Create Acceptance Criteria
|
|
||||||
|
|
||||||
### CR-1 Job creation entry
|
|
||||||
1. Given the user is on the Jobs page
|
|
||||||
2. When the user selects Create job
|
|
||||||
3. Then the user is taken to Job detail/create mode
|
|
||||||
|
|
||||||
### CR-2 Required create values
|
|
||||||
1. document_id must be selected before submit
|
|
||||||
2. at least one source file must be uploaded before submit
|
|
||||||
3. each uploaded file creates a Source linked to the selected Document
|
|
||||||
4. each created Source is linked to the new Job through JobSource
|
|
||||||
|
|
||||||
### CR-3 Source ordering behavior
|
|
||||||
1. Given multi-file or folder upload
|
|
||||||
2. When source records are created
|
|
||||||
3. Then page ordering follows alphabetical order of original filenames
|
|
||||||
4. Then helper text explains how filename conventions control ordering
|
|
||||||
|
|
||||||
### CR-4 Provider/model/prompt visibility
|
|
||||||
1. provider, model, and prompt_name are visible in create flow when known
|
|
||||||
2. provider, model, and prompt_name are visible in detail flow when known
|
|
||||||
3. if values are unknown at create time, UI shows clear unknown or pending state without blocking submit
|
|
||||||
|
|
||||||
### CR-5 Successful create outcome
|
|
||||||
1. Given valid inputs
|
|
||||||
2. When the user submits create
|
|
||||||
3. Then the Job record is created and linked to selected Document
|
|
||||||
4. Then source and JobSource records are created for uploads
|
|
||||||
5. Then job status is queued or processing based on execution timing
|
|
||||||
6. Then the user is routed to Job detail mode
|
|
||||||
|
|
||||||
### CR-6 Create failure outcome
|
|
||||||
1. Given create validation or persistence failure
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then no false success feedback is shown
|
|
||||||
4. Then entered selections are preserved where possible
|
|
||||||
5. Then retry path remains available
|
|
||||||
|
|
||||||
## Read Acceptance Criteria
|
|
||||||
|
|
||||||
### RD-1 Jobs list retrieval
|
|
||||||
1. Given one or more jobs exist
|
|
||||||
2. When the user opens the Jobs page
|
|
||||||
3. Then all jobs are listed in a table or equivalent list surface
|
|
||||||
|
|
||||||
### RD-2 Jobs list fields
|
|
||||||
1. Jobs list shows job id
|
|
||||||
2. Jobs list shows status
|
|
||||||
3. Jobs list shows created or updated timestamps
|
|
||||||
4. Jobs list shows retry_count when available
|
|
||||||
5. Jobs list provides navigation to Job detail for each row
|
|
||||||
|
|
||||||
### RD-3 Job detail retrieval
|
|
||||||
1. Given a valid job id
|
|
||||||
2. When the user opens Job detail
|
|
||||||
3. Then job metadata for that record only is shown
|
|
||||||
4. Then document-scoped navigation links for Sources and Jobs are shown
|
|
||||||
|
|
||||||
### RD-4 Detail execution context visibility
|
|
||||||
1. provider, model, and prompt_name are displayed when known
|
|
||||||
2. status lifecycle value is visible
|
|
||||||
3. source-level transcription and revision context is available through Source detail navigation from Job detail
|
|
||||||
|
|
||||||
### RD-5 Missing and invalid id states
|
|
||||||
1. Given an invalid job id format
|
|
||||||
2. Then UI shows invalid job id state without crashing
|
|
||||||
3. Given a valid but nonexistent job id
|
|
||||||
4. Then UI shows job not found state without crashing
|
|
||||||
|
|
||||||
## Update Acceptance Criteria
|
|
||||||
|
|
||||||
### UP-1 Revision edit entry
|
|
||||||
1. Given a job detail page
|
|
||||||
2. When the user opens the page
|
|
||||||
3. Then navigation links to job-scoped Sources are available
|
|
||||||
4. Then source rows can open Source detail revision workflow
|
|
||||||
|
|
||||||
### UP-2 Revision validation
|
|
||||||
1. revision save blocks empty trimmed text and shows warning feedback
|
|
||||||
|
|
||||||
### UP-3 Successful revision save
|
|
||||||
1. Source detail save persists revised text and shows success feedback
|
|
||||||
|
|
||||||
### UP-4 Revision save failure
|
|
||||||
1. Source detail save failure shows clear error feedback with retry path
|
|
||||||
|
|
||||||
### UP-5 Job lifecycle state update visibility
|
|
||||||
1. status changes from queued to processing to terminal states are reflected in UI
|
|
||||||
2. retry_count updates are reflected when retry logic runs
|
|
||||||
3. users cannot directly edit lifecycle state fields in first release
|
|
||||||
|
|
||||||
## Delete Acceptance Criteria
|
|
||||||
|
|
||||||
### DL-1 Delete entry and confirmation
|
|
||||||
1. Given a job detail context
|
|
||||||
2. When the user opens job delete page
|
|
||||||
3. Then a permanent-action confirmation is shown for non-processing jobs
|
|
||||||
|
|
||||||
### DL-2 Dependency guardrails
|
|
||||||
1. Delete is blocked while job status is processing
|
|
||||||
2. Related JobSource links are removed as part of allowed delete flow
|
|
||||||
|
|
||||||
### DL-3 Blocked delete behavior
|
|
||||||
1. When blocked, the UI shows clear processing-state guidance
|
|
||||||
2. The user is offered navigation back to job or jobs list
|
|
||||||
|
|
||||||
### DL-4 Successful delete
|
|
||||||
1. Given an allowed delete
|
|
||||||
2. When the user confirms delete
|
|
||||||
3. Then the job is removed and success feedback is shown
|
|
||||||
4. Then the user is returned to Jobs list
|
|
||||||
|
|
||||||
### DL-5 Delete failure
|
|
||||||
1. Given backend failure during delete
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then the user remains in delete context with retry path
|
|
||||||
|
|
||||||
## Cross-Criteria Quality Gates
|
|
||||||
|
|
||||||
### QG-1 Separation of intent and implementation
|
|
||||||
1. UX intent remains in user-journey.md
|
|
||||||
2. Current versus target implementation mapping remains in schema-mapping.md
|
|
||||||
|
|
||||||
### QG-2 Traceability
|
|
||||||
1. Each accepted behavior maps to at least one UI action or service path
|
|
||||||
2. No acceptance criterion contradicts first-release deferred items
|
|
||||||
|
|
||||||
### QG-3 First-release constraints
|
|
||||||
1. Jobs page remains list-all with explicit Create job action
|
|
||||||
2. Job create requires Document selection and source upload
|
|
||||||
3. provider/model/prompt_name are visible to users when known
|
|
||||||
4. manual retry controls may remain deferred while status visibility is required
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
# Job Schema-to-UI Mapping
|
|
||||||
|
|
||||||
Purpose: Map the Job schema to the UI, while clearly separating intended target behavior from current implementation.
|
|
||||||
|
|
||||||
Companion document: user-journey.md
|
|
||||||
Acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Entity Snapshot
|
|
||||||
|
|
||||||
- Table: Job
|
|
||||||
- Primary key: id (UUID)
|
|
||||||
- Related entities: Document, JobSource, Source
|
|
||||||
- Canonical schema references:
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
- docs/schema_v2.md
|
|
||||||
|
|
||||||
## 2. Mapping Rules
|
|
||||||
|
|
||||||
This document uses three lenses:
|
|
||||||
1. Intended behavior: what the UX should support.
|
|
||||||
2. Current behavior: what the code supports today.
|
|
||||||
3. Gap to target: what must change to align implementation with the intended UX.
|
|
||||||
|
|
||||||
## 3. Field Inventory
|
|
||||||
|
|
||||||
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| id | UUID | No | uuid4() | Shown read-only in list and detail | Primary key |
|
|
||||||
| document_id | UUID FK | No | None | Required create input via Document selection | Job belongs to one Document |
|
|
||||||
| status | enum JobStatus | No | queued | Shown read-only as lifecycle state | System-managed transitions |
|
|
||||||
| retry_count | int | No | 0 | Shown read-only | Operational counter |
|
|
||||||
| date_created | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
|
|
||||||
| date_updated | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
|
|
||||||
| provider | str | Yes | None | Visible when known; editable if create-time options are available | Processing metadata |
|
|
||||||
| model | str | Yes | None | Visible when known; editable if create-time options are available | Processing metadata |
|
|
||||||
| prompt_name | str | Yes | None | Visible when known; editable if create-time options are available | Prompt metadata |
|
|
||||||
|
|
||||||
Related execution fields rendered in Job detail via relationships:
|
|
||||||
- Job detail renders metadata and document links; source-level review/editing is reached through job-scoped Sources routes.
|
|
||||||
|
|
||||||
## 4. CREATE Mapping
|
|
||||||
|
|
||||||
### 4.1 Intended Create Flow
|
|
||||||
|
|
||||||
Entry point: Jobs page Create job action
|
|
||||||
User action: open create mode, select Document, upload one or more source files or a folder, submit for transcription
|
|
||||||
Success destination: Job detail page in detail mode
|
|
||||||
|
|
||||||
| Field | Intended User Input | Required | Visible | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| document_id | Select/search | Yes | Yes | Required create selection |
|
|
||||||
| status | None | No | Yes (read-only) | Starts at queued and changes by workflow |
|
|
||||||
| retry_count | None | No | Yes (read-only) | Starts at 0 |
|
|
||||||
| date_created | None | No | Yes (read-only) | System-generated |
|
|
||||||
| date_updated | None | No | Yes (read-only) | System-generated |
|
|
||||||
| provider | Display or select | No | Yes | Visible when known during create and detail |
|
|
||||||
| model | Display or select | No | Yes | Visible when known during create and detail |
|
|
||||||
| prompt_name | Display or select | No | Yes | Visible when known during create and detail |
|
|
||||||
|
|
||||||
Create-related relationship rules:
|
|
||||||
1. source file upload is required for create.
|
|
||||||
2. each uploaded file creates a Source linked to the selected Document.
|
|
||||||
3. each created Source must be linked to the new Job through JobSource.
|
|
||||||
4. processing order for multi-file and folder uploads is alphabetical by original filename.
|
|
||||||
|
|
||||||
### 4.2 Current Implementation
|
|
||||||
|
|
||||||
Current entry point: Jobs page create flow
|
|
||||||
Current user action: select Document and upload one or more files or a folder through a single upload widget
|
|
||||||
Current backend path: job create submit -> create_job_for_document()
|
|
||||||
|
|
||||||
| Field | Current Value at Create | Source | Visible to User | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| id | Generated UUID | System | Yes on jobs list/detail | src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| document_id | Selected existing Document id | User selection + service write | Indirectly | src/transcription/ui/pages/jobs_page.py, src/transcription/services/store.py |
|
|
||||||
| status | queued | Service/model default | Yes | src/transcription/services/store.py, src/transcription/db/models.py |
|
|
||||||
| retry_count | 0 | Model default | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| date_created | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| date_updated | current UTC timestamp | System | Yes | src/transcription/db/models.py, src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| provider | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
|
|
||||||
| model | None at create, set after transcription update | Workflow/service | Yes | src/transcription/services/workflows.py |
|
|
||||||
| prompt_name | None at create, set by workflow updates | Workflow/service | Yes | src/transcription/services/workflows.py |
|
|
||||||
|
|
||||||
Current create constraints:
|
|
||||||
1. dedicated Create job action exists in the Jobs page.
|
|
||||||
2. job create flow requires a Document selection.
|
|
||||||
3. current upload path accepts one widget for files or folder selection.
|
|
||||||
|
|
||||||
### 4.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended Create flow, implementation must add:
|
|
||||||
1. Jobs list Create job action that opens Job detail/create mode.
|
|
||||||
2. explicit Document selection and source upload controls in create mode.
|
|
||||||
3. multi-file and folder upload support in create mode.
|
|
||||||
4. deterministic alphabetical page ordering and user guidance.
|
|
||||||
5. explicit visibility of provider, model, and prompt_name in create/detail when known.
|
|
||||||
|
|
||||||
## 5. READ Mapping
|
|
||||||
|
|
||||||
### 5.1 Intended Read Behavior
|
|
||||||
|
|
||||||
On Job list/detail surfaces, users should be able to see:
|
|
||||||
1. all jobs in one list.
|
|
||||||
2. status and timeline context.
|
|
||||||
3. selected Document context.
|
|
||||||
4. source-level processing and transcription results.
|
|
||||||
5. provider/model/prompt_name when known.
|
|
||||||
|
|
||||||
### 5.2 Current Implementation
|
|
||||||
|
|
||||||
Current read behavior exists in jobs list and jobs detail routes.
|
|
||||||
|
|
||||||
| Field | Current Rendering | Visible to User | Notes | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| id | Jobs list row and detail header | Yes | Primary visible identifier | src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| status | Jobs list and detail | Yes | Chip styling for transcribed; text for others | src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| retry_count | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
|
|
||||||
| date_created | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
|
|
||||||
| date_updated | Jobs list table | Yes | Included in row model | src/transcription/ui/components/table/jobs.py |
|
|
||||||
| document_id | Not rendered directly as labeled field | Partial | Document context exists by relationship but limited direct display | src/transcription/ui/pages/jobs_page.py |
|
|
||||||
| provider/model/prompt_name | Rendered as labeled fields in Job detail | Yes | Shows pending fallback when unset | src/transcription/ui/pages/jobs_page.py |
|
|
||||||
|
|
||||||
Source-related read behavior:
|
|
||||||
1. Job detail exposes Sources navigation for current job context.
|
|
||||||
2. Source preview, transcription context, and revision editor are rendered in Source detail.
|
|
||||||
3. invalid or missing job ids show explicit UI states.
|
|
||||||
|
|
||||||
### 5.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended Read flow, implementation must add:
|
|
||||||
1. optional in-page source summaries in Job detail if future UX requires fewer navigation steps.
|
|
||||||
2. richer filtering/search UX if needed.
|
|
||||||
|
|
||||||
## 6. UPDATE Mapping
|
|
||||||
|
|
||||||
### 6.1 Intended Update Behavior
|
|
||||||
|
|
||||||
Primary user updates in first release are source revision edits in Source detail reached from Job detail.
|
|
||||||
|
|
||||||
Intended editable scope (first release):
|
|
||||||
- Source.revised_text through Source detail review
|
|
||||||
|
|
||||||
Intended read-only Job fields in first release:
|
|
||||||
- id
|
|
||||||
- document_id after create
|
|
||||||
- status
|
|
||||||
- retry_count
|
|
||||||
- date_created
|
|
||||||
- date_updated
|
|
||||||
|
|
||||||
Job metadata visibility policy:
|
|
||||||
- provider, model, and prompt_name should be visible when known.
|
|
||||||
- create-time editing of provider/model/prompt_name is optional and depends on available options.
|
|
||||||
|
|
||||||
### 6.2 Current Implementation
|
|
||||||
|
|
||||||
| Field/Area | Updatable via UI | Updatable via Service | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Source.revised_text from Source detail | Yes | Yes | Saved via transcription service revision path from Sources page detail route |
|
|
||||||
| status | No | Yes | Updated by workflow lifecycle services |
|
|
||||||
| retry_count | No | Yes | Incremented by workflow retry logic |
|
|
||||||
| provider/model/prompt_name | No | Yes | Set during transcription result finalization |
|
|
||||||
| document_id | No | Technically via model/service update | Treated as fixed post-create in intended UX |
|
|
||||||
|
|
||||||
### 6.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation now includes:
|
|
||||||
1. create-mode handling for provider/model/prompt visibility and optional selection.
|
|
||||||
2. detail display for provider/model/prompt and document-scoped navigation links.
|
|
||||||
3. source revision workflow through job-scoped Sources and Source detail pages.
|
|
||||||
4. manual controls for retry and state transitions remain deferred.
|
|
||||||
|
|
||||||
## 7. DELETE Mapping
|
|
||||||
|
|
||||||
### 7.1 Intended Delete Behavior
|
|
||||||
|
|
||||||
Job deletion is implemented as a dedicated delete route with processing-state guardrails.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. deletion is allowed only when policy allows cleanup or retention handling for related JobSource records.
|
|
||||||
2. blocked deletion must explain constraints and required cleanup path.
|
|
||||||
3. successful deletion requires confirmation and returns user to Jobs list.
|
|
||||||
|
|
||||||
### 7.2 Current Implementation
|
|
||||||
|
|
||||||
| Action | UI Exposed | Backend Capability | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Delete Job | Yes | Yes | Job delete page confirms permanent action and blocks when processing |
|
|
||||||
|
|
||||||
### 7.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation may add in a future revision:
|
|
||||||
1. inline delete entry in Job detail header.
|
|
||||||
2. richer dependency messaging beyond processing-state guardrail.
|
|
||||||
|
|
||||||
## 8. Hidden and System-Managed Fields
|
|
||||||
|
|
||||||
| Field | Category | Why Hidden or Protected |
|
|
||||||
|---|---|---|
|
|
||||||
| status | System-managed lifecycle | Managed by worker lifecycle transitions |
|
|
||||||
| retry_count | System-managed operational state | Reflects retry behavior, not direct user input |
|
|
||||||
| date_created | System-managed | Audit timestamp |
|
|
||||||
| date_updated | System-managed | Audit timestamp |
|
|
||||||
|
|
||||||
## 9. Traceability Anchors
|
|
||||||
|
|
||||||
Schema and models:
|
|
||||||
- docs/schema_v2.md
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
- src/transcription/ui/pages/jobs_page.py
|
|
||||||
- src/transcription/ui/components/table/jobs.py
|
|
||||||
- src/transcription/ui/pages/sources_page.py
|
|
||||||
- src/transcription/services/jobs.py
|
|
||||||
- src/transcription/services/workflows.py
|
|
||||||
- src/transcription/services/store.py
|
|
||||||
- src/transcription/services/transcription.py
|
|
||||||
|
|
||||||
Companion UX spec:
|
|
||||||
- docs/ui/entities/job/user-journey.md
|
|
||||||
|
|
||||||
Acceptance checklist:
|
|
||||||
- docs/ui/entities/job/acceptance-criteria.md
|
|
||||||
|
|
||||||
## 10. Acceptance Checklist Summary
|
|
||||||
|
|
||||||
- Every Job schema field appears in the field inventory.
|
|
||||||
- Intended Create behavior matches the companion user journey.
|
|
||||||
- Current behavior reflects explicit jobs creation plus source review/editing through dedicated Sources routes.
|
|
||||||
- Provider/model/prompt visibility intent is explicit for create and detail views.
|
|
||||||
- Gaps between intended and current behavior are explicit.
|
|
||||||
- Read, Update, and Delete sections distinguish target behavior from current code.
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
# Job User Journey
|
|
||||||
|
|
||||||
Purpose: Define how a user should interact with the UI to create and manage a Job record, including document linking, source uploads, processing status, and page-level review.
|
|
||||||
|
|
||||||
Scope: This document describes intended user interaction for the Job UI. It is the UX contract for the Job entity.
|
|
||||||
|
|
||||||
Companion schema mapping: schema-mapping.md
|
|
||||||
Companion acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
A Job represents one transcription run for a selected Document and one or more uploaded source files.
|
|
||||||
|
|
||||||
Managing a Job is run-first:
|
|
||||||
1. The user opens the Jobs page.
|
|
||||||
2. The user selects Create job.
|
|
||||||
3. The user lands on a Job detail/create surface.
|
|
||||||
4. The user links a Document and uploads one or more source files.
|
|
||||||
5. The user submits for transcription.
|
|
||||||
6. The system creates and processes the Job.
|
|
||||||
7. The user reviews job metadata and follows document-scoped links for Sources and Jobs.
|
|
||||||
|
|
||||||
## 2. User Goal
|
|
||||||
|
|
||||||
The user wants to:
|
|
||||||
1. see all jobs in one place
|
|
||||||
2. create a new transcription run intentionally
|
|
||||||
3. attach the run to the correct Document
|
|
||||||
4. upload source file(s) for that run
|
|
||||||
5. submit and monitor processing state
|
|
||||||
6. review and revise page-level outputs
|
|
||||||
|
|
||||||
## 3. Page Model
|
|
||||||
|
|
||||||
### 3.1 Jobs List Page
|
|
||||||
|
|
||||||
The Jobs page is the primary UI surface where users manage jobs.
|
|
||||||
|
|
||||||
It should support:
|
|
||||||
1. listing all jobs
|
|
||||||
2. searching or filtering jobs
|
|
||||||
3. opening job detail for any row
|
|
||||||
4. starting Create job
|
|
||||||
5. clear empty state when no jobs exist
|
|
||||||
|
|
||||||
### 3.2 Job Detail/Create Page
|
|
||||||
|
|
||||||
The Job detail/create page is used for both creating a new Job and viewing an existing Job.
|
|
||||||
|
|
||||||
Create mode should include:
|
|
||||||
1. document selection
|
|
||||||
2. source upload controls
|
|
||||||
3. submit for transcription action
|
|
||||||
|
|
||||||
Detail mode should include:
|
|
||||||
1. job metadata and status
|
|
||||||
2. document-scoped navigation links for the current Document
|
|
||||||
3. provider/model/prompt visibility when known
|
|
||||||
4. no delete action in first release
|
|
||||||
|
|
||||||
## 4. Entry Points
|
|
||||||
|
|
||||||
Primary entry points:
|
|
||||||
1. from Jobs page, Create job
|
|
||||||
2. from Jobs page row selection, open existing Job detail
|
|
||||||
|
|
||||||
Current implementation note:
|
|
||||||
1. current code path uses explicit /jobs/new creation
|
|
||||||
2. intended UX is explicit Create job from the Jobs page
|
|
||||||
3. current detail view is link-oriented and routes source review/editing through dedicated Source detail
|
|
||||||
|
|
||||||
## 5. Create Job Flow
|
|
||||||
|
|
||||||
### 5.1 User Intent
|
|
||||||
|
|
||||||
The user wants to start a transcription run by selecting the right Document and providing source files in one guided flow.
|
|
||||||
|
|
||||||
### 5.2 Create Entry
|
|
||||||
|
|
||||||
1. The user opens the Jobs page
|
|
||||||
2. The user selects Create job
|
|
||||||
3. The system opens Job detail/create page in create mode
|
|
||||||
|
|
||||||
### 5.3 Create Inputs
|
|
||||||
|
|
||||||
| UI Label | Schema Area | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Document | Job.document_id | Select/search | Yes | Links the run to one Document |
|
|
||||||
| Source files | Source upload fields | Multi-file upload or folder upload | Yes | User may select one file, many files, or a folder |
|
|
||||||
| Processing order | Source.page_number assignment rule | System rule | Yes | If multiple files are uploaded, order is alphabetical by original filename |
|
|
||||||
| Provider | Job.provider | Display or select | No | Visible to user when known; selectable when options are available |
|
|
||||||
| Model | Job.model | Display or select | No | Visible to user when known; selectable when options are available |
|
|
||||||
| Prompt | Job.prompt_name | Display or select | No | Visible to user when known; selectable when options are available |
|
|
||||||
|
|
||||||
### 5.4 Source Handling Rules
|
|
||||||
|
|
||||||
1. Each uploaded file becomes a Source linked to the selected Document
|
|
||||||
2. Each created Source is linked to the Job through JobSource
|
|
||||||
3. Multi-file or folder uploads are processed alphabetically by original filename
|
|
||||||
4. upload_name stores the original filename
|
|
||||||
5. stored filename uses UUID plus original extension in the form UUID.extension
|
|
||||||
|
|
||||||
Suggested helper text:
|
|
||||||
1. Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control page order.
|
|
||||||
|
|
||||||
### 5.5 Validation Rules
|
|
||||||
|
|
||||||
Create submission must be blocked when:
|
|
||||||
1. no Document is selected
|
|
||||||
2. no source file is uploaded
|
|
||||||
|
|
||||||
Create submission should provide clear feedback when:
|
|
||||||
1. uploaded files are invalid or unreadable
|
|
||||||
2. persistence fails for Job, Source, or JobSource linkage
|
|
||||||
|
|
||||||
### 5.6 Submission Behavior
|
|
||||||
|
|
||||||
On submit:
|
|
||||||
1. validate create inputs
|
|
||||||
2. create Job record linked to selected Document
|
|
||||||
3. create Source records for uploaded files
|
|
||||||
4. create JobSource links for each Source in the Job
|
|
||||||
5. queue processing for transcription
|
|
||||||
6. route user to Job detail mode
|
|
||||||
|
|
||||||
Recommended transactional behavior:
|
|
||||||
1. intended create writes should succeed or fail together
|
|
||||||
2. The user should not receive false success when required records fail
|
|
||||||
|
|
||||||
### 5.7 Create Success Result
|
|
||||||
|
|
||||||
After successful create:
|
|
||||||
1. job appears in Jobs list
|
|
||||||
2. job detail shows selected Document and created source set
|
|
||||||
3. status appears as queued or processing based on execution timing
|
|
||||||
4. The user can monitor progress and open page-level review
|
|
||||||
|
|
||||||
### 5.8 Create Failure Result
|
|
||||||
|
|
||||||
If create fails:
|
|
||||||
1. Show clear error message
|
|
||||||
2. preserve entered selections where possible
|
|
||||||
3. keep retry path available
|
|
||||||
4. do not show false success feedback
|
|
||||||
|
|
||||||
## 6. Read Job Journey
|
|
||||||
|
|
||||||
### 6.1 User Intent
|
|
||||||
|
|
||||||
The user wants to quickly understand what the job is, its current status, and which source pages need review.
|
|
||||||
|
|
||||||
### 6.2 Jobs List Expectations
|
|
||||||
|
|
||||||
The Jobs list should show, at minimum:
|
|
||||||
1. job identifier
|
|
||||||
2. document context
|
|
||||||
3. current status
|
|
||||||
4. creation or update timestamp
|
|
||||||
5. quick action to open detail
|
|
||||||
|
|
||||||
Optional first-release columns if available:
|
|
||||||
1. retry count
|
|
||||||
2. provider/model summary
|
|
||||||
|
|
||||||
### 6.3 Job Detail Expectations
|
|
||||||
|
|
||||||
The Job detail should show:
|
|
||||||
1. job status and summary metadata
|
|
||||||
2. selected Document context
|
|
||||||
3. document-scoped and job-scoped navigation links
|
|
||||||
4. source review entry through job-scoped Sources list
|
|
||||||
|
|
||||||
Source detail should show:
|
|
||||||
1. source metadata and preview
|
|
||||||
2. original transcription output per source
|
|
||||||
3. revision editor and latest revised content
|
|
||||||
|
|
||||||
### 6.4 Read Empty and Missing States
|
|
||||||
|
|
||||||
If no jobs exist:
|
|
||||||
1. list shows no jobs yet empty state
|
|
||||||
2. list shows Create job action
|
|
||||||
|
|
||||||
If a job id is invalid or missing:
|
|
||||||
1. Show clear not found state
|
|
||||||
2. do not crash the page
|
|
||||||
|
|
||||||
If a job has no source items due to failure:
|
|
||||||
1. Show clear warning state
|
|
||||||
2. keep recovery guidance visible
|
|
||||||
|
|
||||||
## 7. Job Status Lifecycle UX
|
|
||||||
|
|
||||||
### 7.1 Status Values
|
|
||||||
|
|
||||||
The UI should map to model-backed job states:
|
|
||||||
1. queued
|
|
||||||
2. processing
|
|
||||||
3. transcribed
|
|
||||||
4. completed
|
|
||||||
5. partial_success
|
|
||||||
6. failed
|
|
||||||
|
|
||||||
### 7.2 In-Progress States
|
|
||||||
|
|
||||||
When status is queued or processing:
|
|
||||||
1. Show active progress state
|
|
||||||
2. keep detail page refresh-safe
|
|
||||||
3. indicate that source-level results may still be arriving
|
|
||||||
|
|
||||||
### 7.3 Terminal States
|
|
||||||
|
|
||||||
When status is completed:
|
|
||||||
1. Show completion success state
|
|
||||||
2. direct user to revision workflow
|
|
||||||
|
|
||||||
When status is partial_success:
|
|
||||||
1. Show mixed outcome state
|
|
||||||
2. identify failed pages
|
|
||||||
3. guide user to review available successful pages and retry strategy
|
|
||||||
|
|
||||||
When status is failed:
|
|
||||||
1. Show failure state with actionable message
|
|
||||||
2. keep navigation and retry guidance available
|
|
||||||
|
|
||||||
## 8. Update Job Journey
|
|
||||||
|
|
||||||
### 8.1 User Intent
|
|
||||||
|
|
||||||
The user primarily updates job-related review outcomes by editing revised transcription text per source page.
|
|
||||||
|
|
||||||
### 8.2 First-Release Editable Scope
|
|
||||||
|
|
||||||
Editable in first release:
|
|
||||||
1. source-level revised_text through Source detail reached from job-scoped Sources navigation
|
|
||||||
|
|
||||||
Read-only in first release:
|
|
||||||
1. Job.document_id after create
|
|
||||||
2. job status values managed by processing workflow
|
|
||||||
3. provider/model/prompt values may be system-managed, but should remain visible in UI when known
|
|
||||||
|
|
||||||
### 8.3 Update Save Behavior
|
|
||||||
|
|
||||||
On revision save:
|
|
||||||
1. validate revised text
|
|
||||||
2. persist revised text for selected source
|
|
||||||
3. update revised timestamp fields by system policy
|
|
||||||
4. show success feedback
|
|
||||||
|
|
||||||
On save failure:
|
|
||||||
1. Show clear error feedback
|
|
||||||
2. preserve entered text where possible
|
|
||||||
3. Allow retry
|
|
||||||
|
|
||||||
## 9. Delete and Retention Policy
|
|
||||||
|
|
||||||
### 9.1 User Intent
|
|
||||||
|
|
||||||
The user may need to remove invalid or duplicate jobs safely.
|
|
||||||
|
|
||||||
### 9.2 First-Release Policy
|
|
||||||
|
|
||||||
Delete behavior uses explicit guardrails:
|
|
||||||
1. deletion is blocked while status is processing
|
|
||||||
2. blocked delete explains constraints and offers back navigation
|
|
||||||
3. allowed delete requires explicit confirmation and then returns to Jobs list with success feedback
|
|
||||||
|
|
||||||
## 10. Relationship to Other Workflows
|
|
||||||
|
|
||||||
Job workflow integrates with:
|
|
||||||
1. Document workflow for ownership context
|
|
||||||
2. Source workflow for uploaded page records and ordering
|
|
||||||
3. Revision workflow for human correction lifecycle
|
|
||||||
4. Worker processing workflow for queued execution and status transitions
|
|
||||||
|
|
||||||
## 11. Relationship to Schema Mapping
|
|
||||||
|
|
||||||
The companion schema-mapping document should specify:
|
|
||||||
1. field visibility per CRUD action
|
|
||||||
2. current implementation status
|
|
||||||
3. intended behavior
|
|
||||||
4. gap-to-target items
|
|
||||||
|
|
||||||
## 12. Deferred Items
|
|
||||||
|
|
||||||
Deferred to future revisions:
|
|
||||||
1. manual retry controls from job detail
|
|
||||||
2. advanced provider/model/prompt policy controls beyond basic create-time visibility
|
|
||||||
3. advanced bulk actions across multiple jobs
|
|
||||||
4. live streaming progress updates beyond refresh-based updates
|
|
||||||
5. job templates or preset configurations
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# Job Acceptance Criteria
|
|
||||||
|
|
||||||
Purpose: Define implementation-ready acceptance criteria for Job Create, Read, Update, and Delete workflows.
|
|
||||||
|
|
||||||
Companion documents:
|
|
||||||
- docs/ui/entities/job/user-journey.md
|
|
||||||
- docs/ui/entities/job/schema-mapping.md
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This checklist covers:
|
|
||||||
1. Create flow
|
|
||||||
2. Read flow
|
|
||||||
3. Update flow
|
|
||||||
4. Delete flow
|
|
||||||
|
|
||||||
This checklist does not cover:
|
|
||||||
1. provider-specific transcription internals
|
|
||||||
2. advanced workflow scheduling and queue orchestration controls
|
|
||||||
3. multi-job bulk operations
|
|
||||||
|
|
||||||
## Create Acceptance Criteria
|
|
||||||
|
|
||||||
### CR-1 Job creation entry
|
|
||||||
1. Given the user is on the Jobs page
|
|
||||||
2. When the user selects Create job
|
|
||||||
3. Then the user is taken to Job detail/create mode
|
|
||||||
|
|
||||||
### CR-2 Required create values
|
|
||||||
1. document_id must be selected before submit
|
|
||||||
2. at least one source file must be uploaded before submit
|
|
||||||
3. each uploaded file creates a Source linked to the selected Document
|
|
||||||
4. each created Source is linked to the new Job through JobSource
|
|
||||||
|
|
||||||
### CR-3 Source ordering behavior
|
|
||||||
1. Given multi-file or folder upload
|
|
||||||
2. When source records are created
|
|
||||||
3. Then page ordering follows alphabetical order of original filenames
|
|
||||||
4. Then helper text explains how filename conventions control ordering
|
|
||||||
|
|
||||||
### CR-4 Provider/model/prompt visibility
|
|
||||||
1. provider, model, and prompt_name are visible in create flow when known
|
|
||||||
2. provider, model, and prompt_name are visible in detail flow when known
|
|
||||||
3. if values are unknown at create time, UI shows clear unknown or pending state without blocking submit
|
|
||||||
|
|
||||||
### CR-5 Successful create outcome
|
|
||||||
1. Given valid inputs
|
|
||||||
2. When the user submits create
|
|
||||||
3. Then the Job record is created and linked to selected Document
|
|
||||||
4. Then source and JobSource records are created for uploads
|
|
||||||
5. Then job status is queued or processing based on execution timing
|
|
||||||
6. Then the user is routed to Job detail mode
|
|
||||||
|
|
||||||
### CR-6 Create failure outcome
|
|
||||||
1. Given create validation or persistence failure
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then no false success feedback is shown
|
|
||||||
4. Then entered selections are preserved where possible
|
|
||||||
5. Then retry path remains available
|
|
||||||
|
|
||||||
## Read Acceptance Criteria
|
|
||||||
|
|
||||||
### RD-1 Jobs list retrieval
|
|
||||||
1. Given one or more jobs exist
|
|
||||||
2. When the user opens the Jobs page
|
|
||||||
3. Then all jobs are listed in a table or equivalent list surface
|
|
||||||
|
|
||||||
### RD-2 Jobs list fields
|
|
||||||
1. Jobs list shows job id
|
|
||||||
2. Jobs list shows status
|
|
||||||
3. Jobs list shows created or updated timestamps
|
|
||||||
4. Jobs list shows retry_count when available
|
|
||||||
5. Jobs list provides navigation to Job detail for each row
|
|
||||||
|
|
||||||
### RD-3 Job detail retrieval
|
|
||||||
1. Given a valid job id
|
|
||||||
2. When the user opens Job detail
|
|
||||||
3. Then job metadata for that record only is shown
|
|
||||||
4. Then document-scoped navigation links for Sources and Jobs are shown
|
|
||||||
|
|
||||||
### RD-4 Detail execution context visibility
|
|
||||||
1. provider, model, and prompt_name are displayed when known
|
|
||||||
2. status lifecycle value is visible
|
|
||||||
3. source-level transcription and revision context is available through Source detail navigation from Job detail
|
|
||||||
|
|
||||||
### RD-5 Missing and invalid id states
|
|
||||||
1. Given an invalid job id format
|
|
||||||
2. Then UI shows invalid job id state without crashing
|
|
||||||
3. Given a valid but nonexistent job id
|
|
||||||
4. Then UI shows job not found state without crashing
|
|
||||||
|
|
||||||
## Update Acceptance Criteria
|
|
||||||
|
|
||||||
### UP-1 Revision edit entry
|
|
||||||
1. Given a job detail page
|
|
||||||
2. When the user opens the page
|
|
||||||
3. Then navigation links to job-scoped Sources are available
|
|
||||||
4. Then source rows can open Source detail revision workflow
|
|
||||||
|
|
||||||
### UP-2 Revision validation
|
|
||||||
1. revision save blocks empty trimmed text and shows warning feedback
|
|
||||||
|
|
||||||
### UP-3 Successful revision save
|
|
||||||
1. Source detail save persists revised text and shows success feedback
|
|
||||||
|
|
||||||
### UP-4 Revision save failure
|
|
||||||
1. Source detail save failure shows clear error feedback with retry path
|
|
||||||
|
|
||||||
### UP-5 Job lifecycle state update visibility
|
|
||||||
1. status changes from queued to processing to terminal states are reflected in UI
|
|
||||||
2. retry_count updates are reflected when retry logic runs
|
|
||||||
3. users cannot directly edit lifecycle state fields in first release
|
|
||||||
|
|
||||||
## Delete Acceptance Criteria
|
|
||||||
|
|
||||||
### DL-1 Delete entry and confirmation
|
|
||||||
1. Given a job detail context
|
|
||||||
2. When the user opens job delete page
|
|
||||||
3. Then a permanent-action confirmation is shown for non-processing jobs
|
|
||||||
|
|
||||||
### DL-2 Dependency guardrails
|
|
||||||
1. Delete is blocked while job status is processing
|
|
||||||
2. Related JobSource links are removed as part of allowed delete flow
|
|
||||||
|
|
||||||
### DL-3 Blocked delete behavior
|
|
||||||
1. When blocked, the UI shows clear processing-state guidance
|
|
||||||
2. The user is offered navigation back to job or jobs list
|
|
||||||
|
|
||||||
### DL-4 Successful delete
|
|
||||||
1. Given an allowed delete
|
|
||||||
2. When the user confirms delete
|
|
||||||
3. Then the job is removed and success feedback is shown
|
|
||||||
4. Then the user is returned to Jobs list
|
|
||||||
|
|
||||||
### DL-5 Delete failure
|
|
||||||
1. Given backend failure during delete
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then the user remains in delete context with retry path
|
|
||||||
|
|
||||||
## Cross-Criteria Quality Gates
|
|
||||||
|
|
||||||
### QG-1 Separation of intent and implementation
|
|
||||||
1. UX intent remains in user-journey.md
|
|
||||||
2. Current versus target implementation mapping remains in schema-mapping.md
|
|
||||||
|
|
||||||
### QG-2 Traceability
|
|
||||||
1. Each accepted behavior maps to at least one UI action or service path
|
|
||||||
2. No acceptance criterion contradicts first-release deferred items
|
|
||||||
|
|
||||||
### QG-3 First-release constraints
|
|
||||||
1. Jobs page remains list-all with explicit Create job action
|
|
||||||
2. Job create requires Document selection and source upload
|
|
||||||
3. provider/model/prompt_name are visible to users when known
|
|
||||||
4. manual retry controls may remain deferred while status visibility is required
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# Person Acceptance Criteria
|
|
||||||
|
|
||||||
Purpose: Define implementation-ready acceptance criteria for Person Create, Read, Update, and Delete workflows.
|
|
||||||
|
|
||||||
Companion documents:
|
|
||||||
- docs/ui/entities/person/user-journey.md
|
|
||||||
- docs/ui/entities/person/schema-mapping.md
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This checklist covers:
|
|
||||||
1. Create flow
|
|
||||||
2. Read flow
|
|
||||||
3. Update flow
|
|
||||||
4. Delete flow
|
|
||||||
|
|
||||||
This checklist does not cover:
|
|
||||||
1. advanced metadata_ editing UX
|
|
||||||
2. structured-name schema migration implementation
|
|
||||||
3. bulk merge or dedup workflow design
|
|
||||||
|
|
||||||
## Create Acceptance Criteria
|
|
||||||
|
|
||||||
### CR-1 Person creation entry
|
|
||||||
1. Given a Person page
|
|
||||||
2. When the user selects Create new person
|
|
||||||
3. Then the user can open a Person create form
|
|
||||||
|
|
||||||
### CR-2 Required field validation
|
|
||||||
1. full_name is required
|
|
||||||
2. Save is blocked when full_name is empty
|
|
||||||
3. Inline feedback is shown for required-field errors
|
|
||||||
|
|
||||||
### CR-3 Optional field handling
|
|
||||||
1. Optional fields may be blank without blocking create
|
|
||||||
2. Date raw and exact fields can coexist
|
|
||||||
3. Exact date remains canonical when both exact and raw are provided
|
|
||||||
4. Portrait uploads persist under uploads/portraits/person and store a relative portrait_path
|
|
||||||
|
|
||||||
### CR-4 Successful create outcome
|
|
||||||
1. Given valid input
|
|
||||||
2. When the user saves
|
|
||||||
3. Then the Person record is created
|
|
||||||
4. Then success feedback is shown
|
|
||||||
5. Then the user is routed to Person detail page
|
|
||||||
|
|
||||||
### CR-5 Create failure outcome
|
|
||||||
1. Given backend failure during create
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then entered values are retained where possible
|
|
||||||
4. Then no false success feedback is shown
|
|
||||||
|
|
||||||
## Read Acceptance Criteria
|
|
||||||
|
|
||||||
### RD-1 Person detail retrieval
|
|
||||||
1. Given a valid Person id
|
|
||||||
2. When the user opens the Person detail page
|
|
||||||
3. Then the system displays Person metadata for that record only
|
|
||||||
|
|
||||||
### RD-2 Metadata visibility
|
|
||||||
1. The page shows full_name and available optional person fields
|
|
||||||
2. created_at and updated_at are shown as system-managed, read-only values
|
|
||||||
3. portrait_path is rendered when available, including an image preview when possible
|
|
||||||
4. relative portrait_path values resolve through /uploads for image rendering
|
|
||||||
|
|
||||||
### RD-3 Linked documents section
|
|
||||||
1. Given no linked DocumentPerson rows
|
|
||||||
2. Then the page shows a no linked documents yet empty state
|
|
||||||
3. Given linked documents exist
|
|
||||||
4. Then the page shows linked document entries
|
|
||||||
|
|
||||||
### RD-4 Read failure state
|
|
||||||
1. Given a nonexistent Person id
|
|
||||||
2. Then the UI shows a clear not found state without crashing
|
|
||||||
|
|
||||||
## Update Acceptance Criteria
|
|
||||||
|
|
||||||
### UP-1 Edit entry
|
|
||||||
1. Given a loaded Person detail page
|
|
||||||
2. When the user selects Edit person
|
|
||||||
3. Then editable controls are shown for allowed fields only
|
|
||||||
|
|
||||||
### UP-2 Editable fields
|
|
||||||
1. Editable: full_name, display_name, maiden_name, birth/death fields, places, biography, portrait_path
|
|
||||||
2. Not editable: id, created_at, updated_at
|
|
||||||
3. metadata_ remains hidden in first release
|
|
||||||
|
|
||||||
### UP-3 Required validation
|
|
||||||
1. full_name remains required
|
|
||||||
2. Save is blocked with inline feedback when full_name is empty
|
|
||||||
|
|
||||||
### UP-4 Successful save
|
|
||||||
1. Given valid input
|
|
||||||
2. When the user saves
|
|
||||||
3. Then changes persist
|
|
||||||
4. Then success feedback is shown
|
|
||||||
5. Then the user remains on Person detail with refreshed values
|
|
||||||
|
|
||||||
### UP-5 Save failure
|
|
||||||
1. Given backend failure during save
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then the user-entered values remain available for retry where possible
|
|
||||||
4. Then no false success feedback is shown
|
|
||||||
|
|
||||||
## Delete Acceptance Criteria
|
|
||||||
|
|
||||||
### DL-1 Delete entry and confirmation
|
|
||||||
1. Given a Person detail page
|
|
||||||
2. When the user selects Delete person
|
|
||||||
3. Then a confirmation dialog appears with permanent-action wording
|
|
||||||
|
|
||||||
### DL-2 Relationship guardrails
|
|
||||||
1. Delete is allowed only when relationship policy allows it
|
|
||||||
2. If linked DocumentPerson rows must be removed first, delete is blocked
|
|
||||||
|
|
||||||
### DL-3 Blocked delete behavior
|
|
||||||
1. When blocked
|
|
||||||
2. Then the UI explains why deletion is blocked
|
|
||||||
3. Then the UI identifies linked-document dependency presence
|
|
||||||
4. Then the UI provides navigation to cleanup paths
|
|
||||||
|
|
||||||
### DL-4 Successful delete
|
|
||||||
1. Given no blocking dependencies
|
|
||||||
2. When the user confirms delete
|
|
||||||
3. Then the Person record is removed
|
|
||||||
4. Then success feedback is shown
|
|
||||||
5. Then the user returns to the Person list page
|
|
||||||
|
|
||||||
### DL-5 Delete failure
|
|
||||||
1. Given backend failure during delete
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then the user remains on Person detail with retry path
|
|
||||||
|
|
||||||
## Cross-Criteria Quality Gates
|
|
||||||
|
|
||||||
### QG-1 Separation of intent and implementation
|
|
||||||
1. UX intent remains in user-journey.md
|
|
||||||
2. Current versus target implementation mapping remains in schema-mapping.md
|
|
||||||
|
|
||||||
### QG-2 Traceability
|
|
||||||
1. Each accepted behavior maps to at least one future UI action or service call path
|
|
||||||
2. No acceptance criterion contradicts the deferred-item policy
|
|
||||||
|
|
||||||
### QG-3 First-release constraints
|
|
||||||
1. metadata_ remains hidden in first release
|
|
||||||
2. structured name field split remains deferred
|
|
||||||
3. recipient and multi-person role management stays in later revisions
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
# Person Schema-to-UI Mapping
|
|
||||||
|
|
||||||
Purpose: Map the Person schema to the UI, while clearly separating intended target behavior from current implementation.
|
|
||||||
|
|
||||||
Companion document: user-journey.md
|
|
||||||
Acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Entity Snapshot
|
|
||||||
|
|
||||||
- Table: Person
|
|
||||||
- Primary key: id (UUID)
|
|
||||||
- Related entities: DocumentPerson, Document
|
|
||||||
- Canonical schema references:
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
- docs/schema_v2.md
|
|
||||||
|
|
||||||
## 2. Mapping Rules
|
|
||||||
|
|
||||||
This document uses three lenses:
|
|
||||||
1. Intended behavior: what the UX should support.
|
|
||||||
2. Current behavior: what the code supports today.
|
|
||||||
3. Gap to target: what must change to align implementation with the intended UX.
|
|
||||||
|
|
||||||
## 3. Field Inventory
|
|
||||||
|
|
||||||
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
|
|
||||||
| full_name | str | No | None | Shown, editable on create and update | Required canonical name |
|
|
||||||
| display_name | str | Yes | None | Shown, editable | Optional |
|
|
||||||
| maiden_name | str | Yes | None | Shown, editable | Optional |
|
|
||||||
| birth_date | date | Yes | None | Shown, editable | Canonical exact date when present |
|
|
||||||
| birth_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
|
|
||||||
| birth_place | str | Yes | None | Shown, editable | Optional |
|
|
||||||
| death_date | date | Yes | None | Shown, editable | Canonical exact date when present |
|
|
||||||
| death_date_raw | str | Yes | None | Shown, editable | Approximate or unknown date text |
|
|
||||||
| death_place | str | Yes | None | Shown, editable | Optional |
|
|
||||||
| biography | str | Yes | None | Shown, editable | Optional narrative |
|
|
||||||
| portrait_path | str | Yes | None | Shown, editable | Optional path |
|
|
||||||
| metadata_ | JSONB/JSON | Yes | None | Hidden in first release | Advanced metadata |
|
|
||||||
| created_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed |
|
|
||||||
| updated_at | datetime | No | datetime.now(UTC) | Hidden or read-only | System-managed |
|
|
||||||
|
|
||||||
## 4. CREATE Mapping
|
|
||||||
|
|
||||||
### 4.1 Intended Create Flow
|
|
||||||
|
|
||||||
Entry point: Person page
|
|
||||||
User action: Create new person
|
|
||||||
Success destination: new Person detail page
|
|
||||||
|
|
||||||
| Field | Intended User Input | Required | Visible | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| full_name | Text input | Yes | Yes | Canonical identity field |
|
|
||||||
| display_name | Text input | No | Yes | Optional |
|
|
||||||
| maiden_name | Text input | No | Yes | Optional |
|
|
||||||
| birth_date | Date input | No | Yes | Structured exact date |
|
|
||||||
| birth_date_raw | Text input | No | Yes | Approximate/uncertain date |
|
|
||||||
| birth_place | Text input | No | Yes | Optional |
|
|
||||||
| death_date | Date input | No | Yes | Structured exact date |
|
|
||||||
| death_date_raw | Text input | No | Yes | Approximate/uncertain date |
|
|
||||||
| death_place | Text input | No | Yes | Optional |
|
|
||||||
| biography | Text area | No | Yes | Optional |
|
|
||||||
| portrait_path | Text input | No | Yes | Optional |
|
|
||||||
| metadata_ | None | No | No | Hidden in first release |
|
|
||||||
| created_at | None | No | No | System-generated |
|
|
||||||
| updated_at | None | No | No | Not user-entered |
|
|
||||||
|
|
||||||
Related records during intended create:
|
|
||||||
- No DocumentPerson link is required during Person creation.
|
|
||||||
- Document linking can be done later from Document or Person workflows.
|
|
||||||
|
|
||||||
### 4.2 Current Implementation
|
|
||||||
|
|
||||||
Current entry point: dedicated People page and Person create/edit flows
|
|
||||||
Current user action: open Person create page, fill form fields, optionally upload portrait
|
|
||||||
Current backend path: People page submit callbacks -> DocumentService.create_person() / update_person()
|
|
||||||
|
|
||||||
| Field | Current Value at Create | Source | Visible to User | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| id | Generated UUID | System | No | Person model default factory in src/transcription/db/models.py |
|
|
||||||
| full_name | Form input | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| display_name | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| maiden_name | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| birth_date | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| birth_date_raw | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| birth_place | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| death_date | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| death_date_raw | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| death_place | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| biography | Form input or None | User input | Yes | src/transcription/ui/pages/people_page.py |
|
|
||||||
| portrait_path | Relative upload path or manual path | Upload helper + user input | Yes | src/transcription/ui/pages/people_page.py, src/transcription/services/store.py |
|
|
||||||
| metadata_ | Caller-provided or None | Service/API caller | No | Person model in src/transcription/db/models.py |
|
|
||||||
| created_at | Current UTC timestamp | System | No | Person model default in src/transcription/db/models.py |
|
|
||||||
| updated_at | Current UTC timestamp | System | No | Person model default in src/transcription/db/models.py |
|
|
||||||
|
|
||||||
### 4.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy the intended Create flow, implementation now includes:
|
|
||||||
1. a Person page and dedicated create form
|
|
||||||
2. user-entered controls for Person fields
|
|
||||||
3. create validation and success/failure UX states
|
|
||||||
4. post-submit routing to a Person detail page
|
|
||||||
|
|
||||||
## 5. READ Mapping
|
|
||||||
|
|
||||||
### 5.1 Intended Read Behavior
|
|
||||||
|
|
||||||
On the Person detail page, the user should be able to see:
|
|
||||||
1. Person identity and biographical metadata
|
|
||||||
2. linked Documents (through DocumentPerson)
|
|
||||||
3. empty-state behavior when no linked documents exist
|
|
||||||
|
|
||||||
### 5.2 Current Implementation
|
|
||||||
|
|
||||||
Current Person visibility is implemented in dedicated list/detail/edit/delete pages.
|
|
||||||
|
|
||||||
| Field | Current Rendering | Visible to User | Notes | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| full_name | Rendered in header and summary | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
|
|
||||||
| display_name | Rendered | Yes | Visible in detail and list contexts | src/transcription/ui/pages/people_page.py |
|
|
||||||
| maiden_name | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| birth_date | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| birth_date_raw | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| birth_place | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| death_date | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| death_date_raw | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| death_place | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| biography | Rendered | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| portrait_path | Rendered as text and image when available | Yes | Dedicated Person page exists | `src/transcription/ui/pages/people_page.py` |
|
|
||||||
| metadata_ | Not rendered | No | Hidden advanced field | no current UI field |
|
|
||||||
| created_at | Rendered read-only | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
| updated_at | Rendered read-only | Yes | Visible in detail context | src/transcription/ui/pages/people_page.py |
|
|
||||||
|
|
||||||
### 5.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy the intended Read flow, implementation now includes:
|
|
||||||
1. metadata rendering for Person fields
|
|
||||||
2. linked Documents section with empty states
|
|
||||||
3. document-link navigation paths
|
|
||||||
|
|
||||||
## 6. UPDATE Mapping
|
|
||||||
|
|
||||||
### 6.1 Intended Update Behavior
|
|
||||||
|
|
||||||
The user should be able to edit Person metadata from the Person detail page or a dedicated edit flow.
|
|
||||||
|
|
||||||
Intended editable fields:
|
|
||||||
- full_name
|
|
||||||
- display_name
|
|
||||||
- maiden_name
|
|
||||||
- birth_date
|
|
||||||
- birth_date_raw
|
|
||||||
- birth_place
|
|
||||||
- death_date
|
|
||||||
- death_date_raw
|
|
||||||
- death_place
|
|
||||||
- biography
|
|
||||||
- portrait_path
|
|
||||||
|
|
||||||
Intended system-managed fields:
|
|
||||||
- id
|
|
||||||
- created_at
|
|
||||||
- updated_at
|
|
||||||
|
|
||||||
Hidden in first release:
|
|
||||||
- metadata_
|
|
||||||
|
|
||||||
### 6.2 Current Implementation
|
|
||||||
|
|
||||||
| Field | Updatable via UI | Updatable via Service | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| id | No | Practically no | Primary key should be treated as immutable |
|
|
||||||
| full_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| display_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| maiden_name | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| birth_date | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| birth_date_raw | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| birth_place | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| death_date | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| death_date_raw | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| death_place | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| biography | Yes | Yes | Editable from Person edit page via DocumentService.update_person() |
|
|
||||||
| portrait_path | Yes | Yes | Editable manually and via portrait upload helper |
|
|
||||||
| metadata_ | No | Yes | Technically updatable, hidden in first release |
|
|
||||||
| created_at | No | Technically yes | Should remain system-managed |
|
|
||||||
| updated_at | No | Technically yes | Should remain system-managed |
|
|
||||||
|
|
||||||
### 6.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation now includes:
|
|
||||||
1. Person edit controls in the UI
|
|
||||||
2. validation and save behavior for Person metadata
|
|
||||||
3. a consistent updated_at update policy for Person edits
|
|
||||||
|
|
||||||
## 7. DELETE Mapping
|
|
||||||
|
|
||||||
### 7.1 Intended Delete Behavior
|
|
||||||
|
|
||||||
The UI should provide a delete action for Person with guardrails.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. Deletion can proceed when relationship policy allows no retained document links.
|
|
||||||
2. If linked DocumentPerson records exist and policy requires cleanup first, deletion is blocked.
|
|
||||||
3. Delete confirmation must make clear that deletion is permanent.
|
|
||||||
|
|
||||||
### 7.2 Current Implementation
|
|
||||||
|
|
||||||
| Action | UI Exposed | Backend Capability | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Delete Person | Yes | Yes | Dedicated delete page enforces linked-document guardrails before service delete |
|
|
||||||
|
|
||||||
### 7.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation includes:
|
|
||||||
1. a Person delete control in the UI
|
|
||||||
2. relationship-aware pre-delete checks
|
|
||||||
3. user-facing blocked-delete messaging
|
|
||||||
4. confirmation UX for successful delete attempts
|
|
||||||
|
|
||||||
## 8. Hidden and System-Managed Fields
|
|
||||||
|
|
||||||
| Field | Category | Why Hidden or Protected |
|
|
||||||
|---|---|---|
|
|
||||||
| id | System-managed | Internal identifier |
|
|
||||||
| created_at | System-managed | Audit timestamp |
|
|
||||||
| updated_at | System-managed | Audit timestamp |
|
|
||||||
| metadata_ | Hidden in first release | Advanced JSON metadata not needed in initial UI |
|
|
||||||
|
|
||||||
## 9. Structured Name Deferred Note
|
|
||||||
|
|
||||||
Structured name fields are deferred to a future schema revision.
|
|
||||||
|
|
||||||
Current policy:
|
|
||||||
1. full_name remains canonical and required.
|
|
||||||
|
|
||||||
Future revision intent:
|
|
||||||
1. introduce first_name, middle_name, last_name, and optional suffix fields.
|
|
||||||
2. maintain compatibility with existing full_name records during migration.
|
|
||||||
3. define normalization and reconciliation rules when structured and canonical forms differ.
|
|
||||||
|
|
||||||
## 10. Traceability Anchors
|
|
||||||
|
|
||||||
Schema and models:
|
|
||||||
- docs/schema_v2.md
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
- src/transcription/services/documents.py
|
|
||||||
- src/transcription/ui/pages/people_page.py
|
|
||||||
- src/transcription/services/store.py
|
|
||||||
|
|
||||||
Companion UX spec:
|
|
||||||
- docs/ui/entities/person/user-journey.md
|
|
||||||
|
|
||||||
Acceptance checklist:
|
|
||||||
- docs/ui/entities/person/acceptance-criteria.md
|
|
||||||
|
|
||||||
## 11. Acceptance Checklist Summary
|
|
||||||
|
|
||||||
- Every Person schema field appears in the field inventory.
|
|
||||||
- Intended Create behavior matches the companion user journey.
|
|
||||||
- Current Create behavior reflects dedicated UI form implementation with optional portrait upload handling.
|
|
||||||
- Gaps between intended and current behavior are explicit.
|
|
||||||
- Read, Update, and Delete sections distinguish target behavior from current code.
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# Person User Journey
|
|
||||||
|
|
||||||
Purpose: Define how a user should interact with the UI to create and manage a Person record, including expected inputs, validation, outcomes, and links to Document relationships.
|
|
||||||
|
|
||||||
Scope: This document describes intended user interaction for the Person UI. It is the UX contract for the Person entity.
|
|
||||||
|
|
||||||
Companion schema mapping: schema-mapping.md
|
|
||||||
Companion acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
A Person represents a historical individual who may be associated with one or more Documents.
|
|
||||||
|
|
||||||
Managing a Person is a profile-first workflow:
|
|
||||||
1. The user opens the Person page.
|
|
||||||
2. The user selects Create new person.
|
|
||||||
3. The user enters known biographical fields.
|
|
||||||
4. The system creates the Person record.
|
|
||||||
5. The user can later associate the Person with one or more Documents through DocumentPerson links.
|
|
||||||
|
|
||||||
## 2. User Goal
|
|
||||||
|
|
||||||
The user wants to:
|
|
||||||
1. create and maintain historical person records
|
|
||||||
2. reuse the same Person across multiple Documents
|
|
||||||
3. record both precise and approximate date values where certainty is limited
|
|
||||||
4. link people to documents as author or recipient in future flows
|
|
||||||
|
|
||||||
## 3. Page Model
|
|
||||||
|
|
||||||
### 3.1 Person Page
|
|
||||||
|
|
||||||
The Person page is the general UI surface where users manage people.
|
|
||||||
|
|
||||||
It should support:
|
|
||||||
1. listing or locating existing people
|
|
||||||
2. starting the Create new person flow
|
|
||||||
3. navigating into a specific Person after it exists
|
|
||||||
|
|
||||||
### 3.2 Person Detail Page
|
|
||||||
|
|
||||||
The Person detail page is the page for one specific Person after creation.
|
|
||||||
|
|
||||||
It should show:
|
|
||||||
1. core identity fields
|
|
||||||
2. biographical metadata
|
|
||||||
3. portrait image when available
|
|
||||||
4. related Documents section
|
|
||||||
5. empty state when no linked documents exist yet
|
|
||||||
|
|
||||||
## 4. Entry Point
|
|
||||||
|
|
||||||
Entry point: Person page
|
|
||||||
|
|
||||||
Primary action: Create new person
|
|
||||||
|
|
||||||
Expected UI affordance:
|
|
||||||
1. a visible action labeled Create new person
|
|
||||||
2. activation opens a dedicated form view, modal, or detail panel
|
|
||||||
|
|
||||||
Preferred first implementation:
|
|
||||||
1. dedicated Person create page or panel
|
|
||||||
2. simple labeled form controls
|
|
||||||
3. text inputs are acceptable for first release
|
|
||||||
|
|
||||||
## 5. Create Person Form
|
|
||||||
|
|
||||||
### 5.1 Required Fields
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Full name | full_name | Text input | Yes | Canonical identity field |
|
|
||||||
|
|
||||||
### 5.2 Optional Name Fields
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Display name | display_name | Text input | No | Friendly or abbreviated display |
|
|
||||||
| Maiden name | maiden_name | Text input | No | Historical alternate surname |
|
|
||||||
|
|
||||||
### 5.3 Birth and Death Date Fields
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Birth date | birth_date | Date input | No | Exact known date |
|
|
||||||
| Birth date (approximate/raw) | birth_date_raw | Text input | No | Approximate or uncertain value |
|
|
||||||
| Death date | death_date | Date input | No | Exact known date |
|
|
||||||
| Death date (approximate/raw) | death_date_raw | Text input | No | Approximate or uncertain value |
|
|
||||||
|
|
||||||
Date handling rule:
|
|
||||||
1. exact and raw values may both be entered
|
|
||||||
2. exact date is canonical when present
|
|
||||||
3. raw date is retained as historical context
|
|
||||||
|
|
||||||
### 5.4 Optional Biographical Fields
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Birth place | birth_place | Text input | No | Free text |
|
|
||||||
| Death place | death_place | Text input | No | Free text |
|
|
||||||
| Biography | biography | Text area | No | Narrative context |
|
|
||||||
| Portrait path | portrait_path | Text input | No | File or resource path |
|
|
||||||
| Metadata | metadata_ | Hidden or advanced JSON editor | No | Prefer hidden in first release |
|
|
||||||
|
|
||||||
### 5.5 System Fields
|
|
||||||
|
|
||||||
| Schema Field | User Editable | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | No | System-generated |
|
|
||||||
| created_at | No | System-generated |
|
|
||||||
| updated_at | No | System-managed |
|
|
||||||
|
|
||||||
## 6. Validation Rules
|
|
||||||
|
|
||||||
### 6.1 Required Validation
|
|
||||||
|
|
||||||
1. full_name is required
|
|
||||||
2. save is blocked when full_name is empty
|
|
||||||
|
|
||||||
### 6.2 Date Validation
|
|
||||||
|
|
||||||
1. birth_date and birth_date_raw may coexist
|
|
||||||
2. death_date and death_date_raw may coexist
|
|
||||||
3. exact date fields are canonical when present
|
|
||||||
4. raw fields remain descriptive context
|
|
||||||
|
|
||||||
### 6.3 Integrity Validation
|
|
||||||
|
|
||||||
1. form accepts unknown values for optional fields
|
|
||||||
2. missing birth or death data does not block creation
|
|
||||||
|
|
||||||
## 7. Submission Behavior
|
|
||||||
|
|
||||||
On submit:
|
|
||||||
1. The system validates required fields
|
|
||||||
2. The system creates the Person record
|
|
||||||
3. The system returns the user to the Person detail page
|
|
||||||
4. The system shows a success message
|
|
||||||
5. If portrait upload is used, the file is stored under uploads/portraits/person and portrait_path is set to that relative file path
|
|
||||||
|
|
||||||
Recommended transactional behavior:
|
|
||||||
1. Person writes are atomic
|
|
||||||
2. no partial save state should be persisted
|
|
||||||
|
|
||||||
## 8. Expected Result After Success
|
|
||||||
|
|
||||||
After successful creation:
|
|
||||||
1. The user sees the Person detail page for the new record
|
|
||||||
2. full_name is visible in the header or summary
|
|
||||||
3. empty Related Documents section is shown if no links exist
|
|
||||||
4. The user can proceed to link this person from Document workflows
|
|
||||||
|
|
||||||
## 9. Expected Result After Failure
|
|
||||||
|
|
||||||
If creation fails:
|
|
||||||
1. Show a clear error message
|
|
||||||
2. Show field-level feedback for validation failures
|
|
||||||
3. preserve entered data where possible
|
|
||||||
4. do not show false success messaging
|
|
||||||
|
|
||||||
## 10. Read Person Journey
|
|
||||||
|
|
||||||
### 10.1 User Intent
|
|
||||||
|
|
||||||
The user wants to open a Person and quickly understand:
|
|
||||||
1. identity and key biography fields
|
|
||||||
2. whether this person is linked to any documents
|
|
||||||
3. what next action to take
|
|
||||||
|
|
||||||
### 10.2 Read Surfaces
|
|
||||||
|
|
||||||
The Person detail page should show:
|
|
||||||
1. full_name and display fields
|
|
||||||
2. birth and death fields
|
|
||||||
3. biography summary
|
|
||||||
4. related documents list or empty state
|
|
||||||
5. portrait preview resolved from /uploads when portrait_path is a relative path
|
|
||||||
|
|
||||||
### 10.3 Read Empty State
|
|
||||||
|
|
||||||
If no linked documents exist:
|
|
||||||
1. Show No linked documents yet
|
|
||||||
2. provide guidance to link from Document workflow
|
|
||||||
|
|
||||||
## 11. Update Person Journey
|
|
||||||
|
|
||||||
### 11.1 User Intent
|
|
||||||
|
|
||||||
The user wants to correct or enrich person metadata over time.
|
|
||||||
|
|
||||||
### 11.2 Editable Fields
|
|
||||||
|
|
||||||
Editable:
|
|
||||||
1. full_name
|
|
||||||
2. display_name
|
|
||||||
3. maiden_name
|
|
||||||
4. birth_date
|
|
||||||
5. birth_date_raw
|
|
||||||
6. birth_place
|
|
||||||
7. death_date
|
|
||||||
8. death_date_raw
|
|
||||||
9. death_place
|
|
||||||
10. biography
|
|
||||||
11. portrait_path
|
|
||||||
|
|
||||||
System-managed:
|
|
||||||
1. id
|
|
||||||
2. created_at
|
|
||||||
3. updated_at
|
|
||||||
4. metadata_ can remain hidden in first release
|
|
||||||
|
|
||||||
### 11.3 Update Save Behavior
|
|
||||||
|
|
||||||
On save:
|
|
||||||
1. validate required fields
|
|
||||||
2. persist updates
|
|
||||||
3. refresh updated_at by system policy
|
|
||||||
4. show confirmation
|
|
||||||
5. keep user on Person detail page
|
|
||||||
|
|
||||||
### 11.4 Update Failure Behavior
|
|
||||||
|
|
||||||
1. Show clear error feedback
|
|
||||||
2. preserve form state where possible
|
|
||||||
3. Allow retry
|
|
||||||
|
|
||||||
## 12. Delete Person Journey
|
|
||||||
|
|
||||||
### 12.1 User Intent
|
|
||||||
|
|
||||||
The user wants to remove incorrect or duplicate person records safely.
|
|
||||||
|
|
||||||
### 12.2 Delete Guardrails
|
|
||||||
|
|
||||||
Delete is allowed when:
|
|
||||||
1. Person has no required retained relationships
|
|
||||||
|
|
||||||
Delete is blocked when:
|
|
||||||
1. Person is linked to one or more Documents via DocumentPerson and unlink policy requires cleanup first
|
|
||||||
|
|
||||||
### 12.3 Blocked Delete UX
|
|
||||||
|
|
||||||
1. explain that linked Document relationships exist
|
|
||||||
2. Show link count or list
|
|
||||||
3. provide cleanup path
|
|
||||||
|
|
||||||
### 12.4 Allowed Delete UX
|
|
||||||
|
|
||||||
1. Show a confirmation dialog
|
|
||||||
2. confirm permanent action
|
|
||||||
3. delete Person
|
|
||||||
4. return to Person list with success message
|
|
||||||
|
|
||||||
## 13. Relationship to Other Workflows
|
|
||||||
|
|
||||||
This Person workflow integrates with:
|
|
||||||
1. Document create and update workflows through person lookup and linking
|
|
||||||
2. DocumentPerson mapping for role assignments
|
|
||||||
3. future recipient and multi-person enhancements
|
|
||||||
|
|
||||||
## 14. Relationship to Schema Mapping
|
|
||||||
|
|
||||||
The companion schema-mapping document should specify:
|
|
||||||
1. field visibility per CRUD action
|
|
||||||
2. current implementation status
|
|
||||||
3. intended behavior
|
|
||||||
4. gap-to-target items
|
|
||||||
|
|
||||||
## 15. Deferred Items
|
|
||||||
|
|
||||||
Deferred to future revisions:
|
|
||||||
1. advanced metadata_ editing UI
|
|
||||||
2. multi-person role editing in the Person UI itself
|
|
||||||
3. richer relationship timeline views
|
|
||||||
4. bulk merge or dedup workflows
|
|
||||||
5. structured name fields migration (first_name, middle_name, last_name, optional suffix)
|
|
||||||
|
|
||||||
### 15.1 Structured Name Fields Migration Note
|
|
||||||
|
|
||||||
For now, `full_name` remains the canonical required name field.
|
|
||||||
|
|
||||||
Future revision intent:
|
|
||||||
1. introduce structured fields such as first_name, middle_name, last_name, and optional suffix
|
|
||||||
2. keep full_name during transition for backward compatibility and historical formatting
|
|
||||||
3. define normalization and formatting rules for display and sorting
|
|
||||||
4. update search and dedup workflows to use both structured and canonical forms during migration
|
|
||||||
|
|
||||||
Migration considerations:
|
|
||||||
1. schema migration and backfill strategy for existing Person records
|
|
||||||
2. validation updates for create and update forms
|
|
||||||
3. compatibility for existing APIs and UI components that currently rely on full_name
|
|
||||||
4. clear precedence and reconciliation rules when structured fields and full_name differ
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# Source Acceptance Criteria
|
|
||||||
|
|
||||||
Purpose: Define implementation-ready acceptance criteria for Source Create, Read, Update, and Delete workflows.
|
|
||||||
|
|
||||||
Companion documents:
|
|
||||||
- docs/ui/entities/source/user-journey.md
|
|
||||||
- docs/ui/entities/source/schema-mapping.md
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This checklist covers:
|
|
||||||
1. Create flow
|
|
||||||
2. Read flow
|
|
||||||
3. Update flow
|
|
||||||
4. Delete flow
|
|
||||||
|
|
||||||
This checklist does not cover:
|
|
||||||
1. advanced multi-version revision history design
|
|
||||||
2. job orchestration state-machine behavior
|
|
||||||
3. provider-level transcription internals
|
|
||||||
|
|
||||||
## Create Acceptance Criteria
|
|
||||||
|
|
||||||
### CR-1 Source creation entry
|
|
||||||
1. Given the user is in job creation or job configuration flow
|
|
||||||
2. When the user selects Add sources
|
|
||||||
3. Then the user can upload one or more source files or a folder
|
|
||||||
4. Then source creation is not offered as a standalone first-release document-only flow
|
|
||||||
|
|
||||||
### CR-2 Required create values
|
|
||||||
1. document_id is derived from selected Document context
|
|
||||||
2. JobSource.job_id is derived from the active Job context
|
|
||||||
3. Each created Source is linked to the active Job through JobSource at create time
|
|
||||||
4. page_number is assigned to preserve ordering
|
|
||||||
5. upload_name, filename, and file_path are persisted for each created source
|
|
||||||
|
|
||||||
### CR-3 Ordering and filename strategy
|
|
||||||
1. Given a multi-file or folder upload
|
|
||||||
2. When source records are created
|
|
||||||
3. Then page ordering follows alphabetical order of original filenames
|
|
||||||
4. Then upload_name stores the original filename
|
|
||||||
5. Then filename is stored using UUID plus original extension in the form UUID.extension
|
|
||||||
|
|
||||||
### CR-4 Successful create outcome
|
|
||||||
1. Given valid uploads
|
|
||||||
2. When source creation completes
|
|
||||||
3. Then Source records are created and linked to the Document
|
|
||||||
4. Then Source records are linked to the active Job through JobSource
|
|
||||||
5. Then source list reflects new pages in sequence
|
|
||||||
6. Then the user can open preview or revision workflow
|
|
||||||
|
|
||||||
### CR-5 Create failure outcome
|
|
||||||
1. Given upload or persistence failure
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then no false success feedback is shown
|
|
||||||
4. Then retry path remains available
|
|
||||||
5. Then creation fails when required Document or Job linkage cannot be established
|
|
||||||
|
|
||||||
## Read Acceptance Criteria
|
|
||||||
|
|
||||||
### RD-1 Source detail retrieval
|
|
||||||
1. Given a valid Source id in source context
|
|
||||||
2. When the user opens source detail
|
|
||||||
3. Then source metadata and preview are displayed for that source only
|
|
||||||
|
|
||||||
### RD-2 Transcription and revision visibility
|
|
||||||
1. Original transcription context is visible read-only in Source detail
|
|
||||||
2. Revision state is visible in Source detail
|
|
||||||
3. If revised_text is absent, revision input opens as empty and can be edited
|
|
||||||
|
|
||||||
### RD-3 Missing source state
|
|
||||||
1. Given a missing source
|
|
||||||
2. Then UI shows clear no source available or not found messaging without crashing
|
|
||||||
|
|
||||||
## Update Acceptance Criteria
|
|
||||||
|
|
||||||
### UP-1 Revision editing entry
|
|
||||||
1. Given a source context
|
|
||||||
2. When the user enters revision edit flow
|
|
||||||
3. Then revised_text input is available in Source detail
|
|
||||||
|
|
||||||
### UP-2 Revision validation
|
|
||||||
1. revised_text cannot be saved as empty after trimming
|
|
||||||
2. Warning feedback is shown for invalid empty input
|
|
||||||
|
|
||||||
### UP-3 Successful revision save
|
|
||||||
1. Given valid revision text
|
|
||||||
2. When the user saves
|
|
||||||
3. Then revised_text persists
|
|
||||||
4. Then date_revised is updated
|
|
||||||
5. Then success feedback is shown
|
|
||||||
6. Then refreshed revision content is visible
|
|
||||||
|
|
||||||
### UP-4 Revision save failure
|
|
||||||
1. Given backend failure during save
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then the user-entered text remains available for retry where possible
|
|
||||||
|
|
||||||
## Delete Acceptance Criteria
|
|
||||||
|
|
||||||
### DL-1 Delete entry and confirmation
|
|
||||||
1. Given a source in source context
|
|
||||||
2. When the user selects delete source
|
|
||||||
3. Then a permanent-action confirmation dialog appears
|
|
||||||
|
|
||||||
### DL-2 Dependency guardrails
|
|
||||||
1. If policy requires cleanup of related JobSource records first, delete is blocked
|
|
||||||
2. If policy allows dependent cleanup path, delete can proceed
|
|
||||||
|
|
||||||
### DL-3 Blocked delete behavior
|
|
||||||
1. When blocked
|
|
||||||
2. Then UI explains dependency constraints
|
|
||||||
3. Then UI provides guidance for dependency cleanup
|
|
||||||
|
|
||||||
### DL-4 Successful delete
|
|
||||||
1. Given no blocking dependencies
|
|
||||||
2. When the user confirms deletion
|
|
||||||
3. Then source is removed
|
|
||||||
4. Then success feedback is shown
|
|
||||||
5. Then the user returns to source list context
|
|
||||||
|
|
||||||
### DL-5 Delete failure
|
|
||||||
1. Given backend failure during delete
|
|
||||||
2. Then clear error feedback is shown
|
|
||||||
3. Then the user remains in source context with retry path
|
|
||||||
|
|
||||||
## Cross-Criteria Quality Gates
|
|
||||||
|
|
||||||
### QG-1 Separation of intent and implementation
|
|
||||||
1. UX intent remains in user-journey.md
|
|
||||||
2. Current versus target implementation mapping remains in schema-mapping.md
|
|
||||||
|
|
||||||
### QG-2 Traceability
|
|
||||||
1. Each accepted behavior maps to at least one future UI action or service path
|
|
||||||
2. No acceptance criterion contradicts first-release deferred items
|
|
||||||
|
|
||||||
### QG-3 First-release constraints
|
|
||||||
1. Source creation remains job-create-centric
|
|
||||||
2. revised_text is the primary editable source field in first release
|
|
||||||
3. source creation requires both Document linkage and Job linkage at create time
|
|
||||||
4. source delete management surfaces are phased in later
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
# Source Schema-to-UI Mapping
|
|
||||||
|
|
||||||
Purpose: Map the Source schema to the UI, while clearly separating intended target behavior from current implementation.
|
|
||||||
|
|
||||||
Companion document: user-journey.md
|
|
||||||
Acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Entity Snapshot
|
|
||||||
|
|
||||||
- Table: Source
|
|
||||||
- Primary key: id (UUID)
|
|
||||||
- Related entities: Document, JobSource, Job
|
|
||||||
- Canonical schema references:
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
- docs/schema_v2.md
|
|
||||||
|
|
||||||
## 2. Mapping Rules
|
|
||||||
|
|
||||||
This document uses three lenses:
|
|
||||||
1. Intended behavior: what the UX should support.
|
|
||||||
2. Current behavior: what the code supports today.
|
|
||||||
3. Gap to target: what must change to align implementation with the intended UX.
|
|
||||||
|
|
||||||
## 3. Field Inventory
|
|
||||||
|
|
||||||
| Field | DB Type | Nullable | Default/Auto Value | Intended UI Treatment | Notes |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| id | UUID | No | uuid4() | Hidden, system-managed | Primary key |
|
|
||||||
| document_id | UUID FK | No | None | Hidden/context-managed | Selected Document context |
|
|
||||||
| page_number | int | No | 1 | Shown read-only or ordered list | Sequential ordering |
|
|
||||||
| upload_name | str | No | None | Shown read-only after upload | Original user-provided name |
|
|
||||||
| filename | str | No | None | Shown read-only | Stored filename |
|
|
||||||
| file_path | str | No | None | Usually hidden; preview uses path internally | Filesystem path |
|
|
||||||
| raw_transcription | str | Yes | None | Shown indirectly or hidden | Immutable machine output context |
|
|
||||||
| revised_text | str | Yes | None | Editable in Source detail | Human-authored correction |
|
|
||||||
| date_uploaded | datetime | No | datetime.now(UTC) | Shown read-only | System-managed timestamp |
|
|
||||||
| date_revised | datetime | Yes | None | Shown read-only | Set when revision is saved |
|
|
||||||
|
|
||||||
## 4. CREATE Mapping
|
|
||||||
|
|
||||||
### 4.1 Intended Create Flow
|
|
||||||
|
|
||||||
Entry point: Job creation or job configuration Add sources action
|
|
||||||
User action: upload one or more source files, or a whole folder
|
|
||||||
Success destination: source preview or revision flow in job detail context
|
|
||||||
|
|
||||||
| Field | Intended User Input | Required | Visible | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| document_id | Hidden/context | Yes | No | Comes from selected Document |
|
|
||||||
| JobSource.job_id | Hidden/context | Yes | No | Comes from active Job; required for first release |
|
|
||||||
| page_number | Auto or user-assisted ordering | Yes | Indirectly | Should preserve sequence |
|
|
||||||
| upload_name | File picker name | Yes | Yes | Original display name |
|
|
||||||
| filename | None | Yes | No or read-only | System-stored as UUID.extension |
|
|
||||||
| file_path | None | Yes | No | Storage path |
|
|
||||||
| raw_transcription | None | No | No | Filled by processing |
|
|
||||||
| revised_text | None | No | No | Initially empty |
|
|
||||||
| date_uploaded | None | No | No | System-generated |
|
|
||||||
| date_revised | None | No | No | Null until revision |
|
|
||||||
|
|
||||||
### 4.2 Current Implementation
|
|
||||||
|
|
||||||
Current entry point: Jobs page create flow
|
|
||||||
Current user action: upload one or more files or a folder through a single upload widget
|
|
||||||
Current backend path: job create submit -> create_job_for_document()
|
|
||||||
|
|
||||||
| Field | Current Value at Create | Source | Visible to User | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| id | Generated UUID | System | No | Source model default in src/transcription/db/models.py |
|
|
||||||
| document_id | Selected existing Document id | Job create selection + service write | Indirectly | src/transcription/ui/pages/jobs_page.py, src/transcription/services/store.py |
|
|
||||||
| page_number | Sequential assignment based on existing max and alphabetical upload order | Service | No | src/transcription/services/store.py |
|
|
||||||
| upload_name | original filename basename | User file name transformed by service | Indirectly | src/transcription/services/store.py |
|
|
||||||
| filename | stored generated filename | Service | Indirectly | src/transcription/services/store.py |
|
|
||||||
| file_path | stored path | Service | Indirectly | src/transcription/services/store.py |
|
|
||||||
| raw_transcription | None initially | System | No at create | Source model defaults |
|
|
||||||
| revised_text | None initially | System | No at create | Source model defaults |
|
|
||||||
| date_uploaded | current UTC timestamp | System | No | Source model default |
|
|
||||||
| date_revised | None | System | No | Source model default |
|
|
||||||
|
|
||||||
### 4.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended Create flow, implementation now includes:
|
|
||||||
1. multi-source and folder upload support in job create/configure flows
|
|
||||||
2. deterministic page_number assignment from alphabetical original filename ordering
|
|
||||||
3. enforced create-time Source-to-Document and Source-to-Job linkage invariants
|
|
||||||
4. filename storage policy using UUID.extension
|
|
||||||
|
|
||||||
## 5. READ Mapping
|
|
||||||
|
|
||||||
### 5.1 Intended Read Behavior
|
|
||||||
|
|
||||||
On Source detail/list surfaces, users should be able to see:
|
|
||||||
1. source page preview
|
|
||||||
2. source metadata and ordering
|
|
||||||
3. revision state
|
|
||||||
4. original transcription context
|
|
||||||
|
|
||||||
### 5.2 Current Implementation
|
|
||||||
|
|
||||||
Current Source reading is centered on dedicated Sources list/detail routes with optional document/job filtering.
|
|
||||||
|
|
||||||
| Field | Current Rendering | Visible to User | Notes | Evidence |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| upload_name | Shown in Sources list and Source detail | Yes | Displayed in source context | src/transcription/ui/pages/sources_page.py |
|
|
||||||
| filename | Shown in Sources list and Source detail | Yes | Source metadata shown in list/detail | src/transcription/ui/pages/sources_page.py |
|
|
||||||
| file_path | Hidden from direct text rendering | No | Used internally for preview rendering | src/transcription/ui/components/document_panzoom.py |
|
|
||||||
| page_number | Shown in Sources list and Source detail | Yes | Ordering visible in filtered/global list | src/transcription/ui/pages/sources_page.py |
|
|
||||||
| raw_transcription | Shown read-only in Source detail | Yes | Read from latest linked JobSource context | src/transcription/ui/pages/sources_page.py |
|
|
||||||
| revised_text | Shown and editable in Source detail | Yes | Saved through revision action | src/transcription/ui/pages/sources_page.py |
|
|
||||||
| date_uploaded | Shown in Source detail | Yes | Read-only metadata | src/transcription/ui/pages/sources_page.py |
|
|
||||||
| date_revised | Shown in Source detail | Yes | Read-only metadata after revision save | src/transcription/ui/pages/sources_page.py |
|
|
||||||
|
|
||||||
### 5.3 Gap to Target
|
|
||||||
|
|
||||||
To satisfy intended Read flow, implementation must add:
|
|
||||||
1. optional list filtering controls in-page (current filtering is URL/context based)
|
|
||||||
2. optional page-specific navigation enhancements beyond current list/detail pattern
|
|
||||||
|
|
||||||
## 6. UPDATE Mapping
|
|
||||||
|
|
||||||
### 6.1 Intended Update Behavior
|
|
||||||
|
|
||||||
Primary user update for Source is revised_text maintenance in Source detail.
|
|
||||||
|
|
||||||
Intended editable fields (first release):
|
|
||||||
- revised_text
|
|
||||||
|
|
||||||
Intended read-only fields (first release):
|
|
||||||
- document_id
|
|
||||||
- page_number
|
|
||||||
- upload_name
|
|
||||||
- filename
|
|
||||||
- file_path
|
|
||||||
- raw_transcription
|
|
||||||
- date_uploaded
|
|
||||||
- date_revised
|
|
||||||
|
|
||||||
### 6.2 Current Implementation
|
|
||||||
|
|
||||||
| Field | Updatable via UI | Updatable via Service | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| revised_text | Yes | Yes | Saved via TranscriptionService.upsert_revision_for_source() from Source detail |
|
|
||||||
| date_revised | No | Yes | Set automatically on revision save |
|
|
||||||
| other fields | No | Technically yes in service layer | No first-class UI editing flow |
|
|
||||||
|
|
||||||
### 6.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation should add in a later revision:
|
|
||||||
1. optional future controls for page ordering and metadata corrections
|
|
||||||
2. revision history and conflict-resolution UX beyond single revised_text updates
|
|
||||||
|
|
||||||
## 7. DELETE Mapping
|
|
||||||
|
|
||||||
### 7.1 Intended Delete Behavior
|
|
||||||
|
|
||||||
Source deletion is deferred in the current UI.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
1. Deletion can proceed when policy allows cleanup of related JobSource records.
|
|
||||||
2. If related execution history must be preserved first, deletion is blocked with guidance.
|
|
||||||
|
|
||||||
### 7.2 Current Implementation
|
|
||||||
|
|
||||||
| Action | UI Exposed | Backend Capability | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Delete Source | No | Yes | TranscriptionService.delete_source() exists, no dedicated UI delete flow |
|
|
||||||
|
|
||||||
### 7.3 Gap to Target
|
|
||||||
|
|
||||||
Implementation should add in a future revision:
|
|
||||||
1. source delete controls in source/document context UI
|
|
||||||
2. dependency checks for JobSource links
|
|
||||||
3. blocked-delete messaging and cleanup path guidance
|
|
||||||
4. confirmation UX for successful delete attempts
|
|
||||||
|
|
||||||
## 8. Hidden and System-Managed Fields
|
|
||||||
|
|
||||||
| Field | Category | Why Hidden or Protected |
|
|
||||||
|---|---|---|
|
|
||||||
| id | System-managed | Internal identifier |
|
|
||||||
| document_id | Context-managed | Derived from selected document context |
|
|
||||||
| file_path | Operational/internal | Used for file storage and preview plumbing |
|
|
||||||
| date_uploaded | System-managed | Audit timestamp |
|
|
||||||
| date_revised | System-managed | Revision timestamp set by system |
|
|
||||||
|
|
||||||
## 9. Traceability Anchors
|
|
||||||
|
|
||||||
Schema and models:
|
|
||||||
- docs/schema_v2.md
|
|
||||||
- src/transcription/db/models.py
|
|
||||||
|
|
||||||
Current implementation:
|
|
||||||
- src/transcription/services/store.py
|
|
||||||
- src/transcription/services/transcription.py
|
|
||||||
- src/transcription/ui/pages/sources_page.py
|
|
||||||
- src/transcription/ui/pages/jobs_page.py
|
|
||||||
- src/transcription/ui/pages/documents_page.py
|
|
||||||
- src/transcription/ui/components/document_panzoom.py
|
|
||||||
|
|
||||||
Companion UX spec:
|
|
||||||
- docs/ui/entities/source/user-journey.md
|
|
||||||
|
|
||||||
Acceptance checklist:
|
|
||||||
- docs/ui/entities/source/acceptance-criteria.md
|
|
||||||
|
|
||||||
## 10. Acceptance Checklist Summary
|
|
||||||
|
|
||||||
- Every Source schema field appears in the field inventory.
|
|
||||||
- Intended Create behavior matches the companion user journey.
|
|
||||||
- Source create invariant requires both Document linkage and Job linkage at create time.
|
|
||||||
- Current behavior reflects upload-centric create flow and dedicated Sources list/detail review flow.
|
|
||||||
- Gaps between intended and current behavior are explicit.
|
|
||||||
- Read, Update, and Delete sections distinguish target behavior from current code.
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
# Source User Journey
|
|
||||||
|
|
||||||
Purpose: Define how a user should interact with the UI to create and manage Source records, including page-level transcription context and revision behavior.
|
|
||||||
|
|
||||||
Scope: This document describes intended user interaction for the Source UI. It is the UX contract for the Source entity.
|
|
||||||
|
|
||||||
Companion schema mapping: schema-mapping.md
|
|
||||||
Companion acceptance criteria: acceptance-criteria.md
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
A Source represents one page or file unit associated with a Document.
|
|
||||||
|
|
||||||
Managing Source records is page-first:
|
|
||||||
1. The user starts from a transcription job flow.
|
|
||||||
2. The user adds one or more source files.
|
|
||||||
3. The system creates Source records linked to the Document and linked to the Job through JobSource.
|
|
||||||
4. The user reviews source lists from a dedicated Sources page.
|
|
||||||
5. The user opens Source detail to review preview, metadata, transcription text, and revision text.
|
|
||||||
|
|
||||||
## 2. User Goal
|
|
||||||
|
|
||||||
The user wants to:
|
|
||||||
1. add page files to a Document
|
|
||||||
2. ensure every source is attached to the transcription job context
|
|
||||||
3. keep page order reliable
|
|
||||||
4. review original machine output
|
|
||||||
5. save human revisions per page
|
|
||||||
6. navigate source pages efficiently
|
|
||||||
|
|
||||||
## 3. Page Model
|
|
||||||
|
|
||||||
### 3.1 Source List Surface
|
|
||||||
|
|
||||||
A Source list surface should support:
|
|
||||||
1. listing source pages globally or filtered by selected Document or Job
|
|
||||||
2. sorting by page_number
|
|
||||||
3. opening the owning Document or Job context
|
|
||||||
4. opening Source detail for a selected source
|
|
||||||
|
|
||||||
### 3.2 Source Detail Surface
|
|
||||||
|
|
||||||
Source detail supports:
|
|
||||||
1. pan/zoom image or PDF preview
|
|
||||||
2. read-only source metadata (page number, names, timestamps)
|
|
||||||
3. read-only original transcription text
|
|
||||||
4. editable revision text with save action
|
|
||||||
|
|
||||||
## 4. Entry Points
|
|
||||||
|
|
||||||
Primary entry points:
|
|
||||||
1. from Job workflow, Add sources while creating or configuring a job
|
|
||||||
2. from Job detail, open filtered Sources for the current Job
|
|
||||||
3. from Document detail, open filtered Sources for the current Document
|
|
||||||
4. from global navigation, open all Sources
|
|
||||||
|
|
||||||
Current implementation note:
|
|
||||||
1. source interaction occurs in job-create flow and dedicated Sources list/detail flows
|
|
||||||
|
|
||||||
## 5. Create Source Flow
|
|
||||||
|
|
||||||
### 5.1 User Intent
|
|
||||||
|
|
||||||
The user wants to attach one or more files to a Document so each page can be processed and reviewed.
|
|
||||||
|
|
||||||
### 5.2 Create from Job Context
|
|
||||||
|
|
||||||
1. The user starts from a job-creation or job-configuration flow
|
|
||||||
2. The user can upload one or more files, or upload a whole folder
|
|
||||||
3. The system creates Source rows linked to the selected Document
|
|
||||||
4. The system creates JobSource links for the active Job as part of this flow
|
|
||||||
5. Source creation fails if required Document or Job linkage cannot be established
|
|
||||||
|
|
||||||
### 5.3 Source Create Inputs
|
|
||||||
|
|
||||||
| UI Label | Schema Field | Input Type | Required | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Source files | upload_name/filename/file_path | Multi-file upload or folder upload | Yes | User may select one file, many files, or a folder |
|
|
||||||
| Processing order | page_number assignment rule | System rule | Yes | If multiple files are uploaded, processing order is alphabetical by original filename |
|
|
||||||
| Document reference | document_id | Hidden/context | Yes | Comes from selected Document |
|
|
||||||
| Job reference | JobSource.job_id | Hidden/context | Yes | Required for first-release source creation |
|
|
||||||
|
|
||||||
### 5.4 Filename Strategy
|
|
||||||
|
|
||||||
1. store original user filename in upload_name
|
|
||||||
2. store persisted filename using UUID plus original extension only, in the form UUID.extension
|
|
||||||
3. this replaces the previous UUID-upload_name.extension pattern
|
|
||||||
|
|
||||||
### 5.5 Ordering Guidance
|
|
||||||
|
|
||||||
1. multi-file or folder uploads are processed alphabetically by original filename
|
|
||||||
2. UI should show a warning or helper note so users understand that filename conventions control order
|
|
||||||
|
|
||||||
Suggested helper text:
|
|
||||||
1. Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control page order.
|
|
||||||
|
|
||||||
### 5.6 System-Managed Values at Create
|
|
||||||
|
|
||||||
| Schema Field | User Editable | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| id | No | System-generated |
|
|
||||||
| date_uploaded | No | System-generated |
|
|
||||||
| raw_transcription | No | Filled later by processing |
|
|
||||||
| revised_text | No | Initially empty |
|
|
||||||
| date_revised | No | Initially null |
|
|
||||||
|
|
||||||
### 5.7 Expected Create Result
|
|
||||||
|
|
||||||
After successful source create:
|
|
||||||
1. Source is linked to the Document
|
|
||||||
2. Source appears in page order derived from alphabetical upload filename ordering
|
|
||||||
3. Source is linked to the Job through JobSource at create time
|
|
||||||
4. The user can open the owning Document or Job context
|
|
||||||
|
|
||||||
### 5.8 Source Creation Invariant
|
|
||||||
|
|
||||||
For first release:
|
|
||||||
1. every new Source must have a Document link (Source.document_id)
|
|
||||||
2. every new Source must have a Job link through JobSource (JobSource.job_id -> JobSource.source_id)
|
|
||||||
3. source creation is treated as part of transcription workflow, not a standalone document-only upload path
|
|
||||||
|
|
||||||
## 6. Read Source Journey
|
|
||||||
|
|
||||||
### 6.1 User Intent
|
|
||||||
|
|
||||||
The user wants to view each page file and understand file identity and processing context.
|
|
||||||
|
|
||||||
### 6.2 Read Surface Expectations
|
|
||||||
|
|
||||||
The UI should show:
|
|
||||||
1. source lists for current context (all, document-filtered, or job-filtered)
|
|
||||||
2. upload_name as the original user-provided filename
|
|
||||||
3. filename as the stored system filename
|
|
||||||
4. page_number and ordering context
|
|
||||||
5. the owning Document and Job navigation context
|
|
||||||
6. direct action to open Source detail
|
|
||||||
|
|
||||||
### 6.3 Read Empty and Missing States
|
|
||||||
|
|
||||||
If source is missing:
|
|
||||||
1. Show clear not found or no source available messaging
|
|
||||||
|
|
||||||
If source metadata is partially unavailable:
|
|
||||||
1. Show fallback labels and keep navigation available where possible
|
|
||||||
|
|
||||||
## 7. Update Source Journey
|
|
||||||
|
|
||||||
### 7.1 User Intent
|
|
||||||
|
|
||||||
The user primarily tracks page-level source records while preserving raw machine output in the service layer.
|
|
||||||
|
|
||||||
### 7.2 Intended Editable Fields
|
|
||||||
|
|
||||||
Editable in first release:
|
|
||||||
1. revised_text in Source detail
|
|
||||||
|
|
||||||
Read-only in first release:
|
|
||||||
1. upload_name
|
|
||||||
2. filename
|
|
||||||
3. file_path
|
|
||||||
4. raw_transcription
|
|
||||||
5. page_number
|
|
||||||
6. date_uploaded
|
|
||||||
7. date_revised set by system on revision save
|
|
||||||
|
|
||||||
### 7.3 Revision Save Behavior
|
|
||||||
|
|
||||||
On save:
|
|
||||||
1. validate revision text is non-empty after trimming
|
|
||||||
2. persist revised_text
|
|
||||||
3. set date_revised
|
|
||||||
4. show success feedback
|
|
||||||
5. keep user in current source context
|
|
||||||
|
|
||||||
### 7.4 Revision Failure Behavior
|
|
||||||
|
|
||||||
If save fails:
|
|
||||||
1. Show clear error feedback
|
|
||||||
2. keep user input where possible
|
|
||||||
3. Allow retry
|
|
||||||
|
|
||||||
## 8. Delete Source Journey
|
|
||||||
|
|
||||||
### 8.1 User Intent
|
|
||||||
|
|
||||||
The user may need to remove incorrect or duplicate source files from a Document.
|
|
||||||
|
|
||||||
### 8.2 Guardrails
|
|
||||||
|
|
||||||
Delete is allowed when:
|
|
||||||
1. policy allows removal of related processing history
|
|
||||||
|
|
||||||
Delete is blocked when:
|
|
||||||
1. policy requires preserving dependent job-source execution records until explicit cleanup
|
|
||||||
|
|
||||||
### 8.3 Delete UX
|
|
||||||
|
|
||||||
When blocked:
|
|
||||||
1. explain dependency constraints in a future delete flow
|
|
||||||
2. show cleanup guidance in a future delete flow
|
|
||||||
|
|
||||||
When allowed:
|
|
||||||
1. confirm permanent removal in a future delete flow
|
|
||||||
2. remove source in a future delete flow
|
|
||||||
3. return to source list with success state in a future delete flow
|
|
||||||
|
|
||||||
## 9. Relationship to Other Workflows
|
|
||||||
|
|
||||||
Source workflow integrates with:
|
|
||||||
1. Document workflow for ownership and page organization
|
|
||||||
2. Job workflow for processing status and outputs
|
|
||||||
3. revision workflow for human correction lifecycle
|
|
||||||
|
|
||||||
## 10. Relationship to Schema Mapping
|
|
||||||
|
|
||||||
The companion schema-mapping document should specify:
|
|
||||||
1. field visibility per CRUD action
|
|
||||||
2. current implementation status
|
|
||||||
3. intended behavior
|
|
||||||
4. gap-to-target items
|
|
||||||
|
|
||||||
## 11. Deferred Items
|
|
||||||
|
|
||||||
Deferred to future revisions:
|
|
||||||
1. bulk page reordering UX
|
|
||||||
2. multi-file upload progress and resumable upload UX
|
|
||||||
3. revision history versions beyond a single revised_text field
|
|
||||||
4. richer per-page status dashboards
|
|
||||||
5. source delete UI with dependency-aware confirmation
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# UI Entity Traceability Matrix
|
|
||||||
|
|
||||||
Purpose: Map acceptance criteria to concrete implementation anchors and current delivery status.
|
|
||||||
|
|
||||||
Updated: 2026-08-02
|
|
||||||
|
|
||||||
Status legend:
|
|
||||||
- Implemented: behavior exists in current UI and service flow
|
|
||||||
- Partial: parts exist, but user-facing behavior or guardrails are incomplete
|
|
||||||
- Planned: documented intent with no dedicated UI implementation yet
|
|
||||||
|
|
||||||
## Document
|
|
||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Read detail and metadata | RD-1, RD-2, RD-7 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Dedicated Document detail route renders metadata, read-only system timestamps, and invalid/missing-id states. |
|
|
||||||
| Related sections and navigation | RD-3, RD-4, RD-5, RD-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py | Document detail now shows linked people plus document-scoped Sources and Jobs navigation for the current document. |
|
|
||||||
| Update entry, validation, and author linkage | UP-1, UP-2, UP-3, UP-4, UP-5, UP-6 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated edit page includes required-field validation messaging, date parsing rules, and author relationship selection with save path routed back to document detail. |
|
|
||||||
| Delete controls and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/documents_page.py; src/transcription/services/documents.py; tests/ui/test_documents_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, dependency-category blocking, and guarded backend delete behavior. |
|
|
||||||
|
|
||||||
## Person
|
|
||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Create flow and validation | CR-1, CR-2, CR-3, CR-4, CR-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py | Dedicated Person create page with required full_name validation, optional field handling, and success routing to detail. |
|
|
||||||
| Read detail and linked documents | RD-1, RD-2, RD-3, RD-4 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Person detail route renders metadata, full-name summary, portrait preview when available, linked-document section, and invalid/missing-id states. |
|
|
||||||
| Update behavior | UP-1, UP-2, UP-3, UP-4, UP-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated Person edit page supports allowed fields, required full_name validation, and save path back to detail. |
|
|
||||||
| Delete behavior and guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/people_page.py; src/transcription/services/documents.py; tests/ui/test_people_page.py; tests/services/test_document_service.py | Dedicated delete page provides permanent-action confirmation, linked-document blocking message, and guarded backend delete behavior. |
|
|
||||||
|
|
||||||
## Source
|
|
||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Create entry and required links | CR-1, CR-2, CR-4, CR-5 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/upload_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Source upload/create is job-create-context only (legacy upload route redirects), with required Document and JobSource linkage enforced. |
|
|
||||||
| Ordering and filename policy | CR-3 | Implemented | src/transcription/services/store.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file/folder uploads are ordered alphabetically by original filename, helper text is visible, and stored filenames use generated unique-id plus extension. |
|
|
||||||
| Read and navigation visibility | RD-1, RD-2, RD-3 | Implemented | src/transcription/ui/pages/sources_page.py; src/transcription/ui/pages/documents_page.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_sources_page.py; tests/ui/test_documents_page.py; tests/ui/test_jobs_page.py | Dedicated Sources list/detail routes support global, document-filtered, and job-filtered navigation plus source metadata and preview rendering. |
|
|
||||||
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/sources_page.py; src/transcription/services/transcription.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Source detail exposes revision edit/save UX with non-empty validation, success feedback, and refreshed state after save. |
|
|
||||||
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Planned | src/transcription/services/transcription.py; tests/services/test_transcription_service.py | Job-detail source delete UI was removed from the current simplified flow; backend guardrails remain for future reinstatement. |
|
|
||||||
|
|
||||||
## Job
|
|
||||||
|
|
||||||
| Criteria Group | Acceptance IDs | Status | Primary Implementation Anchors | Notes |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Create entry and required links | CR-1, CR-2, CR-5, CR-6 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py; tests/services/test_store.py | Jobs list now has explicit Create entry and `/jobs/new` create flow with Document selection, combined file/folder upload widget, and submit routing to job detail. |
|
|
||||||
| Source ordering and upload behavior | CR-3 | Implemented | src/transcription/services/store.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_jobs_page.py | Multi-file and folder upload are supported through one widget, uploads are sorted alphabetically by original filename, and helper guidance is shown in create UI. |
|
|
||||||
| Provider/model/prompt visibility | CR-4, RD-4 | Implemented | src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Provider/model/prompt fields are visible in create and detail flows when known (with pending fallback labels). |
|
|
||||||
| Jobs list and detail read states | RD-1, RD-2, RD-3, RD-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/components/table/jobs.py; tests/ui/test_jobs_page.py | Jobs list, detail route, document-scoped navigation, and invalid/missing id states are present. |
|
|
||||||
| Revision update behavior | UP-1, UP-2, UP-3, UP-4 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/ui/pages/sources_page.py; src/transcription/services/transcription.py; tests/ui/test_jobs_page.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Job detail routes users to job-scoped Sources where Source detail provides revision edit/save workflow. |
|
|
||||||
| Lifecycle visibility and retry indicators | UP-5 | Implemented | src/transcription/services/jobs.py; src/transcription/services/workflows.py; src/transcription/ui/pages/jobs_page.py; tests/ui/test_jobs_page.py | Job detail now surfaces lifecycle status plus retry/update metadata while lifecycle fields remain system-managed (no direct user edit controls). |
|
|
||||||
| Delete and dependency guardrails | DL-1, DL-2, DL-3, DL-4, DL-5 | Implemented | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job delete page enforces processing-state block, confirms allowed deletes, and routes back to jobs list on success. |
|
|
||||||
|
|
||||||
## Quality Gate Coverage
|
|
||||||
|
|
||||||
| Quality Gate | Acceptance IDs | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Separation of intent vs implementation | QG-1 across entities | Implemented | user-journey.md, schema-mapping.md, and acceptance-criteria.md are maintained per entity. |
|
|
||||||
| Traceability from criteria to implementation | QG-2 across entities | Implemented | This matrix provides criterion-to-code anchors and current status tags. |
|
|
||||||
| First-release constraints | QG-3 across entities | Implemented | Constraints are documented and aligned with current flows: jobs-first source upload, visible provider/model/prompt context, and system-managed lifecycle fields. |
|
|
||||||
|
|
||||||
## Supporting Entity Coverage
|
|
||||||
|
|
||||||
| Supporting Entity | Documentation | Status | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| document-person | docs/ui/entities/document-person/schema-mapping.md | Completed | Supporting-entity schema mapping created; no standalone UI contract file by design. |
|
|
||||||
| job-source | docs/ui/entities/job-source/schema-mapping.md | Completed | Supporting-entity schema mapping created; no standalone UI contract file by design. |
|
|
||||||
|
|
||||||
## Suggested Implementation Order
|
|
||||||
|
|
||||||
1. Aggregate final acceptance review across Document, Person, Source, and Job criteria.
|
|
||||||
|
|
||||||
## Aggregate Final Review Snapshot (2026-08-02)
|
|
||||||
|
|
||||||
| Entity | Acceptance IDs still not fully met | Evidence | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Document | None | src/transcription/ui/pages/documents_page.py; tests/ui/test_documents_page.py | Document criteria are covered by dedicated detail/edit/delete pages and document-scoped related views. |
|
|
||||||
| Person | None | src/transcription/ui/pages/people_page.py; tests/ui/test_people_page.py | Person criteria are covered by dedicated create/detail/edit/delete pages with relationship-aware delete guardrails. |
|
|
||||||
| Source | None | src/transcription/services/store.py; src/transcription/ui/pages/sources_page.py; src/transcription/ui/pages/jobs_page.py; tests/services/test_store.py; tests/ui/test_sources_page.py; tests/services/test_transcription_service.py | Source criteria are covered by job-context create behavior, ordering/filename policy, dedicated list/detail read flow, revision flow, and delete guardrails. |
|
|
||||||
| Job | None | src/transcription/ui/pages/jobs_page.py; src/transcription/services/jobs.py; tests/ui/test_jobs_page.py; tests/services/test_job_service.py | Job criteria are covered by create/read/revision/lifecycle visibility and delete guardrails in dedicated routes. |
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
# System Architecture (Version 1)
|
|
||||||
|
|
||||||
This document describes the production architecture of the personal historical-document transcription system. The system is intentionally optimized for single-user operation, low operational overhead, and clean internal boundaries that support future growth without rewrites.
|
|
||||||
|
|
||||||
## Architecture Objectives
|
|
||||||
|
|
||||||
The production architecture is designed to:
|
|
||||||
|
|
||||||
- preserve verbatim family-history source material as searchable text
|
|
||||||
- keep operational complexity low for a personal deployment
|
|
||||||
- support asynchronous transcription without requiring distributed infrastructure
|
|
||||||
- maintain clear module boundaries so extensions can be added incrementally
|
|
||||||
|
|
||||||
## Production Scope And Scale
|
|
||||||
|
|
||||||
The deployed system targets personal use and a corpus of several thousand documents processed over time. The architecture favors simple, composable building blocks over distributed orchestration.
|
|
||||||
|
|
||||||
Current scope includes:
|
|
||||||
|
|
||||||
- content source upload and metadata capture
|
|
||||||
- asynchronous transcription jobs
|
|
||||||
- prompt-library driven transcription behavior, with one Markdown file per prompt
|
|
||||||
- original transcription review and optional revision review
|
|
||||||
- full-text search over accepted transcripts
|
|
||||||
- export of transcript data
|
|
||||||
|
|
||||||
## Deployment Topology
|
|
||||||
|
|
||||||
The production deployment uses [Docker Compose](https://docs.docker.com/compose/) and treats containerized databases as extremely lightweight operational dependencies.
|
|
||||||
|
|
||||||
Running [PostgreSQL](https://www.postgresql.org/docs/) in its own container is considered simple by default for this system.
|
|
||||||
|
|
||||||
Running [MongoDB](https://www.mongodb.com/docs/) in its own container is also considered simple when document-centric storage is enabled.
|
|
||||||
|
|
||||||
Container count is not a hard architectural limit; a three-container deployment (app, PostgreSQL, MongoDB) is an acceptable baseline.
|
|
||||||
|
|
||||||
### Baseline Topology (Two Containers)
|
|
||||||
|
|
||||||
- one application container
|
|
||||||
- one PostgreSQL container
|
|
||||||
- embedded background worker execution inside the app process
|
|
||||||
|
|
||||||
### Expanded Topology (Three Containers)
|
|
||||||
|
|
||||||
- application container
|
|
||||||
- PostgreSQL container
|
|
||||||
- MongoDB container
|
|
||||||
|
|
||||||
No additional queue, scheduler, or search-engine containers are required in the baseline production setup.
|
|
||||||
|
|
||||||
## Runtime Architecture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
User[Browser User] --> App[FastAPI + NiceGUI Service]
|
|
||||||
App --> Worker[In-process Background Worker]
|
|
||||||
App --> PG[(PostgreSQL)]
|
|
||||||
App --> MG[(MongoDB Document Store)]
|
|
||||||
Worker --> AI[Transcription Provider]
|
|
||||||
Worker --> PG
|
|
||||||
Worker --> MG
|
|
||||||
```
|
|
||||||
|
|
||||||
## Runtime Ownership And Startup Policy
|
|
||||||
|
|
||||||
The current implementation now uses explicit lifespan-owned runtime resources.
|
|
||||||
|
|
||||||
- application lifespan initializes and disposes database runtime resources
|
|
||||||
- worker lifecycle is owned by application lifespan startup/shutdown
|
|
||||||
- worker receives lifespan-owned database engine dependency explicitly
|
|
||||||
- schema bootstrap policy is environment-aware and explicit:
|
|
||||||
- development/test default to bootstrap enabled
|
|
||||||
- production defaults to bootstrap disabled
|
|
||||||
- explicit override is available via configuration
|
|
||||||
|
|
||||||
This aligns implementation toward REQ-7 and REQ-10 while preserving personal-scale operational simplicity.
|
|
||||||
|
|
||||||
## Layered Module Structure
|
|
||||||
|
|
||||||
### Interface Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- HTTP API and UI routes
|
|
||||||
- request/response validation
|
|
||||||
- status and result presentation
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- business-rule enforcement
|
|
||||||
- data-access implementation
|
|
||||||
|
|
||||||
### Application Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- upload and job orchestration
|
|
||||||
- state transitions and retry policy
|
|
||||||
- coordination across domain and infrastructure ports
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- provider-specific protocol details
|
|
||||||
- ORM or storage-specific logic
|
|
||||||
|
|
||||||
### Domain Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- verbatim transcription policy
|
|
||||||
- revision and provenance invariants
|
|
||||||
- confidence and annotation semantics
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- web framework concerns
|
|
||||||
- database and network I/O
|
|
||||||
|
|
||||||
### Infrastructure Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- persistence adapters (PostgreSQL and MongoDB)
|
|
||||||
- transcription-provider adapter
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- business policy decisions
|
|
||||||
|
|
||||||
## Processing Workflow
|
|
||||||
|
|
||||||
Production transcription flow:
|
|
||||||
|
|
||||||
1. A user uploads one or more content sources through the UI or API.
|
|
||||||
2. The application validates payloads and creates document, source, and job records.
|
|
||||||
3. The in-process worker de-queues the job and calls the transcription provider.
|
|
||||||
4. The application persists original transcription output on the job, plus confidence metadata and provenance events.
|
|
||||||
5. Job status transitions from queued to processing to transcribed or failed.
|
|
||||||
6. The UI and API expose status, optional revision to original transcription, and searchable transcription text.
|
|
||||||
|
|
||||||
## Data Model Ownership
|
|
||||||
|
|
||||||
System-of-record entities:
|
|
||||||
|
|
||||||
- documents and content sources
|
|
||||||
- transcription jobs, original transcription, and status events
|
|
||||||
- transcript revisions
|
|
||||||
- provenance metadata
|
|
||||||
|
|
||||||
### Original Transcription And Revision Ownership
|
|
||||||
|
|
||||||
- each processing job stores the original immutable provider output (`text`)
|
|
||||||
- provider metadata (`provider`, `model`, `prompt_name`) and failure detail (`error_detail`) are job-owned processing artifacts
|
|
||||||
- revisions are optional user-authored edits linked to a content source
|
|
||||||
- a revision can be created from original `job.text`
|
|
||||||
- many jobs will have zero revisions; revisions are additive and never overwrite original provider output
|
|
||||||
- a document groups one or more content sources (images, PDFs, and future source types)
|
|
||||||
|
|
||||||
Storage strategy:
|
|
||||||
|
|
||||||
- PostgreSQL for relational system-of-record entities
|
|
||||||
- MongoDB for document-oriented payloads and large transcription artifacts
|
|
||||||
- versioned prompt artifacts stored as individual Markdown files for human editing and refinement
|
|
||||||
- in-memory execution state treated as ephemeral
|
|
||||||
|
|
||||||
## Transcription Prompt Asset Policy
|
|
||||||
|
|
||||||
The production system treats transcription prompts as maintainable content assets.
|
|
||||||
|
|
||||||
- each transcription prompt is stored in its own Markdown file
|
|
||||||
- prompt files are designed for direct human editing and iterative refinement
|
|
||||||
- prompt updates are independent and do not require bundling unrelated prompt changes
|
|
||||||
- prompt file identity and revision history are tracked through normal repository version control
|
|
||||||
|
|
||||||
## Simplicity Guardrails
|
|
||||||
|
|
||||||
The production system enforces these constraints to prevent accidental over-engineering:
|
|
||||||
|
|
||||||
- PostgreSQL in a container is treated as a lightweight default dependency
|
|
||||||
- MongoDB in a container is treated as a lightweight optional dependency
|
|
||||||
- three containers (app, PostgreSQL, MongoDB) is an acceptable simple deployment
|
|
||||||
- no dedicated queue or search cluster is introduced without measured need
|
|
||||||
- external infrastructure is added only behind existing ports/adapters
|
|
||||||
|
|
||||||
## Extension Path
|
|
||||||
|
|
||||||
The architecture supports additive growth without changing domain contracts.
|
|
||||||
|
|
||||||
### Stage 1: Foundation (Current)
|
|
||||||
|
|
||||||
- upload, transcription, review, search, export
|
|
||||||
- in-process worker execution
|
|
||||||
- single provider adapter
|
|
||||||
- app plus PostgreSQL deployment
|
|
||||||
|
|
||||||
### Stage 2: Throughput Hardening
|
|
||||||
|
|
||||||
- optional MongoDB document-store enablement
|
|
||||||
- optional external worker/queue process
|
|
||||||
- stronger retry and dead-letter handling
|
|
||||||
|
|
||||||
### Stage 3: Intelligence Features
|
|
||||||
|
|
||||||
- entity extraction and cross-document linking
|
|
||||||
- timeline and narrative assembly
|
|
||||||
- optional multi-provider routing
|
|
||||||
|
|
||||||
Each stage preserves existing module boundaries and keeps migration risk low.
|
|
||||||
|
|
||||||
## Test Strategy
|
|
||||||
|
|
||||||
The test strategy is aligned to personal-scale operation with fast, deterministic feedback.
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
- domain transcription rules and annotation behavior
|
|
||||||
- revision-history invariants
|
|
||||||
- job state-transition logic
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
- repository behavior and transaction boundaries
|
|
||||||
- persistence-adapter and provider adapter contract mapping
|
|
||||||
- upload-to-persistence roundtrip
|
|
||||||
|
|
||||||
### End-to-End Tests
|
|
||||||
|
|
||||||
- happy path: upload, transcribe, review, search, export
|
|
||||||
- failure path: provider error, retry, surfaced failed status
|
|
||||||
|
|
||||||
### CI Execution Model
|
|
||||||
|
|
||||||
- fast suite on each push
|
|
||||||
- optional slower provider-sandbox checks on scheduled runs
|
|
||||||
|
|
||||||
## Risks And Controls
|
|
||||||
|
|
||||||
### Runtime Responsiveness
|
|
||||||
|
|
||||||
Risk:
|
|
||||||
|
|
||||||
- long jobs can reduce responsiveness in a single-process deployment
|
|
||||||
|
|
||||||
Control:
|
|
||||||
|
|
||||||
- bounded concurrency and visible job status in the UI
|
|
||||||
|
|
||||||
### Database Concurrency Limits
|
|
||||||
|
|
||||||
Risk:
|
|
||||||
|
|
||||||
- contention can appear under sustained concurrent writes in personal-scale infrastructure
|
|
||||||
|
|
||||||
Control:
|
|
||||||
|
|
||||||
- tuned connection pooling and phased use of MongoDB for document-heavy workloads
|
|
||||||
|
|
||||||
### Provider Output Variance
|
|
||||||
|
|
||||||
Risk:
|
|
||||||
|
|
||||||
- transcription quality varies by content source type, handwriting legibility, and source quality
|
|
||||||
|
|
||||||
Control:
|
|
||||||
|
|
||||||
- first-class human review and immutable revision history
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technology References
|
|
||||||
|
|
||||||
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
|
||||||
- [NiceGUI documentation](https://nicegui.io/documentation)
|
|
||||||
- [Docker Compose documentation](https://docs.docker.com/compose/)
|
|
||||||
- [PostgreSQL documentation](https://www.postgresql.org/docs/)
|
|
||||||
- [MongoDB documentation](https://www.mongodb.com/docs/)
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v1.md)
|
|
||||||
- [System Design Intent](intent.md)
|
|
||||||
- [Transcription Methodology](transcription_methodology.md)
|
|
||||||
- System Architecture (this document)
|
|
||||||
- [System Requirements](requirements_v1.md)
|
|
||||||
- [Data model](schema_v1.md)
|
|
||||||
- [Error Handling Policy](error_handling_v1.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v1.md)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- Adapter: A component that translates between internal interfaces and external systems such as databases or AI services.
|
|
||||||
- Background job: Work executed outside the request/response path so the UI remains responsive.
|
|
||||||
- Boundary: A strict separation between modules with different responsibilities.
|
|
||||||
- CI (Continuous Integration): Automated test execution for code changes.
|
|
||||||
- Contract test: A test that verifies an adapter follows expected input/output behavior at a boundary.
|
|
||||||
- Domain layer: The module that contains core business rules and invariants.
|
|
||||||
- End-to-end test: A test that validates a full user flow across the running system.
|
|
||||||
- Full-text search: Text indexing and querying optimized for natural-language search.
|
|
||||||
- In-process worker: A background executor that runs within the same application process.
|
|
||||||
- Integration test: A test that verifies interactions between real modules and infrastructure components.
|
|
||||||
- MongoDB: A document-oriented database used for flexible, high-variance data structures.
|
|
||||||
- Modular monolith: A single deployable application with strongly separated internal modules.
|
|
||||||
- Port/Interface: A stable contract used by application/domain code to call infrastructure implementations.
|
|
||||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and can be revised independently.
|
|
||||||
- Provenance: Metadata that records where generated data came from and how it was produced.
|
|
||||||
- Revision history: Optional versioned record of user-authored transcription edits over time.
|
|
||||||
- System of record: The authoritative persistent store for canonical data.
|
|
||||||
- Vertical slice: A minimal end-to-end feature path spanning UI/API, application logic, and persistence.
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
# Error Handling Policy
|
|
||||||
|
|
||||||
This document defines the canonical error-handling policy for the document transcription system. It is the single source of truth for how errors are classified, surfaced to users, logged for diagnosis, and handled across UI, API, service, worker, and provider boundaries.
|
|
||||||
|
|
||||||
## Error Handling Objectives
|
|
||||||
|
|
||||||
The production error-handling model is designed to:
|
|
||||||
|
|
||||||
- make failures visible to the user in clear, actionable language
|
|
||||||
- preserve enough diagnostic detail for fast troubleshooting
|
|
||||||
- keep module behavior consistent across all boundaries
|
|
||||||
- distinguish expected domain failures from unexpected defects
|
|
||||||
- support safe retries for transient failures without hiding persistent faults
|
|
||||||
|
|
||||||
## Scope And Authority
|
|
||||||
|
|
||||||
This page governs error-handling behavior for:
|
|
||||||
|
|
||||||
- UI interactions (NiceGUI pages)
|
|
||||||
- API endpoints (FastAPI routes)
|
|
||||||
- application services and orchestration logic
|
|
||||||
- in-process background worker execution
|
|
||||||
- external provider adapters and persistence adapters
|
|
||||||
|
|
||||||
If implementation behavior conflicts with this document, this document is authoritative and implementation should be updated.
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
- **Clarity first:** user-facing messages should explain what failed in plain language.
|
|
||||||
- **Actionability required:** each surfaced error should include a suggested next step.
|
|
||||||
- **Safety by default:** internal details are logged; sensitive details are not exposed by default in UI/API.
|
|
||||||
- **Consistency across boundaries:** category and structure should remain stable from source to surface.
|
|
||||||
- **Fail explicitly:** silent failure is prohibited.
|
|
||||||
- **Traceability:** every non-trivial error should be traceable with an error reference ID.
|
|
||||||
|
|
||||||
## Error Taxonomy
|
|
||||||
|
|
||||||
The system uses stable, implementation-independent categories:
|
|
||||||
|
|
||||||
| Category | Definition | Typical Source | Retriable |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `validation_error` | Payload or parameter shape/content is invalid | UI/API input validation, service guards | no |
|
|
||||||
| `user_input_error` | User-provided artifact is unacceptable though structurally valid | unsupported file type, empty file, oversized upload | sometimes |
|
|
||||||
| `not_found_error` | Requested resource does not exist | missing job/document/source/revision | no |
|
|
||||||
| `conflict_error` | Requested operation violates current state constraints | invalid state transition | no |
|
|
||||||
| `external_provider_error` | External AI/provider call fails | upstream HTTP/API/provider failures | sometimes |
|
|
||||||
| `infrastructure_transient_error` | Temporary environment issue | network timeout, DB connection reset | yes |
|
|
||||||
| `infrastructure_persistent_error` | Non-transient environment issue | missing permissions, misconfiguration | no |
|
|
||||||
| `internal_unexpected_error` | Unhandled defect or unknown failure | uncaught exceptions, logic errors | unknown (default no) |
|
|
||||||
|
|
||||||
### Classification Rules
|
|
||||||
|
|
||||||
- Classification occurs as close as possible to the origin boundary.
|
|
||||||
- Provider-specific exceptions must be normalized into taxonomy categories before crossing service boundaries.
|
|
||||||
- Unknown exceptions are classified as `internal_unexpected_error` and logged with traceback.
|
|
||||||
- Category names are stable contracts and must not be changed casually.
|
|
||||||
|
|
||||||
## User-Facing Error Experience Contract
|
|
||||||
|
|
||||||
When an error is shown in the GUI, it must include:
|
|
||||||
|
|
||||||
1. **Title** (short context, e.g., “Upload failed”)
|
|
||||||
2. **Message** (plain-language explanation)
|
|
||||||
3. **Suggested action** (explicit next step)
|
|
||||||
4. **Error reference ID** (for support/debug traceability)
|
|
||||||
5. **Technical details** (optional/collapsible for advanced users)
|
|
||||||
|
|
||||||
### UI Message Rules
|
|
||||||
|
|
||||||
- Do not expose raw stack traces by default.
|
|
||||||
- Do not expose secrets, credentials, connection strings, or filesystem internals unless explicitly in debug tooling.
|
|
||||||
- Prefer domain language over implementation language.
|
|
||||||
- Use persistent visibility for important failures (dialog/card), not only transient toasts.
|
|
||||||
|
|
||||||
### Suggested Action Requirements
|
|
||||||
|
|
||||||
Every user-visible error must include a suggested course of action, such as:
|
|
||||||
|
|
||||||
- retry the operation
|
|
||||||
- check file type/size constraints
|
|
||||||
- refresh the jobs page
|
|
||||||
- verify environment configuration
|
|
||||||
- contact operator with error ID and timestamp
|
|
||||||
|
|
||||||
## API Error Response Contract
|
|
||||||
|
|
||||||
API errors should return a structured envelope with stable fields:
|
|
||||||
|
|
||||||
- `error_id`: short unique reference ID
|
|
||||||
- `category`: taxonomy category
|
|
||||||
- `message`: safe human-readable summary
|
|
||||||
- `suggestion`: recommended next step
|
|
||||||
- `details`: optional, only when safe and appropriate
|
|
||||||
- `timestamp`: UTC ISO-8601
|
|
||||||
|
|
||||||
HTTP status mapping guidance:
|
|
||||||
|
|
||||||
- `validation_error`, `user_input_error` -> `400`
|
|
||||||
- `not_found_error` -> `404`
|
|
||||||
- `conflict_error` -> `409`
|
|
||||||
- `external_provider_error` -> `502` or `503` (depending on failure mode)
|
|
||||||
- `infrastructure_transient_error` -> `503`
|
|
||||||
- `infrastructure_persistent_error` -> `500`
|
|
||||||
- `internal_unexpected_error` -> `500`
|
|
||||||
|
|
||||||
## Logging And Observability Contract
|
|
||||||
|
|
||||||
All logged errors must include, where available:
|
|
||||||
|
|
||||||
- `error_id`
|
|
||||||
- `category`
|
|
||||||
- `operation` (e.g., `upload.submit`, `worker.process_job`, `jobs.refresh`)
|
|
||||||
- `exception_type`
|
|
||||||
- `job_id`, `document_id`, `source_id` (when relevant)
|
|
||||||
- UTC timestamp
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Use structured logging fields where practical.
|
|
||||||
- Use full traceback for unexpected errors (`internal_unexpected_error`).
|
|
||||||
- Log at boundary handoff points to preserve causal trail.
|
|
||||||
- Avoid duplicate noisy logging for the same exception at every layer.
|
|
||||||
|
|
||||||
## Recovery And Retry Policy
|
|
||||||
|
|
||||||
### Retriable Conditions
|
|
||||||
|
|
||||||
Retriable failures include:
|
|
||||||
|
|
||||||
- transient network/provider timeouts
|
|
||||||
- intermittent provider unavailability
|
|
||||||
- temporary DB/network interruptions
|
|
||||||
|
|
||||||
### Non-Retriable Conditions
|
|
||||||
|
|
||||||
Non-retriable failures include:
|
|
||||||
|
|
||||||
- invalid file formats
|
|
||||||
- missing required data
|
|
||||||
- permission/configuration failures
|
|
||||||
- deterministic domain conflicts
|
|
||||||
|
|
||||||
### Worker Behavior
|
|
||||||
|
|
||||||
- The worker must classify and persist failure details consistently.
|
|
||||||
- Retries should be bounded by configured limits.
|
|
||||||
- Exhausted retries must end in explicit failed status with recorded reason.
|
|
||||||
- No infinite retry loops are allowed.
|
|
||||||
|
|
||||||
## Boundary-Specific Responsibilities
|
|
||||||
|
|
||||||
### UI Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- display user-safe error summaries and suggested actions
|
|
||||||
- show persistent error visibility for critical failures
|
|
||||||
- include error reference IDs in visible output
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- low-level exception parsing
|
|
||||||
- provider-specific protocol interpretation
|
|
||||||
|
|
||||||
### API Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- map application exceptions into stable error envelopes and HTTP statuses
|
|
||||||
- preserve category and error_id continuity
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- domain-specific remediation logic
|
|
||||||
|
|
||||||
### Service Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- classify domain and infrastructure exceptions
|
|
||||||
- convert adapter-specific failures into taxonomy categories
|
|
||||||
- return deterministic error types to callers
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- presentation formatting for UI
|
|
||||||
|
|
||||||
### Worker Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- execute retry policy for retriable failures
|
|
||||||
- persist terminal failure details for jobs
|
|
||||||
- emit operational logs with category and identifiers
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- direct UI messaging
|
|
||||||
|
|
||||||
### Provider Adapter Layer
|
|
||||||
|
|
||||||
Responsibility:
|
|
||||||
|
|
||||||
- normalize provider SDK/HTTP failures into domain-neutral exceptions
|
|
||||||
- preserve raw provider context for logs (safely)
|
|
||||||
|
|
||||||
Out of scope:
|
|
||||||
|
|
||||||
- choosing user-facing wording
|
|
||||||
|
|
||||||
## Error Lifecycle Workflow
|
|
||||||
|
|
||||||
Standard lifecycle:
|
|
||||||
|
|
||||||
1. Failure occurs at a boundary or operation.
|
|
||||||
2. Exception is classified into taxonomy category.
|
|
||||||
3. `error_id` is created (or propagated).
|
|
||||||
4. Error is logged with required structured fields.
|
|
||||||
5. User/API receives safe message + suggested action.
|
|
||||||
6. Persistent job/resource state is updated when applicable.
|
|
||||||
7. Tests verify contract behavior for the pathway.
|
|
||||||
|
|
||||||
## Test Strategy For Error Handling
|
|
||||||
|
|
||||||
### Unit Tests
|
|
||||||
|
|
||||||
- category classification behavior
|
|
||||||
- retry eligibility decisions
|
|
||||||
- exception-to-message mapping safety
|
|
||||||
|
|
||||||
### Integration Tests
|
|
||||||
|
|
||||||
- UI pathways show clear message + suggested action for known failures
|
|
||||||
- API returns structured error envelope with expected status/category
|
|
||||||
- worker persists failed status and failure detail as required
|
|
||||||
|
|
||||||
### Regression Tests
|
|
||||||
|
|
||||||
- each previously observed production issue should have a guarding test
|
|
||||||
- contract tests must cover adapter error normalization behavior
|
|
||||||
|
|
||||||
## Known Failure Patterns And Prescribed Responses
|
|
||||||
|
|
||||||
| Pattern | Category | User Message | Suggested Action |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Upload payload cannot be parsed by UI handler | `internal_unexpected_error` (until narrowed) | Upload failed due to unexpected processing error | Retry once; if repeated, report error ID and check runtime version compatibility |
|
|
||||||
| Unsupported extension | `user_input_error` | File type is not supported | Upload JPG, PNG, TIFF, or PDF |
|
|
||||||
| Empty file upload | `validation_error` | Uploaded file is empty | Choose a valid non-empty file and retry |
|
|
||||||
| Provider timeout | `external_provider_error` or `infrastructure_transient_error` | Transcription provider timed out | Retry from jobs page; if repeated, check provider status |
|
|
||||||
| Job lookup missing | `not_found_error` | Requested job was not found | Refresh jobs list and open a valid job |
|
|
||||||
|
|
||||||
## Governance And Update Process
|
|
||||||
|
|
||||||
This document is a living policy artifact.
|
|
||||||
|
|
||||||
Update this document when:
|
|
||||||
|
|
||||||
- new error categories are introduced
|
|
||||||
- handling behavior changes at any boundary
|
|
||||||
- a production incident reveals missing guidance
|
|
||||||
- API/UI error contracts change
|
|
||||||
|
|
||||||
Change requirements:
|
|
||||||
|
|
||||||
- update this document and associated tests in the same change set
|
|
||||||
- preserve taxonomy stability; if changed, document migration impact
|
|
||||||
- record noteworthy policy changes in project release notes or changelog
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v1.md)
|
|
||||||
- [System Design Intent](intent.md)
|
|
||||||
- [Transcription Methodology](transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v1.md)
|
|
||||||
- [System Requirements](requirements_v1.md)
|
|
||||||
- [Data model](schema_v1.md)
|
|
||||||
- Error Handling Policy (this document)
|
|
||||||
- [Implementation Plan](implementation_plan_v1.md)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- Error category: Stable classification used to drive handling, messaging, and status mapping.
|
|
||||||
- Error envelope: Structured API payload describing a failure.
|
|
||||||
- Error reference ID: Short identifier used to correlate user-visible failure with logs.
|
|
||||||
- Retriable error: Failure likely to succeed on a later attempt without code changes.
|
|
||||||
- Terminal failure: Failure state after retries are exhausted or retry is not allowed.
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
# Version 1 Implementation Plan
|
|
||||||
|
|
||||||
This plan defines the path from current implementation to **Version 1 complete**, aligned to the updated domain model:
|
|
||||||
|
|
||||||
- `Document` groups one or more content `Source` records
|
|
||||||
- `Job` owns original immutable provider output (`text`) and processing metadata
|
|
||||||
- `Revision` stores optional user-authored edits linked to a `Source`
|
|
||||||
|
|
||||||
The objective is to complete V1 scope with production readiness while keeping non-V1 enhancements out of active delivery.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## V1 Completion Definition
|
|
||||||
|
|
||||||
V1 is complete when all of the following are true:
|
|
||||||
|
|
||||||
1. **Functional complete**
|
|
||||||
- Upload, queue, processing, status display, and transcription result inspection work end-to-end.
|
|
||||||
- Optional revision workflow is implemented (create/view/update single revision).
|
|
||||||
2. **Data-model complete**
|
|
||||||
- Runtime behavior, persistence, and tests all align to `Document` / `Source` / `Job` / `Revision`.
|
|
||||||
3. **Operational complete**
|
|
||||||
- Error handling, logs, and runbooks support reliable operation.
|
|
||||||
4. **Documentation complete**
|
|
||||||
- Architecture, requirements, schema, error handling, and index are consistent and current.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Data Contract Stabilization (Schema-First)
|
|
||||||
|
|
||||||
**Goal:** Lock a single canonical contract before further feature work.
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
1. Confirm and document invariants:
|
|
||||||
- `Job.text` is original immutable transcription output.
|
|
||||||
- `Revision` is optional and user-authored.
|
|
||||||
- Revisions are derived from the original `Job.text`.
|
|
||||||
2. Verify relationship cardinality assumptions:
|
|
||||||
- `Document` -> many `Source`
|
|
||||||
- `Document` -> many `Job`
|
|
||||||
- `Source` -> one `Job`
|
|
||||||
- `Source` -> one `Revision`
|
|
||||||
3. Ensure field naming consistency (`date_created`, `date_updated`, `date_uploaded`) across code and docs.
|
|
||||||
4. Freeze V1 status lifecycle to current implementation (`queued`, `processing`, `transcribed`, `failed`).
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Updated `schema_v1.md` and `requirements.md` traceability alignment.
|
|
||||||
- Explicit V1 data invariants section in architecture docs.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- No conflicting definitions of ownership/cardinality/status remain in docs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Service Layer Refactor To New Model
|
|
||||||
|
|
||||||
**Goal:** Remove all obsolete `Transcript` assumptions from service/workflow code.
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
1. Refactor `services/transcription.py`:
|
|
||||||
- Replace transcript CRUD assumptions with job-output + revision operations.
|
|
||||||
2. Refactor `services/jobs.py`:
|
|
||||||
- Replace old timestamp/relationship accessors with current model fields.
|
|
||||||
3. Refactor `services/documents.py` and `services/store.py`:
|
|
||||||
- Ensure upload creates and links `Document`, `Source`, and `Job` correctly.
|
|
||||||
4. Refactor `services/workflows.py`:
|
|
||||||
- Persist original provider output to `Job`.
|
|
||||||
- Persist failure detail to `Job.error_detail`.
|
|
||||||
- Use `Revision` only for user-authored edits.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Service layer fully aligned with new schema.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- No service module imports or persists `Transcript` model artifacts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — UI Contract Alignment
|
|
||||||
|
|
||||||
**Goal:** Align pages/components to source/job/revision semantics.
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
1. Update job detail and related UI components:
|
|
||||||
- Display original immutable transcription from `Job.text`.
|
|
||||||
- Display optional revision sourced from `Source.revision` (0 or 1).
|
|
||||||
2. Align date fields with new schema naming.
|
|
||||||
3. Preserve clear user messaging when no revisions exist.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Updated jobs page and detail components.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- UI behavior and labels match documentation and domain model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4 — Database Bootstrap, Migration, and Safety
|
|
||||||
|
|
||||||
**Goal:** Make schema transition safe in dev/test and repeatable for deployment.
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
1. Update bootstrap compatibility logic in `db/operations.py`:
|
|
||||||
- Remove obsolete transcript-table assumptions.
|
|
||||||
- Add forward-compatible patches for current tables only.
|
|
||||||
2. Define migration/backfill approach for existing local data.
|
|
||||||
3. Document rollback and recovery steps.
|
|
||||||
4. Rehearse migration path against representative data.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Migration/upgrade runbook.
|
|
||||||
- Validated bootstrap behavior for dev/test.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Migration path is documented and tested with no unresolved data-loss risk.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 — Test Suite Realignment
|
|
||||||
|
|
||||||
**Goal:** Restore full confidence after the schema redesign.
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
1. Rewrite model tests for:
|
|
||||||
- `Document`, `Source`, `Job`, `Revision` relationships and invariants.
|
|
||||||
2. Rewrite service/integration tests:
|
|
||||||
- Worker success/failure paths using `Job.text` / `Job.error_detail`.
|
|
||||||
- Optional single-revision creation/update behavior.
|
|
||||||
3. Update UI tests for new job-detail/revision rendering behavior.
|
|
||||||
4. Re-enable strict CI quality gates (lint, type, tests).
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- Updated test matrix and passing CI.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Critical user flows and failure paths are covered and green.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6 — Reliability, Operations, and Release Readiness
|
|
||||||
|
|
||||||
**Goal:** Ensure V1 is operable and launch-safe.
|
|
||||||
|
|
||||||
### Tasks
|
|
||||||
1. Verify error taxonomy behavior across UI/API/service/worker.
|
|
||||||
2. Confirm structured logging includes relevant identifiers (`job_id`, `document_id`, `source_id` when applicable).
|
|
||||||
3. Validate retry behavior and terminal failure handling.
|
|
||||||
4. Finalize release checklist, deployment steps, and rollback procedure.
|
|
||||||
5. Execute final acceptance run against requirements traceability.
|
|
||||||
|
|
||||||
### Deliverables
|
|
||||||
- V1 release checklist and acceptance evidence.
|
|
||||||
- `runbook_v1.md` for incident response and operator workflows.
|
|
||||||
- `release_checklist_v1.md` for release sign-off.
|
|
||||||
|
|
||||||
### Exit Criteria
|
|
||||||
- Stakeholder sign-off and launch readiness achieved.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirement Traceability Focus
|
|
||||||
|
|
||||||
The plan must keep clear evidence against these requirement groups:
|
|
||||||
|
|
||||||
- **Core flow:** REQ-0 to REQ-6
|
|
||||||
- **Runtime and operations constraints:** REQ-7 to REQ-12
|
|
||||||
- **Revision workflow:** REQ-13
|
|
||||||
|
|
||||||
A lightweight traceability table should be maintained with:
|
|
||||||
|
|
||||||
- requirement ID
|
|
||||||
- implementation status (`not started` / `in progress` / `done`)
|
|
||||||
- validation evidence (test name, screenshot, or runbook step)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggested Execution Rhythm
|
|
||||||
|
|
||||||
- **Weekly:** requirement status and risk review
|
|
||||||
- **Per PR:** contract checks (model names, field names, lifecycle values)
|
|
||||||
- **Milestone checks:** end of Phases 2, 4, and 6
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scope Discipline Rule (V1 Focus)
|
|
||||||
|
|
||||||
- Only work required to satisfy V1 requirements enters this plan.
|
|
||||||
- Nice-to-have enhancements are captured in a separate backlog document.
|
|
||||||
- Schema or contract changes after Phase 1 require explicit approval and traceability impact review.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v1.md)
|
|
||||||
- [System Design Intent](intent.md)
|
|
||||||
- [Transcription Methodology](transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v1.md)
|
|
||||||
- [System Requirements](requirements_v1.md)
|
|
||||||
- [Data model](schema_v1.md)
|
|
||||||
- [Error Handling Policy](error_handling_v1.md)
|
|
||||||
- Implementation Plan (this document)
|
|
||||||
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
## Document Transcription System Overview
|
|
||||||
|
|
||||||
This project is a production application for transcribing and preserving historical family documents. It is intentionally designed for personal-scale use, with a simplicity-first architecture that is easy to operate and easy to extend.
|
|
||||||
|
|
||||||
## Start Here
|
|
||||||
|
|
||||||
Read [architecture_v1.md](architecture_v1.md) first.
|
|
||||||
|
|
||||||
The architecture page is the primary technical reference and defines:
|
|
||||||
|
|
||||||
- deployed topology and infrastructure limits
|
|
||||||
- module boundaries and dependency flow
|
|
||||||
- processing life cycle and data ownership
|
|
||||||
- test strategy, risk controls, and extension path
|
|
||||||
|
|
||||||
## What The Application Does
|
|
||||||
|
|
||||||
At a high level, users upload images or PDFs as content sources for handwritten, typed, or typeset documents, run asynchronous transcription jobs, review optional revisions, and search across accepted text.
|
|
||||||
|
|
||||||
### Core capabilities:
|
|
||||||
|
|
||||||
- document grouping with one or more content sources and metadata capture
|
|
||||||
- asynchronous transcription with visible job status
|
|
||||||
- immutable original transcription persisted with each job (plus provider/model/prompt metadata)
|
|
||||||
- transcription prompt management with one Markdown file per prompt for human refinement over time
|
|
||||||
- optional revisions for user-authored edits of original immutable transcription text
|
|
||||||
- full-text search over accepted transcripts
|
|
||||||
- export of transcript data
|
|
||||||
|
|
||||||
## Production Operating Model
|
|
||||||
|
|
||||||
The system runs with minimal operational overhead:
|
|
||||||
|
|
||||||
- PostgreSQL in a dedicated Docker container is considered extremely lightweight and simple for this system
|
|
||||||
- MongoDB in a dedicated Docker container is also considered extremely lightweight and simple for document-centric persistence
|
|
||||||
- a three-container deployment (app, PostgreSQL, MongoDB) is a simple and acceptable baseline
|
|
||||||
- no required queue or search-engine containers in the baseline setup
|
|
||||||
|
|
||||||
This operating model keeps deployment and maintenance simple while preserving clean boundaries for future scale.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation Map
|
|
||||||
|
|
||||||
- System Overview (this document)
|
|
||||||
- [System Design Intent](intent.md)
|
|
||||||
- [Transcription Methodology](transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v1.md)
|
|
||||||
- [System Requirements](requirements_v1.md)
|
|
||||||
- [Data model](schema_v1.md)
|
|
||||||
- [Error Handling Policy](error_handling_v1.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v1.md)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- Document-oriented persistence: Storing data as flexible records instead of fixed relational rows.
|
|
||||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and is edited independently.
|
|
||||||
- System of record: The authoritative persistent store for canonical data.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# V1 Release Readiness Checklist
|
|
||||||
|
|
||||||
Use this checklist before declaring V1 operationally complete.
|
|
||||||
|
|
||||||
## A) Functional Readiness
|
|
||||||
|
|
||||||
- [ ] Upload flow works for supported file types.
|
|
||||||
- [ ] Worker transitions jobs through `queued -> processing -> transcribed|failed`.
|
|
||||||
- [ ] Job detail displays immutable original transcription from `Job.text`.
|
|
||||||
- [ ] Revision workflow supports create/update/view/delete for optional single revision.
|
|
||||||
|
|
||||||
## B) Reliability and Error Handling
|
|
||||||
|
|
||||||
- [ ] Error categories surface with actionable messages in UI/API pathways.
|
|
||||||
- [ ] Failed jobs persist `error_detail` and terminal state.
|
|
||||||
- [ ] Stale processing recovery verified on restart.
|
|
||||||
- [ ] Retry/timeout behavior validated against configured limits.
|
|
||||||
|
|
||||||
## C) Operational Readiness
|
|
||||||
|
|
||||||
- [ ] `runbook_v1.md` reviewed and current.
|
|
||||||
- [ ] `migration_v1.md` reviewed and current.
|
|
||||||
- [ ] Backup and rollback procedures tested at least once.
|
|
||||||
- [ ] Incident escalation packet template is known to operators.
|
|
||||||
|
|
||||||
## D) Quality Gates
|
|
||||||
|
|
||||||
- [ ] Lint/type checks pass.
|
|
||||||
- [ ] `pytest -m "not external" -q` passes.
|
|
||||||
- [ ] Targeted external/provider checks executed (if credentials available).
|
|
||||||
- [ ] Release evidence recorded in `release_evidence_v1.md`.
|
|
||||||
|
|
||||||
## E) Traceability and Documentation
|
|
||||||
|
|
||||||
- [ ] `requirements_v1.md` aligns with implemented V1 behavior.
|
|
||||||
- [ ] `architecture_v1.md`, `schema_v1.md`, and `error_handling_v1.md` are consistent.
|
|
||||||
- [ ] `traceability_v1.md` is updated with current implementation and test evidence.
|
|
||||||
- [ ] `implementation_plan_v1.md` phase status updated with evidence references.
|
|
||||||
- [ ] REQ traceability evidence links recorded (tests/runbook/checks).
|
|
||||||
|
|
||||||
## Release Sign-Off
|
|
||||||
|
|
||||||
- [ ] Technical sign-off complete.
|
|
||||||
- [ ] Operational sign-off complete.
|
|
||||||
- [ ] V1 completion date recorded.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# V1 Release Evidence Log
|
|
||||||
|
|
||||||
## Step 5 Quality Gates (2026-07-29)
|
|
||||||
|
|
||||||
### Lint
|
|
||||||
|
|
||||||
- Command: `python -m ruff check .`
|
|
||||||
- Result: ✅ pass
|
|
||||||
- Notes: initial findings were auto-fixed (`ruff --fix`) plus small manual line-wrap/annotation adjustments.
|
|
||||||
|
|
||||||
### Tests (primary gate)
|
|
||||||
|
|
||||||
- Command: `python -m pytest -m "not external" -q`
|
|
||||||
- Result: ✅ pass (`[100%]`)
|
|
||||||
|
|
||||||
### Tests (external smoke)
|
|
||||||
|
|
||||||
- Command: `python -m pytest -m external -q`
|
|
||||||
- Result: ✅ pass (`[100%]`)
|
|
||||||
|
|
||||||
### Type Check
|
|
||||||
|
|
||||||
- Command: `python -m ty check src tests`
|
|
||||||
- Result: ⚠️ not passing
|
|
||||||
- Summary: existing SQLModel/SQLAlchemy typing incompatibilities and test double typing mismatches remain.
|
|
||||||
|
|
||||||
Key current blocker families:
|
|
||||||
|
|
||||||
1. SQLModel relationship/query attribute typing (`selectinload`, `order_by`, `.any()`)
|
|
||||||
2. SQLAlchemy join clause typing in `services/transcription.py`
|
|
||||||
3. Test fake client type mismatch for `OpenRouterTranscriptionProvider(client=...)`
|
|
||||||
4. `Settings(**defaults)` typed-dict strictness in `tests/test_config.py`
|
|
||||||
|
|
||||||
## Current Gate Status
|
|
||||||
|
|
||||||
- Lint: pass
|
|
||||||
- Non-external tests: pass
|
|
||||||
- External smoke tests: pass
|
|
||||||
- Type check: **blocked** (requires dedicated typing cleanup pass)
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
## Document Transcription System Requirements
|
|
||||||
|
|
||||||
This page captures a SysML v1.6-style requirements baseline for the production system described in [index_v1.md](index_v1.md). The model is represented as concise tables and traceability lists that preserve SysML-style IDs and relationship semantics.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- System of interest: the single Python application service (NiceGUI + FastAPI) with PostgreSQL as the relational system of record and optional MongoDB for document-oriented persistence.
|
|
||||||
- Operational context: local-first execution with Docker Compose and an intentionally lightweight production trajectory.
|
|
||||||
- Primary concern: end-to-end transcription job lifecycle from upload through completion or failure.
|
|
||||||
|
|
||||||
## Requirements Model (Concise Text Form)
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
| ID | Category | Requirement | Risk | Verify Method |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| REQ-0 | System | Provide end-to-end document transcription with persistent, inspectable lifecycle state. | medium | demonstration |
|
|
||||||
| REQ-1 | Functional | Allow users to upload one or more images or PDFs as sources from the web UI. | low | test |
|
|
||||||
| REQ-2 | Functional | Run each upload through asynchronous processing that returns an original transcription or explicit failure. | high | test |
|
|
||||||
| REQ-3 | Functional | Persist and expose job states: queued, processing, transcribed, failed. | high | inspection |
|
|
||||||
| REQ-4 | Functional | Persist transcription output, processing history, and failure details. | medium | test |
|
|
||||||
| REQ-5 | Interface | Expose API and UI views for status inspection and completed transcription reading. | medium | demonstration |
|
|
||||||
| REQ-6 | Performance | Trigger background processing on upload to preserve UI responsiveness. | medium | analysis |
|
|
||||||
| REQ-7 | Design Constraint | Keep lifespan-owned runtime resources: SQLAlchemy engine, async session factory, worker resources, provider clients. | medium | inspection |
|
|
||||||
| REQ-8 | Design Constraint | Initialize configuration and logging once at startup through centralized mechanisms. | low | inspection |
|
|
||||||
| REQ-9 | Design Constraint | Use Docker Compose baseline of app plus PostgreSQL; allow optional MongoDB container when enabled. | medium | demonstration |
|
|
||||||
| REQ-10 | Design Constraint | Keep schema bootstrap explicit and opt-in; normal startup does not mutate production schema. | high | inspection |
|
|
||||||
| REQ-11 | Design Constraint | Use service-backed persistence for core document and job data. | medium | inspection |
|
|
||||||
| REQ-12 | Design Constraint | Store transcription prompts as individual Markdown artifacts for iterative refinement. | medium | inspection |
|
|
||||||
| REQ-13 | Functional | Allow users to create one optional revision of transcription text derived from the original job transcription. | low | test |
|
|
||||||
|
|
||||||
### Requirement Relationships
|
|
||||||
|
|
||||||
- Contains: REQ-0 contains REQ-1 through REQ-13.
|
|
||||||
- Derives: REQ-2 -> REQ-3, REQ-3 -> REQ-4.
|
|
||||||
- Traces: REQ-5 -> REQ-3.
|
|
||||||
- Refines: REQ-6 -> REQ-2.
|
|
||||||
|
|
||||||
### Architecture Elements
|
|
||||||
|
|
||||||
| Element | Type | Doc Reference |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| UI | NiceGUI pages | src/transcription/ui/pages |
|
|
||||||
| API | FastAPI routes | src/transcription/api/routes.py |
|
|
||||||
| GRAPH | Async processing workflow | src/transcription/services, src/transcription/ai |
|
|
||||||
| DBREL | PostgreSQL + SQLModel relational persistence | src/transcription/db |
|
|
||||||
| DBDOC | MongoDB document persistence | src/transcription/db, src/transcription/services |
|
|
||||||
| OPS | Docker Compose runtime | docker-compose.yml |
|
|
||||||
| PROMPTS | Transcription prompt artifact library (Markdown files) | .github/prompts, docs |
|
|
||||||
| TESTS | Pytest verification suite | tests |
|
|
||||||
|
|
||||||
### Satisfaction Mapping
|
|
||||||
|
|
||||||
- UI satisfies REQ-1, REQ-5, REQ-13.
|
|
||||||
- API satisfies REQ-5.
|
|
||||||
- GRAPH satisfies REQ-2, REQ-6.
|
|
||||||
- DBREL satisfies REQ-3, REQ-10, REQ-13.
|
|
||||||
- DBDOC satisfies REQ-4, REQ-11.
|
|
||||||
- OPS satisfies REQ-9.
|
|
||||||
- PROMPTS satisfies REQ-12.
|
|
||||||
|
|
||||||
### Verification Mapping
|
|
||||||
|
|
||||||
- TESTS verifies REQ-1, REQ-2, REQ-3, REQ-4, REQ-5, REQ-10, REQ-11, REQ-12, REQ-13.
|
|
||||||
|
|
||||||
## Requirement Notes
|
|
||||||
|
|
||||||
- Requirement IDs (`REQ-*`) are stable references for planning, implementation, and test traceability.
|
|
||||||
- The model uses compact tables and traceability lists for renderer compatibility while preserving SysML-style requirement IDs and relationship semantics.
|
|
||||||
- Requirement categories (functional, interface, performance, and design constraints) are preserved as explicit REQ entries and relationship labels to keep change impact visible.
|
|
||||||
- PostgreSQL containerization and optional MongoDB containerization are both treated as extremely lightweight and simple operational choices in this architecture.
|
|
||||||
|
|
||||||
## Verification Intent
|
|
||||||
|
|
||||||
- Demonstration: validate end-to-end behavior via running system flows and operator-visible outcomes.
|
|
||||||
- Inspection: verify architecture and startup/runtime policies in code and configuration.
|
|
||||||
- Analysis: evaluate asynchronous execution behavior and design sufficiency.
|
|
||||||
- Test: automate behavioral checks through pytest suites and service-level tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v1.md)
|
|
||||||
- [System Design Intent](intent.md)
|
|
||||||
- [Transcription Methodology](transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v1.md)
|
|
||||||
- System Requirements (this document)
|
|
||||||
- [Data model](schema_v1.md)
|
|
||||||
- [Error Handling Policy](error_handling_v1.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v1.md)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- Document-oriented persistence: A storage approach that uses flexible document structures for variable data shapes.
|
|
||||||
- Prompt artifact: A single Markdown file that defines one transcription prompt and is revised independently.
|
|
||||||
- SysML: Systems Modeling Language used to express structured requirements and traceability.
|
|
||||||
- System of record: The authoritative persistent store for canonical business data.
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
# V1 Operations Runbook
|
|
||||||
|
|
||||||
This runbook provides day-2 operational procedures for the V1 baseline.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Applies to:
|
|
||||||
|
|
||||||
- local/hosted V1 runtime
|
|
||||||
- SQLite-backed persistence
|
|
||||||
- in-process worker lifecycle
|
|
||||||
- OpenRouter provider integration
|
|
||||||
|
|
||||||
## Preconditions
|
|
||||||
|
|
||||||
- `.env` contains `OPENROUTER_API_KEY`
|
|
||||||
- app starts successfully
|
|
||||||
- `uploads/` and `prompts/` are writable
|
|
||||||
- health endpoint responds at `/healthz`
|
|
||||||
|
|
||||||
## Standard Startup Procedure
|
|
||||||
|
|
||||||
1. Start the app using the project-standard command.
|
|
||||||
2. Open `/healthz` and verify `{"status":"ok"}`.
|
|
||||||
3. Open `/ui/upload` and submit a small valid file.
|
|
||||||
4. Confirm job transitions from `queued` -> `processing` -> `transcribed` (or `failed` with detail).
|
|
||||||
|
|
||||||
## Standard Shutdown Procedure
|
|
||||||
|
|
||||||
1. Stop the application process.
|
|
||||||
2. Ensure no active process still holds the SQLite file.
|
|
||||||
3. If maintenance is planned, copy the DB file before edits:
|
|
||||||
- `transcription.db` (or configured `DATABASE_URL` file path)
|
|
||||||
|
|
||||||
## Incident: Jobs Stuck In `processing`
|
|
||||||
|
|
||||||
### Symptoms
|
|
||||||
|
|
||||||
- Jobs remain `processing` for longer than provider timeout
|
|
||||||
- New uploads queue but do not complete
|
|
||||||
- provider usage increases but no terminal job state is visible
|
|
||||||
|
|
||||||
### Checks
|
|
||||||
|
|
||||||
1. Confirm app process is still running.
|
|
||||||
2. Confirm worker loop is active (startup logs include worker lifespan start).
|
|
||||||
3. Inspect recent app logs for:
|
|
||||||
- `worker.process_job`
|
|
||||||
- `error_id`
|
|
||||||
- `category`
|
|
||||||
- `job_id` / `document_id` / `source_id`
|
|
||||||
4. Verify provider credentials and provider status.
|
|
||||||
|
|
||||||
### Recovery
|
|
||||||
|
|
||||||
1. Restart the app to trigger stale-processing recovery.
|
|
||||||
2. On startup, app re-queues stale processing jobs based on timeout policy.
|
|
||||||
3. Re-check jobs page and confirm terminal state progression.
|
|
||||||
4. If persistent, capture logs + error IDs and move to deep investigation.
|
|
||||||
|
|
||||||
## Incident: Provider Authentication Failures
|
|
||||||
|
|
||||||
### Symptoms
|
|
||||||
|
|
||||||
- failures categorized as provider/auth
|
|
||||||
- jobs fail quickly with authentication guidance
|
|
||||||
|
|
||||||
### Recovery
|
|
||||||
|
|
||||||
1. Validate `OPENROUTER_API_KEY` value.
|
|
||||||
2. Restart app after updating env.
|
|
||||||
3. Re-run a small transcription to confirm recovery.
|
|
||||||
|
|
||||||
## Incident: Upload Failures
|
|
||||||
|
|
||||||
### Symptoms
|
|
||||||
|
|
||||||
- UI reports upload errors
|
|
||||||
- unsupported extension or empty payload
|
|
||||||
|
|
||||||
### Recovery
|
|
||||||
|
|
||||||
1. Validate file extension (`.jpg`, `.jpeg`, `.png`, `.tif`, `.tiff`, `.pdf`).
|
|
||||||
2. Validate file is not empty.
|
|
||||||
3. Validate upload directory permissions.
|
|
||||||
4. Retry upload.
|
|
||||||
|
|
||||||
## Incident: Database File/Permission Issues
|
|
||||||
|
|
||||||
### Symptoms
|
|
||||||
|
|
||||||
- persistence errors during upload/job update
|
|
||||||
- startup failures around schema/runtime
|
|
||||||
|
|
||||||
### Recovery
|
|
||||||
|
|
||||||
1. Confirm the configured DB file path exists and is writable.
|
|
||||||
2. Confirm parent directory permissions.
|
|
||||||
3. Restore from last known backup copy if corruption is suspected.
|
|
||||||
4. Restart app and run smoke test.
|
|
||||||
|
|
||||||
## Logging Requirements (Operational)
|
|
||||||
|
|
||||||
Operational triage should always capture:
|
|
||||||
|
|
||||||
- `error_id`
|
|
||||||
- category
|
|
||||||
- operation name
|
|
||||||
- `job_id`, `document_id`, `source_id` when applicable
|
|
||||||
- UTC timestamp
|
|
||||||
|
|
||||||
## Escalation Packet (When opening an issue)
|
|
||||||
|
|
||||||
Include:
|
|
||||||
|
|
||||||
- exact timestamp window
|
|
||||||
- one failing `job_id`
|
|
||||||
- relevant `error_id` values
|
|
||||||
- latest 100 lines of app logs
|
|
||||||
- environment summary (`DATABASE_URL` type, app version/commit)
|
|
||||||
|
|
||||||
## Post-Incident Validation
|
|
||||||
|
|
||||||
After mitigation, verify:
|
|
||||||
|
|
||||||
1. Upload works.
|
|
||||||
2. One job reaches `transcribed`.
|
|
||||||
3. One induced failure reaches `failed` with error detail.
|
|
||||||
4. Jobs page and detail page render correctly.
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
## Database Schema (V1 Baseline)
|
|
||||||
|
|
||||||
This document describes the current relational schema for the transcription system.
|
|
||||||
|
|
||||||
All primary and foreign keys in the domain models are UUID-based in V1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Schema Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
DOCUMENT {
|
|
||||||
UUID id PK
|
|
||||||
TEXT name
|
|
||||||
}
|
|
||||||
|
|
||||||
JOB {
|
|
||||||
UUID id PK
|
|
||||||
UUID document_id FK
|
|
||||||
TEXT status
|
|
||||||
INTEGER retry_count
|
|
||||||
DATETIME date_created
|
|
||||||
DATETIME date_updated
|
|
||||||
TEXT provider
|
|
||||||
TEXT model
|
|
||||||
TEXT prompt_name
|
|
||||||
TEXT text
|
|
||||||
TEXT error_detail
|
|
||||||
}
|
|
||||||
|
|
||||||
SOURCE {
|
|
||||||
UUID id PK
|
|
||||||
UUID document_id FK
|
|
||||||
UUID job_id FK
|
|
||||||
TEXT upload_name
|
|
||||||
TEXT filename
|
|
||||||
TEXT file_path
|
|
||||||
DATETIME date_uploaded
|
|
||||||
}
|
|
||||||
|
|
||||||
REVISION {
|
|
||||||
UUID id PK
|
|
||||||
UUID source_id "FK, UK"
|
|
||||||
INTEGER revision
|
|
||||||
TEXT text
|
|
||||||
DATETIME date_created
|
|
||||||
}
|
|
||||||
|
|
||||||
DOCUMENT ||--o{ SOURCE : has_many
|
|
||||||
DOCUMENT ||--o{ JOB : has_many
|
|
||||||
JOB ||--o{ SOURCE : referenced_by
|
|
||||||
SOURCE ||--o| REVISION : has_optional_one
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table Relationships and Constraints
|
|
||||||
|
|
||||||
- A `Document` can have zero or more `Source` records.
|
|
||||||
- A `Document` can have zero or more `Job` records.
|
|
||||||
- A `Source` belongs to exactly one `Document` and one `Job`.
|
|
||||||
- A `Source` may have one optional `Revision`.
|
|
||||||
- Optional `0..1` revision cardinality is enforced by uniqueness on `revision.source_id`.
|
|
||||||
|
|
||||||
### Invariants
|
|
||||||
|
|
||||||
- `Job.text` stores immutable original provider transcription output.
|
|
||||||
- `Revision` rows are optional user-authored edits derived from original transcription.
|
|
||||||
- Revisions do not overwrite original `Job.text`.
|
|
||||||
- Job status lifecycle values are: `queued`, `processing`, `transcribed`, `failed`.
|
|
||||||
|
|
||||||
### Timestamp Fields
|
|
||||||
|
|
||||||
- `Job.date_created`
|
|
||||||
- `Job.date_updated`
|
|
||||||
- `Source.date_uploaded`
|
|
||||||
- `Revision.date_created`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Related Local References
|
|
||||||
|
|
||||||
- [System Overview](index_v1.md)
|
|
||||||
- [System Design Intent](intent.md)
|
|
||||||
- [Transcription Methodology](transcription_methodology.md)
|
|
||||||
- [System Architecture](architecture_v1.md)
|
|
||||||
- [System Requirements](requirements_v1.md)
|
|
||||||
- Data model (this document)
|
|
||||||
- [Error Handling Policy](error_handling_v1.md)
|
|
||||||
- [Implementation Plan](implementation_plan_v1.md)
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- **Document**: logical grouping for one or more transcribed sources.
|
|
||||||
- **Source**: uploaded file content (image/PDF) linked to a job.
|
|
||||||
- **Job**: processing record that stores lifecycle status and original output.
|
|
||||||
- **Revision**: optional single user-authored edited text linked to a source.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# V1 Traceability Matrix
|
|
||||||
|
|
||||||
This matrix provides implementation and validation evidence for V1 requirements (`REQ-0` through `REQ-13`).
|
|
||||||
|
|
||||||
Status values:
|
|
||||||
|
|
||||||
- `done`: implemented and evidence recorded
|
|
||||||
- `in progress`: partially implemented or evidence incomplete
|
|
||||||
- `not started`: no implementation/evidence yet
|
|
||||||
|
|
||||||
## Requirement Evidence Table
|
|
||||||
|
|
||||||
| Requirement | Status | Implementation Evidence | Validation Evidence |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| REQ-0 | done | End-to-end upload + worker pipeline in `src/transcription/services/store.py`, `src/transcription/worker.py`, `src/transcription/services/workflows.py` | `tests/integration/test_pipeline_flow.py` |
|
|
||||||
| REQ-1 | done | Upload UI/page flow in `src/transcription/ui/pages/upload_page.py`, `src/transcription/ui/components/upload.py` | `tests/ui/test_upload_page.py`, `tests/integration/test_pipeline_flow.py` |
|
|
||||||
| REQ-2 | done | Async worker execution and provider call orchestration in `src/transcription/worker.py`, `src/transcription/services/workflows.py` | `tests/integration/test_pipeline_flow.py`, `tests/services/test_workflows_reliability.py` |
|
|
||||||
| REQ-3 | done | Job lifecycle state model + transitions in `src/transcription/models.py`, `src/transcription/services/jobs.py`, `src/transcription/services/workflows.py` | `tests/services/test_job_service.py`, `tests/ui/test_jobs_page.py` |
|
|
||||||
| REQ-4 | done | Persistence of original output and failure detail in `src/transcription/services/transcription.py`, `src/transcription/services/workflows.py` | `tests/integration/test_pipeline_flow.py`, `tests/services/test_workflows_reliability.py` |
|
|
||||||
| REQ-5 | done | Status/result inspection via UI pages and API health route in `src/transcription/ui/pages/jobs_page.py`, `src/transcription/api/health.py` | `tests/ui/test_jobs_page.py`, `tests/ui/test_pages_registration.py`, `tests/api/test_health.py` |
|
|
||||||
| REQ-6 | done | Background processing trigger/worker notifier and non-blocking workflow in `src/transcription/ui/components/upload.py`, `src/transcription/worker.py` | `tests/test_app.py`, `tests/services/test_workflows_reliability.py` |
|
|
||||||
| REQ-7 | done | Lifespan-owned runtime resources in `src/transcription/app.py`, `src/transcription/db/runtime.py` | `tests/test_app.py`, `tests/test_db.py` |
|
|
||||||
| REQ-8 | done | Centralized settings/logging initialization in `src/transcription/config.py`, `src/transcription/app.py` | `tests/test_config.py`, `tests/test_app.py` |
|
|
||||||
| REQ-9 | done | Containerized runtime baseline in `docker-compose.yml`, `Dockerfile` | `release_checklist_v1.md` (Ops checklist), manual demonstration step |
|
|
||||||
| REQ-10 | done | Explicit schema bootstrap policy + runtime controls in `src/transcription/config.py`, `src/transcription/app.py`, `src/transcription/db/operations.py` | `tests/test_db.py`, `tests/test_config.py` |
|
|
||||||
| REQ-11 | done | Service/workflow persistence boundaries in `src/transcription/services/*.py`, `src/transcription/services/workflows.py` | `tests/services/test_job_service.py`, `tests/services/test_transcription_service.py` |
|
|
||||||
| REQ-12 | done | Prompt artifacts in `prompts/` and loading/validation in `src/transcription/services/transcription.py` | `tests/test_prompts.py` |
|
|
||||||
| REQ-13 | done | Optional single revision create/update/view/delete in `src/transcription/services/transcription.py`, `src/transcription/ui/pages/jobs_page.py` | `tests/services/test_transcription_service.py`, `tests/ui/test_jobs_page.py` |
|
|
||||||
|
|
||||||
## Operational Evidence (Step 3 Artifacts)
|
|
||||||
|
|
||||||
- Runbook: `runbook_v1.md`
|
|
||||||
- Migration/backfill/rollback guidance: `migration_v1.md`
|
|
||||||
- Release readiness checklist: `release_checklist_v1.md`
|
|
||||||
|
|
||||||
## Verification Cadence
|
|
||||||
|
|
||||||
- Per change: maintain `tests/test_traceability.py` mappings for touched requirements.
|
|
||||||
- Per milestone: update this table status and evidence links.
|
|
||||||
- Pre-release: confirm all rows are `done` and non-external suite is green.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# AI Coding Assistant Project Briefing & Context
|
|
||||||
|
|
||||||
## Project Mission
|
|
||||||
This application is a family history archival and transcription platform. Its primary goal is to accept scanned document images (letters, postcards, logbooks, diaries), execute OCR and structured transcription via AI vision models (OpenAI GPT-4o, Anthropic Claude 3.5 Sonnet), and manage historical metadata (authors, recipients, dates, and locations).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Stack & Architecture
|
|
||||||
* **Database:** PostgreSQL 13+ with native `UUID` (`gen_random_uuid()`) and `JSONB` columns.
|
|
||||||
* **Backend Runtime / Concurrency:** Python utilizing `asyncio` for concurrent HTTP API calls to AI providers, with strict rate-limiting via `asyncio.Semaphore`.
|
|
||||||
* **Validation & Types:** Python with **Pydantic** model definitions. Incoming AI responses must be parsed and validated with Pydantic models *before* database insertion.
|
|
||||||
* **ORM / Database Access:** SQLModel and SQLAlchemy, using parameterized statements and PostgreSQL-native types.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core System Directives for AI Code Generation
|
|
||||||
|
|
||||||
### 1. Data Immutability vs. Human Corrections
|
|
||||||
* `job_source.raw_transcription` and `source.raw_transcription` represent original, point-in-time machine outputs and are **immutable**.
|
|
||||||
* Human corrections occur on `source.revised_text`.
|
|
||||||
* When fetching text for the UI, always display `COALESCE(source.revised_text, source.raw_transcription)`.
|
|
||||||
|
|
||||||
### 2. Async Execution & Batching Rules
|
|
||||||
* A `job` represents an overarching execution run for a folder/group of images belonging to a single `document`.
|
|
||||||
* Images are submitted to AI APIs **one at a time in rapid succession** using `asyncio` worker pools.
|
|
||||||
* Each single-image API call populates a row in `job_source` with its own `status`, `raw_transcription`, `ai_metadata`, and `raw_api_response`.
|
|
||||||
* If 9 of 10 pages succeed and 1 fails, `job_source.status` for the failed image becomes `'failed'`, while `job.status` becomes `'partial_success'`. Do not mark the entire batch as failed if partial results exist.
|
|
||||||
|
|
||||||
### 3. Entity Relationships
|
|
||||||
* **Authors/Recipients:** A `document` can have multiple authors and recipients. Do NOT put direct `author_id` foreign keys on `document`. Query authors/recipients via `document_person` where `role = 'author'` or `role = 'recipient'`.
|
|
||||||
* **Page Ordering:** Multi-page documents must always be queried using `ORDER BY page_number ASC`.
|
|
||||||
|
|
||||||
### 4. Database Mutations
|
|
||||||
* Always use parameterized SQL queries (`$1`, `$2`) to prevent SQL injection.
|
|
||||||
* Store datetimes using UTC ISO 8601 strings or native PostgreSQL `TIMESTAMPTZ`.
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
# SQLModel Table Models
|
|
||||||
|
|
||||||
These models implement the canonical [Version 2 database schema](../schema_v2.md). Each schema entity is represented by exactly one `SQLModel` table class. Because `SQLModel` is built on Pydantic and SQLAlchemy, these classes provide application validation and PostgreSQL mappings without parallel row and create models.
|
|
||||||
|
|
||||||
Database-generated UUIDs and timestamps are `None` until PostgreSQL supplies their values during insert. The database columns remain non-nullable. `Person.metadata_` maps to the `metadata` column because `metadata` is reserved by SQLAlchemy's declarative API.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from datetime import date
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import JsonValue
|
|
||||||
from sqlalchemy import Column
|
|
||||||
from sqlalchemy import Date
|
|
||||||
from sqlalchemy import DateTime
|
|
||||||
from sqlalchemy import ForeignKey
|
|
||||||
from sqlalchemy import Index
|
|
||||||
from sqlalchemy import Integer
|
|
||||||
from sqlalchemy import String
|
|
||||||
from sqlalchemy import Text
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID
|
|
||||||
from sqlmodel import Field
|
|
||||||
from sqlmodel import Relationship
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
class PersonRole(StrEnum):
|
|
||||||
AUTHOR = "author"
|
|
||||||
RECIPIENT = "recipient"
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(StrEnum):
|
|
||||||
QUEUED = "queued"
|
|
||||||
PROCESSING = "processing"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
PARTIAL_SUCCESS = "partial_success"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class JobSourceStatus(StrEnum):
|
|
||||||
PENDING = "pending"
|
|
||||||
TRANSCRIBED = "transcribed"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class Person(SQLModel, table=True):
|
|
||||||
__tablename__ = "person"
|
|
||||||
__table_args__ = (Index("idx_person_full_name", "full_name"),)
|
|
||||||
|
|
||||||
id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
primary_key=True,
|
|
||||||
server_default=text("gen_random_uuid()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
full_name: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
display_name: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
maiden_name: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
birth_date: date | None = Field(default=None, sa_column=Column(Date))
|
|
||||||
birth_date_raw: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
birth_place: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
death_date: date | None = Field(default=None, sa_column=Column(Date))
|
|
||||||
death_date_raw: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
death_place: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
biography: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
portrait_path: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
metadata_: JsonValue | None = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
sa_column=Column(
|
|
||||||
"metadata",
|
|
||||||
JSONB,
|
|
||||||
server_default=text("'{}'::jsonb"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
updated_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
document_people: list["DocumentPerson"] = Relationship(
|
|
||||||
back_populates="person",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Document(SQLModel, table=True):
|
|
||||||
__tablename__ = "document"
|
|
||||||
__table_args__ = (Index("idx_document_date", "document_date"),)
|
|
||||||
|
|
||||||
id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
primary_key=True,
|
|
||||||
server_default=text("gen_random_uuid()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
name: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
document_type: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
document_date: date | None = Field(default=None, sa_column=Column(Date))
|
|
||||||
document_date_raw: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
location_created: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
notes: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
archive_identifier: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
updated_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
document_people: list["DocumentPerson"] = Relationship(
|
|
||||||
back_populates="document",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
|
||||||
)
|
|
||||||
jobs: list["Job"] = Relationship(
|
|
||||||
back_populates="document",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
|
||||||
)
|
|
||||||
sources: list["Source"] = Relationship(
|
|
||||||
back_populates="document",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentPerson(SQLModel, table=True):
|
|
||||||
__tablename__ = "document_person"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"document_id",
|
|
||||||
"person_id",
|
|
||||||
"role",
|
|
||||||
name="unique_document_person_role",
|
|
||||||
),
|
|
||||||
Index("idx_document_person_doc", "document_id"),
|
|
||||||
Index("idx_document_person_per", "person_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
primary_key=True,
|
|
||||||
server_default=text("gen_random_uuid()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
document_id: UUID = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
ForeignKey("document.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
person_id: UUID = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
ForeignKey("person.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
role: PersonRole = Field(sa_column=Column(String(20), nullable=False))
|
|
||||||
created_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
document: Document | None = Relationship(
|
|
||||||
back_populates="document_people",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise"},
|
|
||||||
)
|
|
||||||
person: Person | None = Relationship(
|
|
||||||
back_populates="document_people",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Job(SQLModel, table=True):
|
|
||||||
__tablename__ = "job"
|
|
||||||
__table_args__ = (Index("idx_job_document", "document_id"),)
|
|
||||||
|
|
||||||
id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
primary_key=True,
|
|
||||||
server_default=text("gen_random_uuid()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
document_id: UUID = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
ForeignKey("document.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
status: JobStatus = Field(
|
|
||||||
default=JobStatus.QUEUED,
|
|
||||||
sa_column=Column(
|
|
||||||
String(50),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("'queued'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
retry_count: int = Field(
|
|
||||||
default=0,
|
|
||||||
sa_column=Column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("0"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
provider: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
model: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
prompt_name: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
date_created: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
date_updated: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
document: Document | None = Relationship(
|
|
||||||
back_populates="jobs",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise"},
|
|
||||||
)
|
|
||||||
job_sources: list["JobSource"] = Relationship(
|
|
||||||
back_populates="job",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Source(SQLModel, table=True):
|
|
||||||
__tablename__ = "source"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_source_document", "document_id"),
|
|
||||||
Index("idx_source_page_order", "document_id", "page_number"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
primary_key=True,
|
|
||||||
server_default=text("gen_random_uuid()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
document_id: UUID = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
ForeignKey("document.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
page_number: int = Field(
|
|
||||||
default=1,
|
|
||||||
sa_column=Column(
|
|
||||||
Integer,
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("1"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
upload_name: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
filename: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
file_path: str = Field(sa_column=Column(Text, nullable=False))
|
|
||||||
raw_transcription: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
revised_text: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
date_uploaded: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
date_revised: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(DateTime(timezone=True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
document: Document | None = Relationship(
|
|
||||||
back_populates="sources",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise"},
|
|
||||||
)
|
|
||||||
job_sources: list["JobSource"] = Relationship(
|
|
||||||
back_populates="source",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise", "passive_deletes": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JobSource(SQLModel, table=True):
|
|
||||||
__tablename__ = "job_source"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("job_id", "source_id", name="unique_job_source"),
|
|
||||||
Index("idx_job_source_job", "job_id"),
|
|
||||||
Index("idx_job_source_source", "source_id"),
|
|
||||||
Index(
|
|
||||||
"idx_job_source_ai_metadata",
|
|
||||||
"ai_metadata",
|
|
||||||
postgresql_using="gin",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: UUID | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
primary_key=True,
|
|
||||||
server_default=text("gen_random_uuid()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
job_id: UUID = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
ForeignKey("job.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
source_id: UUID = Field(
|
|
||||||
sa_column=Column(
|
|
||||||
PostgreSQLUUID(as_uuid=True),
|
|
||||||
ForeignKey("source.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
status: JobSourceStatus = Field(
|
|
||||||
default=JobSourceStatus.PENDING,
|
|
||||||
sa_column=Column(
|
|
||||||
String(50),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("'pending'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
raw_transcription: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
ai_metadata: JsonValue | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(JSONB),
|
|
||||||
)
|
|
||||||
raw_api_response: JsonValue | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(JSONB),
|
|
||||||
)
|
|
||||||
error_detail: str | None = Field(default=None, sa_column=Column(Text))
|
|
||||||
executed_at: datetime | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=text("now()"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
job: Job | None = Relationship(
|
|
||||||
back_populates="job_sources",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise"},
|
|
||||||
)
|
|
||||||
source: Source | None = Relationship(
|
|
||||||
back_populates="job_sources",
|
|
||||||
sa_relationship_kwargs={"lazy": "raise"},
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The enum annotations validate application values while the mapped columns retain the `VARCHAR` types specified by the DDL. PostgreSQL owns generated UUIDs and timestamps through `server_default`; call `session.refresh(instance)` after a flush or commit when those generated values are needed immediately.
|
|
||||||
|
|
||||||
`ai_metadata`, `raw_api_response`, and `metadata_` accept any JSON value supported by `JSONB`. Validate provider-specific payload structure before assigning it to these fields, while preserving the complete raw response in `raw_api_response`.
|
|
||||||
|
|
||||||
Relationships use `lazy="raise"` to prevent implicit database I/O in async code. Queries must explicitly load relationships they need, for example with `selectinload()`.
|
|
||||||
|
|
||||||
The schema's behavioral invariants are enforced outside the table shape where appropriate:
|
|
||||||
|
|
||||||
- `PersonRole`, `JobStatus`, and `JobSourceStatus` define the exact values listed by the schema.
|
|
||||||
- `unique_document_person_role` enforces role uniqueness for `(document_id, person_id, role)`.
|
|
||||||
- Services order document sources by `Source.document_id` and `Source.page_number`.
|
|
||||||
- Services derive aggregate `Job.status` from related `JobSource.status` values.
|
|
||||||
- Services preserve `JobSource.raw_transcription` and `JobSource.raw_api_response` as point-in-time outputs while updating the active text on `Source`.
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# {{project_name}}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
project_name:
|
||||||
|
type: str
|
||||||
|
help: What is the project name?
|
||||||
|
|
||||||
|
repo_name:
|
||||||
|
type: str
|
||||||
|
help: What is the repo name?
|
||||||
|
|
||||||
|
module_name:
|
||||||
|
type: str
|
||||||
|
help: What is your Python module name?
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# {{project_name}}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "{{repo_name}}"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pre-commit>=4.6.0",
|
||||||
|
"ruff>=0.15.15",
|
||||||
|
"ipykernel>=7.2.0",
|
||||||
|
]
|
||||||
@@ -10,11 +10,10 @@ exclude = [
|
|||||||
"build",
|
"build",
|
||||||
"site",
|
"site",
|
||||||
"__pycache__",
|
"__pycache__",
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[lint]
|
[lint]
|
||||||
preview = true
|
|
||||||
|
|
||||||
extend-select = [
|
extend-select = [
|
||||||
"ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
|
"ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
|
||||||
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
|
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
|
||||||
@@ -49,7 +48,6 @@ ignore = [
|
|||||||
"*.ipynb" = [
|
"*.ipynb" = [
|
||||||
"F401", # unused imports
|
"F401", # unused imports
|
||||||
"F841", # unused local variable
|
"F841", # unused local variable
|
||||||
"F821", # undefined name in exploratory notebook cells
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[lint.isort]
|
[lint.isort]
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
# ============================================================================
|
||||||
|
#
|
||||||
|
# The configuration produced by default is meant to highlight the features
|
||||||
|
# that Zensical provides and to serve as a starting point for your own
|
||||||
|
# projects.
|
||||||
|
#
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
[project]
|
||||||
|
|
||||||
|
# The site_name is shown in the page header and the browser window title
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_name
|
||||||
|
site_name = "{{project_name}} Documentation"
|
||||||
|
|
||||||
|
# The site_description is included in the HTML head and should contain a
|
||||||
|
# meaningful description of the site content for use by search engines.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_description
|
||||||
|
site_description = "Documentation site for {{project_name}}"
|
||||||
|
|
||||||
|
# The site_author attribute. This is used in the HTML head element.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_author
|
||||||
|
site_author = "John Lancaster"
|
||||||
|
|
||||||
|
# The site_url is the canonical URL for your site. When building online
|
||||||
|
# documentation you should set this.
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#site_url
|
||||||
|
#site_url = "https://www.example.com/"
|
||||||
|
|
||||||
|
# The copyright notice appears in the page footer and can contain an HTML
|
||||||
|
# fragment.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/basics/#copyright
|
||||||
|
copyright = """
|
||||||
|
Copyright © 2026 The authors
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Zensical supports both implicit navigation and explicitly defined navigation.
|
||||||
|
# If you decide not to define a navigation here then Zensical will simply
|
||||||
|
# derive the navigation structure from the directory structure of your
|
||||||
|
# "docs_dir". The definition below demonstrates how a navigation structure
|
||||||
|
# can be defined using TOML syntax.
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/setup/navigation/
|
||||||
|
# nav = [
|
||||||
|
# { "Get started" = "index.md" },
|
||||||
|
# { "Markdown in 5min" = "markdown.md" },
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# With the "extra_css" option you can add your own CSS styling to customize
|
||||||
|
# your Zensical project according to your needs. You can add any number of
|
||||||
|
# CSS files.
|
||||||
|
#
|
||||||
|
# The path provided should be relative to the "docs_dir".
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/customization/#additional-css
|
||||||
|
#
|
||||||
|
#extra_css = ["stylesheets/extra.css"]
|
||||||
|
|
||||||
|
# With the `extra_javascript` option you can add your own JavaScript to your
|
||||||
|
# project to customize the behavior according to your needs.
|
||||||
|
#
|
||||||
|
# The path provided should be relative to the "docs_dir".
|
||||||
|
#
|
||||||
|
# Read more: https://zensical.org/docs/customization/#additional-javascript
|
||||||
|
#extra_javascript = ["javascripts/extra.js"]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Section for configuring theme options
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[project.theme]
|
||||||
|
|
||||||
|
# change this to "classic" to use the traditional Material for MkDocs look.
|
||||||
|
#variant = "classic"
|
||||||
|
|
||||||
|
# Zensical allows you to override specific blocks, partials, or whole
|
||||||
|
# templates as well as to define your own templates. To do this, uncomment
|
||||||
|
# the custom_dir setting below and set it to a directory in which you
|
||||||
|
# keep your template overrides.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/customization/#extending-the-theme
|
||||||
|
#
|
||||||
|
#custom_dir = "overrides"
|
||||||
|
|
||||||
|
# With the "favicon" option you can set your own image to use as the icon
|
||||||
|
# browsers will use in the browser title bar or tab bar. The path provided
|
||||||
|
# must be relative to the "docs_dir".
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/logo-and-icons/#favicon
|
||||||
|
# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon
|
||||||
|
#
|
||||||
|
#favicon = "images/favicon.png"
|
||||||
|
|
||||||
|
# Zensical supports more than 60 different languages. This means that the
|
||||||
|
# labels and tooltips that Zensical's templates produce are translated.
|
||||||
|
# The "language" option allows you to set the language used. This language
|
||||||
|
# is also indicated in the HTML head element to help with accessibility
|
||||||
|
# and guide search engines and translation tools.
|
||||||
|
#
|
||||||
|
# The default language is "en" (English). It is possible to create
|
||||||
|
# sites with multiple languages and configure a language selector. See
|
||||||
|
# the documentation for details.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/language/
|
||||||
|
#
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
# Zensical provides a number of feature toggles that change the behavior
|
||||||
|
# of the documentation site.
|
||||||
|
features = [
|
||||||
|
# Zensical includes an announcement bar. This feature allows users to
|
||||||
|
# dismiss it when they have read the announcement.
|
||||||
|
# https://zensical.org/docs/setup/header/#announcement-bar
|
||||||
|
"announce.dismiss",
|
||||||
|
|
||||||
|
# If you have a repository configured and turn on this feature, Zensical
|
||||||
|
# will generate an edit button for the page. This works for common
|
||||||
|
# repository hosting services.
|
||||||
|
# https://zensical.org/docs/setup/repository/#content-actions
|
||||||
|
#"content.action.edit",
|
||||||
|
|
||||||
|
# If you have a repository configured and turn on this feature, Zensical
|
||||||
|
# will generate a button that allows the user to view the Markdown
|
||||||
|
# code for the current page.
|
||||||
|
# https://zensical.org/docs/setup/repository/#content-actions
|
||||||
|
#"content.action.view",
|
||||||
|
|
||||||
|
# Code annotations allow you to add an icon with a tooltip to your
|
||||||
|
# code blocks to provide explanations at crucial points.
|
||||||
|
# https://zensical.org/docs/authoring/code-blocks/#code-annotations
|
||||||
|
"content.code.annotate",
|
||||||
|
|
||||||
|
# This feature turns on a button in code blocks that allow users to
|
||||||
|
# copy the content to their clipboard without first selecting it.
|
||||||
|
# https://zensical.org/docs/authoring/code-blocks/#code-copy-button
|
||||||
|
"content.code.copy",
|
||||||
|
|
||||||
|
# Code blocks can include a button to allow for the selection of line
|
||||||
|
# ranges by the user.
|
||||||
|
# https://zensical.org/docs/authoring/code-blocks/#code-selection-button
|
||||||
|
"content.code.select",
|
||||||
|
|
||||||
|
# Zensical can render footnotes as inline tooltips, so the user can read
|
||||||
|
# the footnote without leaving the context of the document.
|
||||||
|
# https://zensical.org/docs/authoring/footnotes/#footnote-tooltips
|
||||||
|
"content.footnote.tooltips",
|
||||||
|
|
||||||
|
# If you have many content tabs that have the same titles (e.g., "Python",
|
||||||
|
# "JavaScript", "Cobol"), this feature causes all of them to switch to
|
||||||
|
# at the same time when the user chooses their language in one.
|
||||||
|
# https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs
|
||||||
|
"content.tabs.link",
|
||||||
|
|
||||||
|
# With this feature enabled users can add tooltips to links that will be
|
||||||
|
# displayed when the mouse pointer hovers the link.
|
||||||
|
# https://zensical.org/docs/authoring/tooltips/#improved-tooltips
|
||||||
|
"content.tooltips",
|
||||||
|
|
||||||
|
# With this feature enabled, Zensical will automatically hide parts
|
||||||
|
# of the header when the user scrolls past a certain point.
|
||||||
|
# https://zensical.org/docs/setup/header/#automatic-hiding
|
||||||
|
# "header.autohide",
|
||||||
|
|
||||||
|
# Turn on this feature to expand all collapsible sections in the
|
||||||
|
# navigation sidebar by default.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-expansion
|
||||||
|
# "navigation.expand",
|
||||||
|
|
||||||
|
# This feature turns on navigation elements in the footer that allow the
|
||||||
|
# user to navigate to a next or previous page.
|
||||||
|
# https://zensical.org/docs/setup/footer/#navigation
|
||||||
|
"navigation.footer",
|
||||||
|
|
||||||
|
# When section index pages are enabled, documents can be directly attached
|
||||||
|
# to sections, which is particularly useful for providing overview pages.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#section-index-pages
|
||||||
|
"navigation.indexes",
|
||||||
|
|
||||||
|
# When instant navigation is enabled, clicks on all internal links will be
|
||||||
|
# intercepted and dispatched via XHR without fully reloading the page.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#instant-navigation
|
||||||
|
"navigation.instant",
|
||||||
|
|
||||||
|
# With instant prefetching, your site will start to fetch a page once the
|
||||||
|
# user hovers over a link. This will reduce the perceived loading time
|
||||||
|
# for the user.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#instant-prefetching
|
||||||
|
"navigation.instant.prefetch",
|
||||||
|
|
||||||
|
# In order to provide a better user experience on slow connections when
|
||||||
|
# using instant navigation, a progress indicator can be enabled.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#progress-indicator
|
||||||
|
#"navigation.instant.progress",
|
||||||
|
|
||||||
|
# When navigation paths are activated, a breadcrumb navigation is rendered
|
||||||
|
# above the title of each page
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-path
|
||||||
|
"navigation.path",
|
||||||
|
|
||||||
|
# When pruning is enabled, only the visible navigation items are included
|
||||||
|
# in the rendered HTML, reducing the size of the built site by 33% or more.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-pruning
|
||||||
|
#"navigation.prune",
|
||||||
|
|
||||||
|
# When sections are enabled, top-level sections are rendered as groups in
|
||||||
|
# the sidebar for viewports above 1220px, but remain as-is on mobile.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-sections
|
||||||
|
"navigation.sections",
|
||||||
|
|
||||||
|
# When tabs are enabled, top-level sections are rendered in a menu layer
|
||||||
|
# below the header for viewports above 1220px, but remain as-is on mobile.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-tabs
|
||||||
|
#"navigation.tabs",
|
||||||
|
|
||||||
|
# When sticky tabs are enabled, navigation tabs will lock below the header
|
||||||
|
# and always remain visible when scrolling down.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#sticky-navigation-tabs
|
||||||
|
#"navigation.tabs.sticky",
|
||||||
|
|
||||||
|
# A back-to-top button can be shown when the user, after scrolling down,
|
||||||
|
# starts to scroll up again.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#back-to-top-button
|
||||||
|
"navigation.top",
|
||||||
|
|
||||||
|
# When anchor tracking is enabled, the URL in the address bar is
|
||||||
|
# automatically updated with the active anchor as highlighted in the table
|
||||||
|
# of contents.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#anchor-tracking
|
||||||
|
"navigation.tracking",
|
||||||
|
|
||||||
|
# When search highlighting is enabled and a user clicks on a search result,
|
||||||
|
# Zensical will highlight all occurrences after following the link.
|
||||||
|
# https://zensical.org/docs/setup/search/#search-highlighting
|
||||||
|
"search.highlight",
|
||||||
|
|
||||||
|
# When anchor following for the table of contents is enabled, the sidebar
|
||||||
|
# is automatically scrolled so that the active anchor is always visible.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#anchor-following
|
||||||
|
# "toc.follow",
|
||||||
|
|
||||||
|
# When navigation integration for the table of contents is enabled, it is
|
||||||
|
# always rendered as part of the navigation sidebar on the left.
|
||||||
|
# https://zensical.org/docs/setup/navigation/#navigation-integration
|
||||||
|
#"toc.integrate",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# You can configure your own logo to be shown in the header using the "logo"
|
||||||
|
# option in the "theme" subsection. The logo must be a relative path to a file
|
||||||
|
# in your "docs_dir", e.g., to use `docs/assets/logo.png` you would set:
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#logo = "assets/logo.png"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# If you don't have a dedicated project logo, you can use a built-in icon from
|
||||||
|
# the icon sets shipped in Zensical. Please note that the setting lives in a
|
||||||
|
# different subsection, and that the above take precedence over the icon.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/logo-and-icons
|
||||||
|
# - https://github.com/zensical/ui/tree/master/dist/.icons
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#[project.theme.icon]
|
||||||
|
#logo = "lucide/smile"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# In the "font" subsection you can configure the fonts used. By default, fonts
|
||||||
|
# are loaded from Google Fonts, giving you a wide range of choices from a set
|
||||||
|
# of suitably licensed fonts. There are options for a normal text font and for
|
||||||
|
# a monospaced font used in code blocks.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#[project.theme.font]
|
||||||
|
#text = "Inter"
|
||||||
|
#code = "Jetbrains Mono"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# In the "palette" subsection you can configure options for the color scheme.
|
||||||
|
# You can configure different color schemes, e.g., to turn on dark mode,
|
||||||
|
# that the user can switch between. Each color scheme can be further
|
||||||
|
# customized.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/colors/
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[[project.theme.palette]]
|
||||||
|
scheme = "default"
|
||||||
|
toggle.icon = "lucide/sun"
|
||||||
|
toggle.name = "Switch to dark mode"
|
||||||
|
|
||||||
|
[[project.theme.palette]]
|
||||||
|
scheme = "slate"
|
||||||
|
toggle.icon = "lucide/moon"
|
||||||
|
toggle.name = "Switch to light mode"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# The "extra" section contains miscellaneous settings.
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#[[project.extra.social]]
|
||||||
|
#icon = "fontawesome/brands/github"
|
||||||
|
#link = "https://github.com/user/repo"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# In this section you can configure the Markdown extensions that are used when
|
||||||
|
# rendering your documentation. We enable the most useful extensions by default,
|
||||||
|
# but you can customize this list to your needs.
|
||||||
|
#
|
||||||
|
# Read more:
|
||||||
|
# - https://zensical.org/docs/setup/extensions/
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
[project.markdown_extensions.abbr]
|
||||||
|
[project.markdown_extensions.admonition]
|
||||||
|
[project.markdown_extensions.attr_list]
|
||||||
|
[project.markdown_extensions.def_list]
|
||||||
|
[project.markdown_extensions.footnotes]
|
||||||
|
[project.markdown_extensions.md_in_html]
|
||||||
|
[project.markdown_extensions.toc]
|
||||||
|
permalink = true
|
||||||
|
[project.markdown_extensions.pymdownx.arithmatex]
|
||||||
|
generic = true
|
||||||
|
[project.markdown_extensions.pymdownx.betterem]
|
||||||
|
[project.markdown_extensions.pymdownx.caret]
|
||||||
|
[project.markdown_extensions.pymdownx.details]
|
||||||
|
[project.markdown_extensions.pymdownx.emoji]
|
||||||
|
emoji_generator = "zensical.extensions.emoji.to_svg"
|
||||||
|
emoji_index = "zensical.extensions.emoji.twemoji"
|
||||||
|
[project.markdown_extensions.pymdownx.highlight]
|
||||||
|
anchor_linenums = true
|
||||||
|
line_spans = "__span"
|
||||||
|
pygments_lang_class = true
|
||||||
|
[project.markdown_extensions.pymdownx.inlinehilite]
|
||||||
|
[project.markdown_extensions.pymdownx.keys]
|
||||||
|
[project.markdown_extensions.pymdownx.magiclink]
|
||||||
|
[project.markdown_extensions.pymdownx.mark]
|
||||||
|
[project.markdown_extensions.pymdownx.smartsymbols]
|
||||||
|
[project.markdown_extensions.pymdownx.snippets]
|
||||||
|
[project.markdown_extensions.pymdownx.superfences]
|
||||||
|
custom_fences = [
|
||||||
|
{ name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" }
|
||||||
|
]
|
||||||
|
[project.markdown_extensions.pymdownx.tabbed]
|
||||||
|
alternate_style = true
|
||||||
|
combine_header_slug = true
|
||||||
|
[project.markdown_extensions.pymdownx.tasklist]
|
||||||
|
custom_checkbox = true
|
||||||
|
[project.markdown_extensions.pymdownx.tilde]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Changes here will be overwritten by Copier
|
||||||
|
{{ _copier_answers|to_nice_yaml -}}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Prompt Artifacts
|
|
||||||
|
|
||||||
This directory stores transcription prompts as individual Markdown artifacts.
|
|
||||||
|
|
||||||
## Conventions
|
|
||||||
- Keep one prompt per file.
|
|
||||||
- Use stable, descriptive snake_case file names.
|
|
||||||
- Prefer incremental edits to a single prompt per change for clean history.
|
|
||||||
- Keep prompts human-readable and policy-focused.
|
|
||||||
- Do not store secrets in prompt files.
|
|
||||||
|
|
||||||
## Current Prompt
|
|
||||||
- `transcribe_document.md`: baseline verbatim transcription policy for historical documents.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
You are an assistant that may call tools.
|
|
||||||
|
|
||||||
Tool safety rules:
|
|
||||||
1) Tool arguments MUST be strict JSON matching the schema exactly.
|
|
||||||
2) Never place disallowed, sensitive, explicit, or policy-violating text directly into tool arguments.
|
|
||||||
3) If user content may be unsafe, first produce a brief neutral summary and pass only that summary.
|
|
||||||
4) Prefer IDs, enums, booleans, and short fields over raw free-form text.
|
|
||||||
5) Keep all string arguments <= 300 chars unless schema says otherwise.
|
|
||||||
6) If you cannot safely provide valid tool args, do not call the tool; respond with "NO_TOOL_CALL" and explain briefly.
|
|
||||||
7) Never include markdown/code fences in tool arguments.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Historical Document Verbatim Transcription Prompt
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
Transcribe the provided historical document image as a faithful **verbatim** transcript.
|
|
||||||
Do not summarize. Do not paraphrase. Do not modernize style.
|
|
||||||
|
|
||||||
## Output Contract
|
|
||||||
- Return only the transcription text.
|
|
||||||
- Preserve original wording, punctuation, and meaningful structure.
|
|
||||||
- Keep line/section flow readable while preserving intent and document organization.
|
|
||||||
- Never invent missing content.
|
|
||||||
|
|
||||||
## Rules for Ambiguous or Damaged Text
|
|
||||||
|
|
||||||
### Misspellings and original errors
|
|
||||||
- Preserve original spelling.
|
|
||||||
- Add `[sic]` immediately after an evident original error.
|
|
||||||
|
|
||||||
### Missing words or clear omissions
|
|
||||||
- If a single missing word is obvious from context, insert it in square brackets.
|
|
||||||
- Example form: `[to]`
|
|
||||||
|
|
||||||
### Uncertain readings
|
|
||||||
- If best-effort interpretation is uncertain, use bracketed guess with question mark.
|
|
||||||
- Example form: `[Boston?]`
|
|
||||||
|
|
||||||
### Completely illegible text
|
|
||||||
- Use a clear bracketed label.
|
|
||||||
- Preferred forms: `[illegible]`, `[torn]`, `[ink blot]`, `[remainder of page torn]`
|
|
||||||
|
|
||||||
### Crossed-out text
|
|
||||||
- Preserve it using: `[deleted: ...]`
|
|
||||||
|
|
||||||
### Squeezed-in or above-line insertions
|
|
||||||
- Preserve it using: `[inserted: ...]`
|
|
||||||
|
|
||||||
### Superscripts and abbreviations
|
|
||||||
- Bring superscript letters down to baseline text.
|
|
||||||
- Expand only when clearly intended; if expanded, place added letters in brackets.
|
|
||||||
|
|
||||||
### Non-text visual elements
|
|
||||||
- Describe briefly in square brackets.
|
|
||||||
- Example forms: `[wax notary seal attached here]`, `[sketch of a fort layout]`
|
|
||||||
|
|
||||||
### Marginalia and side notes
|
|
||||||
- Signal location before the note text.
|
|
||||||
- Example form: `[written in left margin: ...]`
|
|
||||||
|
|
||||||
### Line-break hyphenation
|
|
||||||
- Rejoin words split across line breaks when they are clearly one word.
|
|
||||||
- Remove only line-break hyphens used for wrapping.
|
|
||||||
|
|
||||||
### Ambiguous capitalization
|
|
||||||
- Prefer modern capitalization only when uncertainty is high.
|
|
||||||
- Preserve clearly intentional archaic capitalization.
|
|
||||||
|
|
||||||
### Hierarchical outlines and numbering
|
|
||||||
- Preserve original numbering characters exactly (including roman numerals and unusual suffixes).
|
|
||||||
- Preserve indentation levels.
|
|
||||||
- Do not silently correct sequence mistakes; if clearly erroneous, preserve and use `[sic]` where appropriate.
|
|
||||||
|
|
||||||
## Confidence and Integrity Policy
|
|
||||||
- When uncertain, mark uncertainty explicitly rather than guessing silently.
|
|
||||||
- If text cannot be read, use a bracketed illegibility label instead of fabrication.
|
|
||||||
- Do not add commentary outside the transcription.
|
|
||||||
|
|
||||||
## Final Self-Check
|
|
||||||
Before finalizing, ensure:
|
|
||||||
1. The transcript is verbatim and not summarized.
|
|
||||||
2. Uncertain/illegible areas are explicitly marked.
|
|
||||||
3. Crossed-out and inserted text are preserved with required tags.
|
|
||||||
4. Structure/ordering is preserved as faithfully as possible.
|
|
||||||
+2
-45
@@ -1,50 +1,7 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
|
||||||
packages = ["src/transcription"]
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "transcription"
|
name = "python-template"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Historical document transcription system"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiosqlite>=0.21.0",
|
"copier>=9.15.1",
|
||||||
"asyncpg>=0.31.0",
|
|
||||||
"fastapi>=0.138.0",
|
|
||||||
"nicegui==3.13.0",
|
|
||||||
"openrouter>=0.7.0",
|
|
||||||
"psycopg2-binary>=2.9.12",
|
|
||||||
"pydantic>=2.13.4",
|
|
||||||
"pydantic-settings>=2.9.1",
|
|
||||||
"sqlmodel>=0.0.25",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.25",
|
|
||||||
"httpx2>=2.5.0",
|
|
||||||
"ipykernel>=7.3.0",
|
|
||||||
"ipywidgets>=8.1.8",
|
|
||||||
"pre-commit>=4.6.0",
|
|
||||||
"rich>=15.0.0",
|
|
||||||
"ruff>=0.15.20",
|
|
||||||
"ty>=0.0.54",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
addopts = "--strict-markers -q"
|
|
||||||
asyncio_mode = "strict"
|
|
||||||
filterwarnings = [
|
|
||||||
"error:coroutine .* was never awaited:RuntimeWarning",
|
|
||||||
]
|
|
||||||
markers = [
|
|
||||||
"unit: pure logic tests with no external dependencies",
|
|
||||||
"integration: tests that touch framework or database contracts",
|
|
||||||
"external: tests that call external services (slow, requires credentials)",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import uvicorn
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from .app import create_app
|
|
||||||
from .config import parse_cli_settings
|
|
||||||
|
|
||||||
|
|
||||||
def create_cli_app() -> FastAPI:
|
|
||||||
"""Create an app from CLI settings for Uvicorn's reload process."""
|
|
||||||
return create_app(settings=parse_cli_settings())
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
settings = parse_cli_settings()
|
|
||||||
application = "transcription.__main__:create_cli_app" if settings.reload else create_app(settings=settings)
|
|
||||||
uvicorn.run(
|
|
||||||
application,
|
|
||||||
factory=settings.reload,
|
|
||||||
host=settings.host,
|
|
||||||
port=settings.port,
|
|
||||||
log_level=settings.log_level,
|
|
||||||
reload=settings.reload,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API route modules for the transcription app."""
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""Centralized API exception handlers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from transcription.errors import AppError
|
|
||||||
from transcription.errors import ErrorCategory
|
|
||||||
from transcription.errors import build_error_envelope
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
_STATUS_BY_CATEGORY: dict[ErrorCategory, int] = {
|
|
||||||
ErrorCategory.VALIDATION: 400,
|
|
||||||
ErrorCategory.USER_INPUT: 400,
|
|
||||||
ErrorCategory.NOT_FOUND: 404,
|
|
||||||
ErrorCategory.CONFLICT: 409,
|
|
||||||
ErrorCategory.EXTERNAL_PROVIDER: 503,
|
|
||||||
ErrorCategory.INFRA_TRANSIENT: 503,
|
|
||||||
ErrorCategory.INFRA_PERSISTENT: 500,
|
|
||||||
ErrorCategory.INTERNAL_UNEXPECTED: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _status_for(error: AppError) -> int:
|
|
||||||
return _STATUS_BY_CATEGORY.get(error.category, 500)
|
|
||||||
|
|
||||||
|
|
||||||
def register_error_handlers(app: FastAPI) -> None:
|
|
||||||
"""Register API exception handlers on the app."""
|
|
||||||
|
|
||||||
@app.exception_handler(AppError)
|
|
||||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
|
||||||
envelope = build_error_envelope(exc)
|
|
||||||
return JSONResponse(status_code=_status_for(exc), content=envelope.__dict__)
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
|
||||||
async def fallback_error_handler(_request: Request, exc: Exception) -> JSONResponse:
|
|
||||||
normalized = AppError(
|
|
||||||
"Unexpected error while handling request",
|
|
||||||
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
|
||||||
suggestion="Retry once. If it persists, report the error reference id.",
|
|
||||||
)
|
|
||||||
logger.exception(
|
|
||||||
"Unhandled API exception operation=api.request error_id=%s category=%s exception_type=%s",
|
|
||||||
normalized.error_id,
|
|
||||||
normalized.category.value,
|
|
||||||
type(exc).__name__,
|
|
||||||
)
|
|
||||||
envelope = build_error_envelope(normalized)
|
|
||||||
return JSONResponse(status_code=_status_for(normalized), content=envelope.__dict__)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""Health endpoint routes."""
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def healthz() -> dict[str, str]:
|
|
||||||
"""Return a simple health status payload."""
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/healthz")
|
|
||||||
def healthz_route() -> dict[str, str]:
|
|
||||||
"""Route wrapper for health status payload."""
|
|
||||||
return healthz()
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""Application factory and lifespan wiring for the transcription app."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from contextlib import AsyncExitStack
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi import status
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
|
||||||
from .api.health import router as health_router
|
|
||||||
from .config import Settings
|
|
||||||
from .config import configure_logging
|
|
||||||
from .config import get_settings
|
|
||||||
from .db import create_all
|
|
||||||
from .db import dispose_database_runtime
|
|
||||||
from .db import initialize_database_runtime
|
|
||||||
from .services import ServiceBundle
|
|
||||||
from .services.jobs import JobService
|
|
||||||
from .ui import register_pages
|
|
||||||
from .worker import worker_consumer_lifespan
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _lifespan(app: FastAPI):
|
|
||||||
settings = getattr(app.state, "settings", None) or get_settings()
|
|
||||||
configure_logging(settings)
|
|
||||||
app.state.settings = settings
|
|
||||||
app.state.services = ServiceBundle()
|
|
||||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
|
||||||
|
|
||||||
if settings.should_bootstrap_schema:
|
|
||||||
await create_all(engine=app.state.runtime.engine)
|
|
||||||
|
|
||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
await _recover_stale_processing_jobs(app)
|
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
|
||||||
stack.push_async_callback(dispose_database_runtime)
|
|
||||||
stop_event, worker_notifier = await stack.enter_async_context(
|
|
||||||
worker_consumer_lifespan(
|
|
||||||
session_factory=app.state.runtime.session_factory,
|
|
||||||
poll_interval_seconds=1.0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
app.state.worker_stop_event = stop_event
|
|
||||||
app.state.worker_notifier = worker_notifier
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
async def _recover_stale_processing_jobs(app: FastAPI) -> None:
|
|
||||||
"""Re-queue stale processing jobs at startup.
|
|
||||||
|
|
||||||
Any job left in PROCESSING longer than the configured provider timeout is
|
|
||||||
assumed orphaned and moved back to QUEUED before the worker starts.
|
|
||||||
"""
|
|
||||||
settings = app.state.settings
|
|
||||||
stale_before = datetime.now(UTC) - timedelta(seconds=settings.worker_provider_timeout_seconds)
|
|
||||||
job_service = JobService(session_factory=app.state.runtime.session_factory)
|
|
||||||
recovered = await job_service.requeue_stale_processing_jobs(stale_before=stale_before)
|
|
||||||
if recovered > 0:
|
|
||||||
logger.warning("Recovered %s stale processing job(s) at startup", recovered)
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
|
||||||
"""Create and configure the FastAPI application."""
|
|
||||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
|
||||||
active_settings = settings or get_settings()
|
|
||||||
app.state.settings = active_settings
|
|
||||||
app.mount(
|
|
||||||
"/uploads",
|
|
||||||
StaticFiles(directory=active_settings.upload_dir, check_dir=False),
|
|
||||||
name="uploads",
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/", include_in_schema=False)
|
|
||||||
async def root_redirect() -> RedirectResponse:
|
|
||||||
return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
|
||||||
|
|
||||||
@app.get("/ui", include_in_schema=False)
|
|
||||||
async def ui_redirect() -> RedirectResponse:
|
|
||||||
return RedirectResponse(url="/ui/homepage", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
|
||||||
|
|
||||||
@app.get("/healthz")
|
|
||||||
def health() -> dict[str, str]:
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
register_error_handlers(app)
|
|
||||||
register_pages(app)
|
|
||||||
app.include_router(health_router)
|
|
||||||
return app
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""Helpers for accessing lifespan-owned application state resources."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from transcription.db.runtime import DatabaseRuntime
|
|
||||||
from transcription.db.session import get_session_factory
|
|
||||||
from transcription.worker import WorkerNotifier
|
|
||||||
from transcription.worker import resolve_worker_notifier
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_database_runtime(state: object) -> DatabaseRuntime | None:
|
|
||||||
"""Return database runtime from app-like state objects when available."""
|
|
||||||
runtime = getattr(state, "runtime", None)
|
|
||||||
return runtime if isinstance(runtime, DatabaseRuntime) else None
|
|
||||||
|
|
||||||
|
|
||||||
def require_database_runtime(state: object) -> DatabaseRuntime:
|
|
||||||
"""Return database runtime or raise when app lifespan has not initialized it."""
|
|
||||||
runtime = resolve_database_runtime(state)
|
|
||||||
if runtime is None:
|
|
||||||
raise RuntimeError("Database runtime is not initialized on application state")
|
|
||||||
return runtime
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_session_factory(state: object) -> async_sessionmaker[AsyncSession]:
|
|
||||||
"""Return DB session factory from state when available, otherwise shared runtime."""
|
|
||||||
runtime = resolve_database_runtime(state)
|
|
||||||
if runtime is not None:
|
|
||||||
return runtime.session_factory
|
|
||||||
return get_session_factory()
|
|
||||||
|
|
||||||
|
|
||||||
def get_worker_notifier(app: FastAPI) -> WorkerNotifier:
|
|
||||||
"""Return app worker notifier, or a no-op fallback when unavailable."""
|
|
||||||
return resolve_worker_notifier(app.state)
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
"""Centralized application configuration.
|
|
||||||
|
|
||||||
All settings are loaded from environment variables (or a .env file)
|
|
||||||
once at startup. Provider-specific defaults (model names, base URLs)
|
|
||||||
are resolved by the provider adapters, not here.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging.config
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from enum import StrEnum
|
|
||||||
from functools import cache
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Annotated
|
|
||||||
from typing import Any
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from pydantic import Field
|
|
||||||
from pydantic import SecretStr
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from pydantic_settings import SettingsConfigDict
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Provider(StrEnum):
|
|
||||||
OPENROUTER = "openrouter"
|
|
||||||
|
|
||||||
|
|
||||||
class SqliteSettings(BaseModel):
|
|
||||||
driver: Literal["sqlite"] = "sqlite"
|
|
||||||
path: str = "app.db"
|
|
||||||
|
|
||||||
|
|
||||||
class PostgresSettings(BaseModel):
|
|
||||||
driver: Literal["postgres"] = "postgres"
|
|
||||||
host: str
|
|
||||||
port: int = 5432
|
|
||||||
database: str
|
|
||||||
user: str
|
|
||||||
password: SecretStr
|
|
||||||
|
|
||||||
|
|
||||||
DatabaseSettings = Annotated[
|
|
||||||
SqliteSettings | PostgresSettings,
|
|
||||||
Field(discriminator="driver"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
extra="ignore",
|
|
||||||
env_nested_delimiter="__",
|
|
||||||
cli_implicit_flags=True,
|
|
||||||
cli_kebab_case=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- NiceGUI Server ---
|
|
||||||
host: str = "0.0.0.0"
|
|
||||||
port: int = 8000
|
|
||||||
log_level: Literal["critical", "error", "warning", "info", "debug", "trace"] = "info"
|
|
||||||
reload: bool = False
|
|
||||||
|
|
||||||
# --- AI provider ---
|
|
||||||
provider: Provider = Provider.OPENROUTER
|
|
||||||
openrouter_api_key: str
|
|
||||||
provider_model: str | None = None
|
|
||||||
openrouter_http_referer: str | None = None
|
|
||||||
openrouter_app_title: str | None = None
|
|
||||||
|
|
||||||
# --- runtime environment ---
|
|
||||||
environment: Literal["development", "test", "production"] = "development"
|
|
||||||
|
|
||||||
# --- persistence ---
|
|
||||||
database: DatabaseSettings = Field(default_factory=SqliteSettings)
|
|
||||||
bootstrap_schema_on_startup: bool = False
|
|
||||||
sqlite_check_same_thread: bool = False
|
|
||||||
|
|
||||||
# --- filesystem paths ---
|
|
||||||
upload_dir: Path = Path("./uploads")
|
|
||||||
prompt_dir: Path = Path("./prompts")
|
|
||||||
|
|
||||||
# --- worker reliability ---
|
|
||||||
worker_max_retries: int = 0
|
|
||||||
worker_retry_backoff_seconds: float = 0.0
|
|
||||||
worker_provider_timeout_seconds: float = Field(default=20.0, gt=0.0, le=20.0)
|
|
||||||
worker_min_transcription_chars: int = Field(default=0, ge=0)
|
|
||||||
worker_min_transcription_lines: int = Field(default=0, ge=0)
|
|
||||||
worker_fail_on_finish_reason_length: bool = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def should_bootstrap_schema(self) -> bool:
|
|
||||||
"""Return whether startup should auto-create schema for this environment."""
|
|
||||||
if "bootstrap_schema_on_startup" in self.model_fields_set:
|
|
||||||
return self.bootstrap_schema_on_startup
|
|
||||||
return self.environment in {"development", "test"}
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def get_settings(**kwargs: Any) -> Settings:
|
|
||||||
"""Load cached settings without reading process CLI arguments."""
|
|
||||||
return Settings(_cli_parse_args=False, **kwargs) # pyright: ignore[reportCallIssue]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cli_settings(args: Sequence[str] | None = None) -> Settings:
|
|
||||||
"""Load settings with CLI arguments at the executable boundary."""
|
|
||||||
cli_args = True if args is None else list(args)
|
|
||||||
return Settings(_cli_parse_args=cli_args) # pyright: ignore[reportCallIssue]
|
|
||||||
|
|
||||||
|
|
||||||
LOGGING_CONFIG: dict[str, Any] = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"formatters": {
|
|
||||||
"standard": {
|
|
||||||
"format": "%(asctime)s %(levelname)-8s | %(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"],
|
|
||||||
},
|
|
||||||
"loggers": {
|
|
||||||
"transcription": {
|
|
||||||
"level": "DEBUG",
|
|
||||||
"handlers": ["console"],
|
|
||||||
"propagate": False,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(settings: Settings | None = None) -> None:
|
|
||||||
"""Configure root logging once at startup."""
|
|
||||||
cfg = LOGGING_CONFIG.copy()
|
|
||||||
active_settings = settings or get_settings()
|
|
||||||
cfg["loggers"]["transcription"]["level"] = active_settings.log_level.upper()
|
|
||||||
logging.config.dictConfig(cfg)
|
|
||||||
logger.debug("Logging configured")
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from .operations import create_all
|
|
||||||
from .runtime import dispose_database_runtime
|
|
||||||
from .runtime import initialize_database_runtime
|
|
||||||
from .session import session_scope
|
|
||||||
from .session import transaction_scope
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"create_all",
|
|
||||||
"dispose_database_runtime",
|
|
||||||
"initialize_database_runtime",
|
|
||||||
"session_scope",
|
|
||||||
"transaction_scope",
|
|
||||||
]
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
from functools import cache
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import URL
|
|
||||||
from sqlalchemy import StaticPool
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from ..config import PostgresSettings
|
|
||||||
from ..config import Settings
|
|
||||||
from ..config import SqliteSettings
|
|
||||||
from ..config import get_settings
|
|
||||||
|
|
||||||
|
|
||||||
def get_database_url(settings: Settings) -> str:
|
|
||||||
match settings.database:
|
|
||||||
case SqliteSettings(path=path):
|
|
||||||
url = URL.create(
|
|
||||||
drivername="sqlite+aiosqlite",
|
|
||||||
database=path,
|
|
||||||
)
|
|
||||||
case PostgresSettings() as database:
|
|
||||||
url = URL.create(
|
|
||||||
drivername="postgresql+asyncpg",
|
|
||||||
host=database.host,
|
|
||||||
port=database.port,
|
|
||||||
database=database.database,
|
|
||||||
username=database.user,
|
|
||||||
password=database.password.get_secret_value(),
|
|
||||||
)
|
|
||||||
return url.render_as_string(hide_password=False)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_engine(settings: Settings | None = None) -> AsyncEngine:
|
|
||||||
active_settings = settings or get_settings()
|
|
||||||
return get_engine(get_database_url(active_settings))
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def get_engine(database_url: str) -> AsyncEngine:
|
|
||||||
kwargs: dict[str, Any] = {"echo": False, "pool_pre_ping": True}
|
|
||||||
if database_url.startswith("sqlite"):
|
|
||||||
kwargs["connect_args"] = {"check_same_thread": False}
|
|
||||||
if ":memory:" in database_url:
|
|
||||||
kwargs["poolclass"] = StaticPool
|
|
||||||
|
|
||||||
return create_async_engine(database_url, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
async def dispose_engine(database_url: str) -> None:
|
|
||||||
engine = get_engine(database_url)
|
|
||||||
try:
|
|
||||||
await engine.dispose()
|
|
||||||
finally:
|
|
||||||
get_engine.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_engine(database_url: str) -> AsyncEngine:
|
|
||||||
await dispose_engine(database_url)
|
|
||||||
return get_engine(database_url)
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""SQLModel domain models for the V2 transcription system."""
|
|
||||||
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import date
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from typing import Any
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlalchemy import Column
|
|
||||||
from sqlalchemy import JSON
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm.exc import DetachedInstanceError
|
|
||||||
from sqlalchemy.types import TypeDecorator
|
|
||||||
from sqlmodel import Field
|
|
||||||
from sqlmodel import Relationship
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
|
|
||||||
class JSONBCompat(TypeDecorator):
|
|
||||||
"""JSONB for PostgreSQL and JSON for SQLite/testing backends."""
|
|
||||||
|
|
||||||
impl = JSON
|
|
||||||
|
|
||||||
def load_dialect_impl(self, dialect):
|
|
||||||
if dialect.name == "postgresql":
|
|
||||||
return dialect.type_descriptor(JSONB())
|
|
||||||
return dialect.type_descriptor(JSON())
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(StrEnum):
|
|
||||||
QUEUED = "queued"
|
|
||||||
PROCESSING = "processing"
|
|
||||||
TRANSCRIBED = "transcribed"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
PARTIAL_SUCCESS = "partial_success"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentPersonRole(StrEnum):
|
|
||||||
AUTHOR = "author"
|
|
||||||
RECIPIENT = "recipient"
|
|
||||||
|
|
||||||
|
|
||||||
class JobSourceStatus(StrEnum):
|
|
||||||
PENDING = "pending"
|
|
||||||
TRANSCRIBED = "transcribed"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class Document(SQLModel, table=True):
|
|
||||||
"""An historical document."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
name: str
|
|
||||||
document_type: str | None = None
|
|
||||||
document_date: date | None = None
|
|
||||||
document_date_raw: str | None = None
|
|
||||||
location_created: str | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
archive_identifier: str | None = None
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
document_people: list["DocumentPerson"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
|
|
||||||
|
|
||||||
class Person(SQLModel, table=True):
|
|
||||||
"""A historical person linked to one or more documents."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
full_name: str
|
|
||||||
display_name: str | None = None
|
|
||||||
maiden_name: str | None = None
|
|
||||||
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
|
|
||||||
biography: str | None = None
|
|
||||||
portrait_path: str | None = None
|
|
||||||
metadata_: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
sa_column=Column("metadata", JSONBCompat(), nullable=True),
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
document_people: list["DocumentPerson"] = Relationship(back_populates="person", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentPerson(SQLModel, table=True):
|
|
||||||
"""Associates documents with people in a given role."""
|
|
||||||
|
|
||||||
__tablename__ = "document_person"
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
document_id: UUID = Field(foreign_key="document.id")
|
|
||||||
person_id: UUID = Field(foreign_key="person.id")
|
|
||||||
role: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR)
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),
|
|
||||||
)
|
|
||||||
|
|
||||||
document: Optional["Document"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
person: Optional["Person"] = Relationship(back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
|
|
||||||
|
|
||||||
class Job(SQLModel, table=True):
|
|
||||||
"""A transcription job tied to a single document."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
document_id: UUID = Field(foreign_key="document.id")
|
|
||||||
status: JobStatus = Field(default=JobStatus.QUEUED)
|
|
||||||
retry_count: int = Field(default=0, ge=0)
|
|
||||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
provider: str | None = None
|
|
||||||
model: str | None = None
|
|
||||||
prompt_name: str | None = None
|
|
||||||
|
|
||||||
document: Optional["Document"] = Relationship(back_populates="jobs", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
job_sources: list["JobSource"] = Relationship(back_populates="job", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filename(self) -> str:
|
|
||||||
"""Return the filename of the associated source, when available."""
|
|
||||||
if not self.job_sources:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
for job_source in self.job_sources:
|
|
||||||
source = job_source.__dict__.get("source")
|
|
||||||
if source is None:
|
|
||||||
try:
|
|
||||||
source = job_source.source
|
|
||||||
except DetachedInstanceError:
|
|
||||||
source = None
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
source = None
|
|
||||||
|
|
||||||
if source is not None:
|
|
||||||
return source.filename
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def error_detail(self) -> str | None:
|
|
||||||
"""Return the first available source-level error detail for the job."""
|
|
||||||
if not self.job_sources:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for job_source in self.job_sources:
|
|
||||||
if job_source.error_detail:
|
|
||||||
return job_source.error_detail
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Source(SQLModel, table=True):
|
|
||||||
"""A document source image or PDF page."""
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
document_id: UUID = Field(foreign_key="document.id")
|
|
||||||
page_number: int = Field(default=1, ge=1)
|
|
||||||
upload_name: str
|
|
||||||
filename: str
|
|
||||||
file_path: str
|
|
||||||
raw_transcription: str | None = None
|
|
||||||
revised_text: str | None = None
|
|
||||||
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
date_revised: datetime | None = None
|
|
||||||
|
|
||||||
document: Optional["Document"] = Relationship(back_populates="sources", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
job_sources: list["JobSource"] = Relationship(back_populates="source", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
|
|
||||||
|
|
||||||
class JobSource(SQLModel, table=True):
|
|
||||||
"""A single AI execution record for one source page."""
|
|
||||||
|
|
||||||
__tablename__ = "job_source"
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
|
||||||
job_id: UUID = Field(foreign_key="job.id")
|
|
||||||
source_id: UUID = Field(foreign_key="source.id")
|
|
||||||
status: JobSourceStatus = Field(default=JobSourceStatus.PENDING)
|
|
||||||
raw_transcription: str | None = None
|
|
||||||
ai_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
|
||||||
raw_api_response: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
|
||||||
error_detail: str | None = None
|
|
||||||
executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
job: Optional["Job"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
source: Optional["Source"] = Relationship(back_populates="job_sources", sa_relationship_kwargs={"lazy": "selectin"})
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from .engine import resolve_engine
|
|
||||||
from .models import Job
|
|
||||||
from .models import JobStatus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
|
||||||
"""Create any missing tables on the selected engine."""
|
|
||||||
# Import models so SQLModel metadata is fully registered before bootstrap.
|
|
||||||
from transcription.db import models as _models # noqa: F401
|
|
||||||
|
|
||||||
active_engine = engine or resolve_engine()
|
|
||||||
async with active_engine.begin() as connection:
|
|
||||||
await connection.run_sync(SQLModel.metadata.create_all)
|
|
||||||
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
|
||||||
"""Get the next queued job, if any."""
|
|
||||||
result = await session.exec(
|
|
||||||
select(Job)
|
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
|
||||||
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
|
|
||||||
.limit(1)
|
|
||||||
) # fmt: skip
|
|
||||||
return result.first()
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import logging
|
|
||||||
from contextvars import ContextVar
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from ..config import Settings
|
|
||||||
from ..config import get_settings
|
|
||||||
from .engine import get_database_url
|
|
||||||
from .engine import get_engine
|
|
||||||
from .session import get_session_factory
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DatabaseRuntime:
|
|
||||||
"""Database runtime resources owned by app lifespan."""
|
|
||||||
|
|
||||||
engine: AsyncEngine
|
|
||||||
session_factory: async_sessionmaker[AsyncSession]
|
|
||||||
|
|
||||||
|
|
||||||
_runtime: ContextVar[DatabaseRuntime | None] = ContextVar("database_runtime", default=None)
|
|
||||||
|
|
||||||
|
|
||||||
async def dispose_database_runtime() -> None:
|
|
||||||
"""Dispose lifespan-owned async database resources."""
|
|
||||||
runtime = _runtime.get()
|
|
||||||
if runtime is None:
|
|
||||||
return
|
|
||||||
await runtime.engine.dispose()
|
|
||||||
_runtime.set(None)
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_database_runtime(*, settings: Settings | None = None) -> DatabaseRuntime:
|
|
||||||
"""Initialize lifespan-owned async DB resources once per process."""
|
|
||||||
runtime = _runtime.get()
|
|
||||||
if runtime is not None:
|
|
||||||
return runtime
|
|
||||||
|
|
||||||
active_settings = settings or get_settings()
|
|
||||||
database_url = get_database_url(active_settings)
|
|
||||||
engine = get_engine(database_url)
|
|
||||||
session_factory = get_session_factory(database_url)
|
|
||||||
runtime = DatabaseRuntime(engine=engine, session_factory=session_factory)
|
|
||||||
_runtime.set(runtime)
|
|
||||||
logger.debug("Initialized async database runtime for database_url=%s", engine.url)
|
|
||||||
return runtime
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
from collections.abc import AsyncGenerator
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from functools import cache
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSessionTransaction
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from ..config import Settings
|
|
||||||
from ..config import get_settings
|
|
||||||
from .engine import dispose_engine
|
|
||||||
from .engine import get_database_url
|
|
||||||
from .engine import get_engine
|
|
||||||
|
|
||||||
type SessionFactory = async_sessionmaker[AsyncSession]
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def get_session_factory(database_url: str) -> SessionFactory:
|
|
||||||
return async_sessionmaker(
|
|
||||||
bind=get_engine(database_url),
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_session_factory(
|
|
||||||
database_url: str | None = None,
|
|
||||||
*,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
) -> SessionFactory:
|
|
||||||
if database_url is not None:
|
|
||||||
return get_session_factory(database_url)
|
|
||||||
return get_session_factory(get_database_url(settings or get_settings()))
|
|
||||||
|
|
||||||
|
|
||||||
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
|
|
||||||
|
|
||||||
|
|
||||||
async def dispose_session_factory(database_url: str) -> None:
|
|
||||||
get_session_factory.cache_clear()
|
|
||||||
await dispose_engine(database_url)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def session_scope(
|
|
||||||
*,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
database_url: str | None = None,
|
|
||||||
session_factory: SessionFactory | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> AsyncGenerator[AsyncSession]:
|
|
||||||
if session is not None:
|
|
||||||
yield session
|
|
||||||
return
|
|
||||||
|
|
||||||
active_session_factory = session_factory or resolve_session_factory(
|
|
||||||
database_url,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
async with active_session_factory() as owned_session:
|
|
||||||
yield owned_session
|
|
||||||
|
|
||||||
|
|
||||||
type SessionScopeDep = Annotated[AsyncSession, Depends(session_scope)]
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def transaction_scope(
|
|
||||||
*,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
database_url: str | None = None,
|
|
||||||
session_factory: SessionFactory | None = None,
|
|
||||||
session: AsyncSession | AsyncSessionTransaction | None = None,
|
|
||||||
) -> AsyncGenerator[AsyncSession | AsyncSessionTransaction]:
|
|
||||||
match session:
|
|
||||||
case AsyncSession() as async_session:
|
|
||||||
if not async_session.in_transaction():
|
|
||||||
raise RuntimeError("A supplied session must have an active transaction")
|
|
||||||
yield async_session
|
|
||||||
return
|
|
||||||
case AsyncSessionTransaction() as async_transaction:
|
|
||||||
yield async_transaction
|
|
||||||
return
|
|
||||||
|
|
||||||
active_session_factory = session_factory or resolve_session_factory(
|
|
||||||
database_url,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
async with active_session_factory.begin() as owned_session:
|
|
||||||
yield owned_session
|
|
||||||
|
|
||||||
|
|
||||||
type TransactionScopeDep = Annotated[
|
|
||||||
AsyncSession | AsyncSessionTransaction,
|
|
||||||
Depends(transaction_scope),
|
|
||||||
]
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""Shared error taxonomy and helpers for runtime boundaries."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorCategory(StrEnum):
|
|
||||||
"""Stable error categories defined by docs/error_handling.md."""
|
|
||||||
|
|
||||||
VALIDATION = "validation_error"
|
|
||||||
USER_INPUT = "user_input_error"
|
|
||||||
NOT_FOUND = "not_found_error"
|
|
||||||
CONFLICT = "conflict_error"
|
|
||||||
EXTERNAL_PROVIDER = "external_provider_error"
|
|
||||||
PROCESSING = "processing_error"
|
|
||||||
INFRA_TRANSIENT = "infrastructure_transient_error"
|
|
||||||
INFRA_PERSISTENT = "infrastructure_persistent_error"
|
|
||||||
INTERNAL_UNEXPECTED = "internal_unexpected_error"
|
|
||||||
|
|
||||||
|
|
||||||
def new_error_id() -> str:
|
|
||||||
"""Return a short, user-shareable error reference id."""
|
|
||||||
return uuid4().hex[:8]
|
|
||||||
|
|
||||||
|
|
||||||
class AppError(RuntimeError):
|
|
||||||
"""Base application error carrying user-safe handling metadata."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
category: ErrorCategory = ErrorCategory.INTERNAL_UNEXPECTED,
|
|
||||||
suggestion: str = "Retry once. If it persists, review logs and report the error reference id.",
|
|
||||||
retriable: bool = False,
|
|
||||||
error_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
self.category = category
|
|
||||||
self.suggestion = suggestion
|
|
||||||
self.retriable = retriable
|
|
||||||
self.error_id = error_id or new_error_id()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ErrorEnvelope:
|
|
||||||
"""Serializable API/UI error payload."""
|
|
||||||
|
|
||||||
error_id: str
|
|
||||||
category: str
|
|
||||||
message: str
|
|
||||||
suggestion: str
|
|
||||||
timestamp: str
|
|
||||||
|
|
||||||
|
|
||||||
def build_error_envelope(error: AppError) -> ErrorEnvelope:
|
|
||||||
"""Build an API-safe response envelope from an AppError."""
|
|
||||||
return ErrorEnvelope(
|
|
||||||
error_id=error.error_id,
|
|
||||||
category=error.category.value,
|
|
||||||
message=error.message,
|
|
||||||
suggestion=error.suggestion,
|
|
||||||
timestamp=datetime.now(UTC).isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def classify_unexpected_error(exc: Exception, *, operation: str) -> AppError:
|
|
||||||
"""Normalize unknown exceptions into internal_unexpected_error."""
|
|
||||||
return AppError(
|
|
||||||
f"Unexpected error during {operation}: {exc}",
|
|
||||||
category=ErrorCategory.INTERNAL_UNEXPECTED,
|
|
||||||
suggestion="Retry once. If it persists, review logs and report the error reference id.",
|
|
||||||
retriable=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def format_error_detail(error: AppError) -> str:
|
|
||||||
"""Return a compact persisted failure string for transcript.error_detail."""
|
|
||||||
return f"[{error.category.value}] {error.message} | suggestion={error.suggestion} | error_id={error.error_id}"
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""Provider exports and factory for transcription adapters."""
|
|
||||||
|
|
||||||
from transcription.config import Provider
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.providers.base import ProviderAuthError
|
|
||||||
from transcription.providers.base import ProviderError
|
|
||||||
from transcription.providers.base import ProviderResponseError
|
|
||||||
from transcription.providers.base import TranscriptionProvider
|
|
||||||
from transcription.providers.base import TranscriptionResult
|
|
||||||
from transcription.providers.openrouter import OpenRouterTranscriptionProvider
|
|
||||||
|
|
||||||
|
|
||||||
def get_transcription_provider(*, settings: Settings | None = None) -> TranscriptionProvider:
|
|
||||||
"""Return the configured transcription provider adapter."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
if runtime_settings.provider == Provider.OPENROUTER:
|
|
||||||
return OpenRouterTranscriptionProvider(settings=runtime_settings)
|
|
||||||
|
|
||||||
raise ProviderError(f"Unsupported transcription provider: {runtime_settings.provider}")
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"OpenRouterTranscriptionProvider",
|
|
||||||
"ProviderAuthError",
|
|
||||||
"ProviderError",
|
|
||||||
"ProviderResponseError",
|
|
||||||
"TranscriptionProvider",
|
|
||||||
"TranscriptionResult",
|
|
||||||
"get_transcription_provider",
|
|
||||||
]
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Provider interfaces and shared types for transcription adapters."""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderError(RuntimeError):
|
|
||||||
"""Base error for provider failures."""
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderAuthError(ProviderError):
|
|
||||||
"""Raised when provider authentication fails."""
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderResponseError(ProviderError):
|
|
||||||
"""Raised when provider responses are malformed or unusable."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TranscriptionResult:
|
|
||||||
"""Normalized output returned by any transcription provider."""
|
|
||||||
|
|
||||||
text: str
|
|
||||||
provider: str
|
|
||||||
prompt_name: str
|
|
||||||
model: str
|
|
||||||
finish_reason: str | None = None
|
|
||||||
usage_input_tokens: int | None = None
|
|
||||||
usage_output_tokens: int | None = None
|
|
||||||
usage_total_tokens: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionProvider(Protocol):
|
|
||||||
"""Contract every transcription provider adapter must satisfy."""
|
|
||||||
|
|
||||||
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
|
||||||
"""Transcribe the provided image according to the prompt text."""
|
|
||||||
...
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""OpenRouter transcription provider adapter."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from openrouter import OpenRouter
|
|
||||||
from openrouter.components.chatmessages import ChatMessagesTypedDict
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.providers.base import ProviderAuthError
|
|
||||||
from transcription.providers.base import ProviderError
|
|
||||||
from transcription.providers.base import ProviderResponseError
|
|
||||||
from transcription.providers.base import TranscriptionResult
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DEFAULT_OPENROUTER_MODEL = "google/gemini-2.5-flash"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class OpenRouterRequest:
|
|
||||||
"""Normalized request payload fields for OpenRouter calls."""
|
|
||||||
|
|
||||||
model: str
|
|
||||||
messages: list[dict[str, Any]]
|
|
||||||
http_referer: str | None
|
|
||||||
x_open_router_title: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class OpenRouterTranscriptionProvider:
|
|
||||||
"""Adapter that performs image transcription through OpenRouter."""
|
|
||||||
|
|
||||||
def __init__(self, *, settings: Settings | None = None, client: OpenRouter | None = None):
|
|
||||||
self._settings = settings or get_settings()
|
|
||||||
self._model = self._settings.provider_model or DEFAULT_OPENROUTER_MODEL
|
|
||||||
self._client = client or OpenRouter(api_key=self._settings.openrouter_api_key)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model(self) -> str:
|
|
||||||
"""Return the resolved OpenRouter model slug."""
|
|
||||||
return self._model
|
|
||||||
|
|
||||||
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
|
||||||
"""Send prompt + image to OpenRouter and return normalized text output."""
|
|
||||||
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
|
|
||||||
try:
|
|
||||||
response = await self._client.chat.send_async(
|
|
||||||
messages=cast(list[ChatMessagesTypedDict], request.messages),
|
|
||||||
model=request.model,
|
|
||||||
http_referer=request.http_referer,
|
|
||||||
x_open_router_title=request.x_open_router_title,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
message = str(exc).lower()
|
|
||||||
if "401" in message or "auth" in message or "api key" in message:
|
|
||||||
raise ProviderAuthError("OpenRouter authentication failed") from exc
|
|
||||||
raise ProviderError("OpenRouter request failed") from exc
|
|
||||||
|
|
||||||
text = self._extract_text(response)
|
|
||||||
model = self._get_optional_attr(response, "model") or self.model
|
|
||||||
finish_reason = self._extract_finish_reason(response)
|
|
||||||
usage_input_tokens, usage_output_tokens, usage_total_tokens = self._extract_usage(response)
|
|
||||||
logger.info("OpenRouter transcription completed using model=%s", model)
|
|
||||||
return TranscriptionResult(
|
|
||||||
text=text,
|
|
||||||
provider="openrouter",
|
|
||||||
prompt_name="",
|
|
||||||
model=model,
|
|
||||||
finish_reason=finish_reason,
|
|
||||||
usage_input_tokens=usage_input_tokens,
|
|
||||||
usage_output_tokens=usage_output_tokens,
|
|
||||||
usage_total_tokens=usage_total_tokens,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
|
|
||||||
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
|
||||||
data_url = f"data:{mime_type};base64,{image_b64}"
|
|
||||||
|
|
||||||
messages: list[dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{"type": "text", "text": prompt_text},
|
|
||||||
{"type": "image_url", "image_url": {"url": data_url}},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return OpenRouterRequest(
|
|
||||||
model=self.model,
|
|
||||||
messages=messages,
|
|
||||||
http_referer=self._settings.openrouter_http_referer,
|
|
||||||
x_open_router_title=self._settings.openrouter_app_title,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _extract_text(self, response: Any) -> str:
|
|
||||||
choices = self._get_optional_attr(response, "choices")
|
|
||||||
if not choices:
|
|
||||||
raise ProviderResponseError("OpenRouter response missing choices")
|
|
||||||
|
|
||||||
first_choice = choices[0]
|
|
||||||
message = self._get_optional_attr(first_choice, "message")
|
|
||||||
if message is None:
|
|
||||||
raise ProviderResponseError("OpenRouter response missing assistant message")
|
|
||||||
|
|
||||||
content = self._get_optional_attr(message, "content")
|
|
||||||
text = self._normalize_content(content)
|
|
||||||
if not text:
|
|
||||||
raise ProviderResponseError("OpenRouter response contained no transcription text")
|
|
||||||
return text
|
|
||||||
|
|
||||||
def _extract_finish_reason(self, response: Any) -> str | None:
|
|
||||||
choices = self._get_optional_attr(response, "choices")
|
|
||||||
if not choices:
|
|
||||||
return None
|
|
||||||
first_choice = choices[0]
|
|
||||||
finish_reason = self._get_optional_attr(first_choice, "finish_reason")
|
|
||||||
if isinstance(finish_reason, str) and finish_reason.strip():
|
|
||||||
return finish_reason.strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_usage(self, response: Any) -> tuple[int | None, int | None, int | None]:
|
|
||||||
usage = self._get_optional_attr(response, "usage")
|
|
||||||
if usage is None:
|
|
||||||
return None, None, None
|
|
||||||
|
|
||||||
input_tokens = self._as_int(self._get_optional_attr(usage, "prompt_tokens"))
|
|
||||||
output_tokens = self._as_int(self._get_optional_attr(usage, "completion_tokens"))
|
|
||||||
total_tokens = self._as_int(self._get_optional_attr(usage, "total_tokens"))
|
|
||||||
|
|
||||||
if input_tokens is None:
|
|
||||||
input_tokens = self._as_int(self._get_optional_attr(usage, "input_tokens"))
|
|
||||||
if output_tokens is None:
|
|
||||||
output_tokens = self._as_int(self._get_optional_attr(usage, "output_tokens"))
|
|
||||||
if total_tokens is None:
|
|
||||||
total_tokens = self._as_int(self._get_optional_attr(usage, "total"))
|
|
||||||
|
|
||||||
return input_tokens, output_tokens, total_tokens
|
|
||||||
|
|
||||||
def _normalize_content(self, content: Any) -> str:
|
|
||||||
if isinstance(content, str):
|
|
||||||
return content.strip()
|
|
||||||
|
|
||||||
if isinstance(content, list):
|
|
||||||
parts: list[str] = []
|
|
||||||
for item in content:
|
|
||||||
text_part = None
|
|
||||||
text_part = item.get("text") if isinstance(item, dict) else self._get_optional_attr(item, "text")
|
|
||||||
|
|
||||||
if isinstance(text_part, str) and text_part.strip():
|
|
||||||
parts.append(text_part.strip())
|
|
||||||
return "\n".join(parts).strip()
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_optional_attr(obj: Any, key: str) -> Any:
|
|
||||||
if obj is None:
|
|
||||||
return None
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return obj.get(key)
|
|
||||||
return getattr(obj, key, None)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _as_int(value: Any) -> int | None:
|
|
||||||
if isinstance(value, int):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Service layer exports."""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from dataclasses import field
|
|
||||||
|
|
||||||
from .documents import DocumentService
|
|
||||||
from .jobs import JobService
|
|
||||||
from .transcription import TranscriptionService
|
|
||||||
|
|
||||||
__all__ = ["DocumentService", "JobService", "ServiceBundle", "TranscriptionService"]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ServiceBundle:
|
|
||||||
"""Container for all service instances."""
|
|
||||||
|
|
||||||
documents: DocumentService = field(default_factory=DocumentService)
|
|
||||||
jobs: JobService = field(default_factory=JobService)
|
|
||||||
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from abc import ABC
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from ..config import Settings
|
|
||||||
from ..config import get_settings
|
|
||||||
from ..db.session import resolve_session_factory
|
|
||||||
from ..db.session import session_scope
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceBase(ABC):
|
|
||||||
"""Thin service class for managing documents in the database."""
|
|
||||||
|
|
||||||
settings: Settings
|
|
||||||
session_factory: async_sessionmaker[AsyncSession]
|
|
||||||
queue: asyncio.Queue
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
|
||||||
queue: asyncio.Queue | None = None,
|
|
||||||
):
|
|
||||||
self.settings = get_settings()
|
|
||||||
self.session_factory = session_factory or resolve_session_factory()
|
|
||||||
self.queue = queue or asyncio.Queue()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _session_scope(self, session: AsyncSession | None = None):
|
|
||||||
"""Provide a transactional scope around a series of operations."""
|
|
||||||
async with session_scope(
|
|
||||||
session_factory=self.session_factory,
|
|
||||||
session=session,
|
|
||||||
) as active_session:
|
|
||||||
yield active_session
|
|
||||||
|
|
||||||
async def _finalize(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
caller_session: AsyncSession | None,
|
|
||||||
refresh: Sequence[object] = (),
|
|
||||||
) -> None:
|
|
||||||
"""Finalize a write based on transaction ownership.
|
|
||||||
|
|
||||||
Service-owned sessions commit immediately. Caller-owned sessions flush so
|
|
||||||
orchestration code can commit once at a larger transaction boundary.
|
|
||||||
"""
|
|
||||||
should_commit = caller_session is None
|
|
||||||
if should_commit:
|
|
||||||
await session.commit()
|
|
||||||
else:
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
for obj in refresh:
|
|
||||||
await session.refresh(obj)
|
|
||||||
@@ -1,359 +0,0 @@
|
|||||||
import logging
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from ..db.models import Document
|
|
||||||
from ..db.models import DocumentPerson
|
|
||||||
from ..db.models import Person
|
|
||||||
from ..errors import AppError
|
|
||||||
from ..errors import ErrorCategory
|
|
||||||
from .base import ServiceBase
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentError(AppError):
|
|
||||||
"""Raised when document operations fail."""
|
|
||||||
|
|
||||||
|
|
||||||
class MissingSourceError(DocumentError):
|
|
||||||
"""Raised when a document has no associated sources."""
|
|
||||||
|
|
||||||
|
|
||||||
class UploadError(DocumentError):
|
|
||||||
"""Raised when uploaded content cannot be persisted safely."""
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentAlreadyExistsError(DocumentError):
|
|
||||||
"""Raised when a document with the same name already exists in the database."""
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentDeleteBlockedError(DocumentError):
|
|
||||||
"""Raised when a document delete is blocked by dependent records."""
|
|
||||||
|
|
||||||
|
|
||||||
class PersonDeleteBlockedError(DocumentError):
|
|
||||||
"""Raised when a person delete is blocked by linked documents."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class UploadJobResult:
|
|
||||||
"""Summary of created upload records."""
|
|
||||||
|
|
||||||
document_id: UUID
|
|
||||||
job_id: UUID
|
|
||||||
stored_path: Path
|
|
||||||
original_filename: str
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentService(ServiceBase):
|
|
||||||
"""Thin service class for managing documents in the database."""
|
|
||||||
|
|
||||||
#
|
|
||||||
# CRUD Operations
|
|
||||||
#
|
|
||||||
|
|
||||||
async def create_document(
|
|
||||||
self,
|
|
||||||
document: Document,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Document:
|
|
||||||
"""Create a new document in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
_session.add(document)
|
|
||||||
try:
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
|
||||||
except IntegrityError as exc:
|
|
||||||
raise DocumentAlreadyExistsError(
|
|
||||||
f"Document with id {document.id} already exists",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Rename the file and try again.",
|
|
||||||
) from exc
|
|
||||||
return document
|
|
||||||
|
|
||||||
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
|
||||||
"""Read an existing document from the database.
|
|
||||||
|
|
||||||
The selectinload option is used to eagerly load related jobs and sources.
|
|
||||||
"""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
document = await _session.get(
|
|
||||||
Document,
|
|
||||||
document_id,
|
|
||||||
options=(
|
|
||||||
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if document is None:
|
|
||||||
raise DocumentError(
|
|
||||||
f"Document with id {document_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Re-upload the source document and retry.",
|
|
||||||
)
|
|
||||||
elif not document.sources:
|
|
||||||
raise MissingSourceError(
|
|
||||||
f"Document with id {document_id} has no associated source records",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Upload at least one source for this document and retry.",
|
|
||||||
)
|
|
||||||
return document
|
|
||||||
|
|
||||||
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
|
||||||
"""Update an existing document in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
document.updated_at = datetime.now(UTC)
|
|
||||||
merged = await _session.merge(document)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a document from the database."""
|
|
||||||
document_id = document.id
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
existing = await _session.get(
|
|
||||||
Document,
|
|
||||||
document.id,
|
|
||||||
options=(
|
|
||||||
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if existing is None:
|
|
||||||
raise DocumentError(
|
|
||||||
f"Document with id {document.id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the document id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
has_jobs = bool(existing.jobs)
|
|
||||||
has_sources = bool(existing.sources)
|
|
||||||
if has_jobs or has_sources:
|
|
||||||
blocked_by: list[str] = []
|
|
||||||
if has_sources:
|
|
||||||
blocked_by.append("Sources")
|
|
||||||
if has_jobs:
|
|
||||||
blocked_by.append("Jobs")
|
|
||||||
raise DocumentDeleteBlockedError(
|
|
||||||
f"Document delete blocked by related records: {', '.join(blocked_by)}",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Remove related Sources and Jobs first, then retry deletion.",
|
|
||||||
)
|
|
||||||
|
|
||||||
await _session.delete(existing)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
self._delete_document_storage_folder(document_id=document_id)
|
|
||||||
|
|
||||||
def _delete_document_storage_folder(self, *, document_id: UUID) -> None:
|
|
||||||
"""Best-effort cleanup for document-scoped source storage."""
|
|
||||||
document_dir = self.settings.upload_dir / "documents" / str(document_id)
|
|
||||||
if not document_dir.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
shutil.rmtree(document_dir)
|
|
||||||
logger.info("Deleted document storage folder: %s", document_dir)
|
|
||||||
except OSError:
|
|
||||||
logger.warning("Failed to delete document storage folder: %s", document_dir)
|
|
||||||
|
|
||||||
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
|
||||||
"""Create a new person in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
_session.add(person)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(person,))
|
|
||||||
return person
|
|
||||||
|
|
||||||
async def read_person(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
|
||||||
"""Read an existing person from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
person = await _session.get(Person, person_id)
|
|
||||||
if person is None:
|
|
||||||
raise DocumentError(
|
|
||||||
f"Person with id {person_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the person id and retry.",
|
|
||||||
)
|
|
||||||
return person
|
|
||||||
|
|
||||||
async def read_person_detail(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
|
|
||||||
"""Read a person with eagerly loaded document links for UI detail rendering."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Person)
|
|
||||||
.options(
|
|
||||||
selectinload(Person.document_people).selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
.where(Person.id == person_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
person = (await _session.exec(query)).first()
|
|
||||||
|
|
||||||
if person is None:
|
|
||||||
raise DocumentError(
|
|
||||||
f"Person with id {person_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the person id and retry.",
|
|
||||||
)
|
|
||||||
return person
|
|
||||||
|
|
||||||
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
|
|
||||||
"""Update an existing person in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
person.updated_at = datetime.now(UTC)
|
|
||||||
merged = await _session.merge(person)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a person from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
existing = await _session.get(
|
|
||||||
Person,
|
|
||||||
person.id,
|
|
||||||
options=(
|
|
||||||
selectinload(Person.document_people), # pyright: ignore[reportArgumentType]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if existing is None:
|
|
||||||
raise DocumentError(
|
|
||||||
f"Person with id {person.id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the person id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
for link in list(existing.document_people):
|
|
||||||
await _session.delete(link)
|
|
||||||
|
|
||||||
await _session.delete(existing)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
async def create_document_person(
|
|
||||||
self,
|
|
||||||
document_person: DocumentPerson,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> DocumentPerson:
|
|
||||||
"""Create a document-person association in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
_session.add(document_person)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(document_person,))
|
|
||||||
return document_person
|
|
||||||
|
|
||||||
async def read_document_person(
|
|
||||||
self,
|
|
||||||
document_person_id: UUID,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> DocumentPerson:
|
|
||||||
"""Read an existing document-person association from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
document_person = await _session.get(DocumentPerson, document_person_id)
|
|
||||||
if document_person is None:
|
|
||||||
raise DocumentError(
|
|
||||||
f"DocumentPerson with id {document_person_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the document-person id and retry.",
|
|
||||||
)
|
|
||||||
return document_person
|
|
||||||
|
|
||||||
async def update_document_person(
|
|
||||||
self,
|
|
||||||
document_person: DocumentPerson,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> DocumentPerson:
|
|
||||||
"""Update an existing document-person association in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
merged = await _session.merge(document_person)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_document_person(
|
|
||||||
self,
|
|
||||||
document_person: DocumentPerson,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Delete a document-person association from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
await _session.delete(document_person)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
# Query Operations
|
|
||||||
|
|
||||||
async def query_documents(
|
|
||||||
self, *, name: str | None = None, session: AsyncSession | None = None
|
|
||||||
) -> Sequence[Document]:
|
|
||||||
"""Query documents from the database based on provided filters."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(Document)
|
|
||||||
if name is not None:
|
|
||||||
query = query.where(Document.name == name)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
|
|
||||||
"""List all documents in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
result = await _session.exec(select(Document))
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
|
||||||
"""Read a document with eagerly loaded relations for UI detail rendering."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Document)
|
|
||||||
.options(
|
|
||||||
selectinload(Document.jobs), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Document.sources), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
.where(Document.id == document_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
document = (await _session.exec(query)).first()
|
|
||||||
if document is None:
|
|
||||||
raise DocumentError(
|
|
||||||
f"Document with id {document_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the document id and retry.",
|
|
||||||
)
|
|
||||||
return document
|
|
||||||
|
|
||||||
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
|
|
||||||
"""List all people in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
result = await _session.exec(select(Person))
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def list_document_people(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
document_id: UUID | None = None,
|
|
||||||
person_id: UUID | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[DocumentPerson]:
|
|
||||||
"""List document-person associations, optionally filtered by document or person."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(DocumentPerson).options(
|
|
||||||
selectinload(DocumentPerson.document), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
if document_id is not None:
|
|
||||||
query = query.where(DocumentPerson.document_id == document_id)
|
|
||||||
if person_id is not None:
|
|
||||||
query = query.where(DocumentPerson.person_id == person_id)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
from collections.abc import Sequence
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from ..errors import AppError
|
|
||||||
from ..errors import ErrorCategory
|
|
||||||
from ..db.models import Job
|
|
||||||
from ..db.models import JobSource
|
|
||||||
from ..db.models import JobSourceStatus
|
|
||||||
from ..db.models import JobStatus
|
|
||||||
from ..db.models import Source
|
|
||||||
from .base import ServiceBase
|
|
||||||
|
|
||||||
|
|
||||||
class JobDeleteBlockedError(AppError):
|
|
||||||
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
|
||||||
|
|
||||||
|
|
||||||
class JobCancelBlockedError(AppError):
|
|
||||||
"""Raised when a job cancel operation is blocked by lifecycle policy."""
|
|
||||||
|
|
||||||
|
|
||||||
class JobResubmitBlockedError(AppError):
|
|
||||||
"""Raised when a job resubmit operation is blocked by lifecycle policy."""
|
|
||||||
|
|
||||||
|
|
||||||
class JobService(ServiceBase):
|
|
||||||
"""Thin service class for managing jobs in the database."""
|
|
||||||
|
|
||||||
#
|
|
||||||
# CRUD Operations
|
|
||||||
#
|
|
||||||
|
|
||||||
async def create_job(self, job: Job, session: AsyncSession | None = None) -> Job:
|
|
||||||
"""Create a new job in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
_session.add(job)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
|
||||||
return job
|
|
||||||
|
|
||||||
async def read_job(self, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
|
||||||
"""Read an existing job from the database.
|
|
||||||
|
|
||||||
The related document is always eagerly loaded so callers can safely
|
|
||||||
access ``job.document`` in async contexts without triggering lazy-load IO.
|
|
||||||
"""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Job)
|
|
||||||
.options(
|
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
.where(Job.id == job_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
job = (await _session.exec(query)).first()
|
|
||||||
if job is None:
|
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
|
||||||
return job
|
|
||||||
|
|
||||||
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
|
|
||||||
"""Update an existing job in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
merged = await _session.merge(job)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_job(self, job: Job, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a job from the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
await _session.delete(job)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
# Query Operations
|
|
||||||
|
|
||||||
async def query_jobs(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
status: JobStatus | None = None,
|
|
||||||
filename: str | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[Job]:
|
|
||||||
"""Query jobs from the database based on provided filters."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(Job).options(
|
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
if status is not None:
|
|
||||||
query = query.where(Job.status == status)
|
|
||||||
if filename is not None:
|
|
||||||
query = query.where(Job.job_sources.any(JobSource.source.has(Source.filename == filename)))
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def list_jobs(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
load_docs: bool = False,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[Job]:
|
|
||||||
"""List all jobs in the database with eagerly loaded documents."""
|
|
||||||
_ = load_docs
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(Job).options(
|
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
# Other Operations
|
|
||||||
|
|
||||||
async def mark_job_status(
|
|
||||||
self,
|
|
||||||
job_id: UUID,
|
|
||||||
status: JobStatus,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Mark a job with a new status."""
|
|
||||||
return await self.update_job_state(job_id=job_id, status=status, session=session)
|
|
||||||
|
|
||||||
async def update_job_state(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
job_id: UUID,
|
|
||||||
status: JobStatus,
|
|
||||||
retry_count_increment: int = 0,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Update a job's lifecycle fields.
|
|
||||||
|
|
||||||
When ``session`` is provided, this method flushes so callers can commit
|
|
||||||
once at an orchestration boundary.
|
|
||||||
"""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Job)
|
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
|
||||||
.where(Job.id == job_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
job = (await _session.exec(query)).first()
|
|
||||||
if job is None:
|
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
|
||||||
job.status = status
|
|
||||||
if retry_count_increment:
|
|
||||||
job.retry_count += retry_count_increment
|
|
||||||
job.date_updated = datetime.now(UTC)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
|
||||||
return job
|
|
||||||
|
|
||||||
async def read_next_queued_job(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job | None:
|
|
||||||
"""Read the next queued job ordered by creation time."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Job)
|
|
||||||
.options(
|
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
|
||||||
# Break ties by id so "next" is stable when two rows share close timestamps.
|
|
||||||
.order_by(Job.date_created, Job.id) # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
return (await _session.exec(query)).first()
|
|
||||||
|
|
||||||
async def requeue_stale_processing_jobs(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
stale_before: datetime,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Move stale processing jobs back to queued state.
|
|
||||||
|
|
||||||
Jobs with ``status=PROCESSING`` and ``date_updated`` older than
|
|
||||||
``stale_before`` are considered stale and re-queued.
|
|
||||||
"""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(Job).where(Job.status == JobStatus.PROCESSING).where(Job.date_updated < stale_before)
|
|
||||||
stale_jobs = (await _session.exec(query)).all()
|
|
||||||
if not stale_jobs:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
for job in stale_jobs:
|
|
||||||
job.status = JobStatus.QUEUED
|
|
||||||
job.date_updated = now
|
|
||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
|
||||||
return len(stale_jobs)
|
|
||||||
|
|
||||||
async def delete_job_with_guardrails(self, *, job_id: UUID, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a job with lifecycle guardrails and dependent cleanup policy.
|
|
||||||
|
|
||||||
Policy:
|
|
||||||
- Block when the job is actively processing.
|
|
||||||
- Otherwise remove related JobSource rows, then delete the job.
|
|
||||||
"""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Job)
|
|
||||||
.options(selectinload(Job.job_sources)) # pyright: ignore[reportArgumentType]
|
|
||||||
.where(Job.id == job_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
job = (await _session.exec(query)).first()
|
|
||||||
if job is None:
|
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
|
||||||
|
|
||||||
if job.status == JobStatus.PROCESSING:
|
|
||||||
raise JobDeleteBlockedError(
|
|
||||||
"Job delete blocked while status is processing",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Wait for processing to complete, or move the job out of processing before deleting.",
|
|
||||||
)
|
|
||||||
|
|
||||||
for job_source in list(job.job_sources):
|
|
||||||
await _session.delete(job_source)
|
|
||||||
|
|
||||||
await _session.delete(job)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
async def cancel_job(self, *, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
|
||||||
"""Cancel a queued/processing job and stop remaining source work."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Job)
|
|
||||||
.options(
|
|
||||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
.where(Job.id == job_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
job = (await _session.exec(query)).first()
|
|
||||||
if job is None:
|
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
|
||||||
|
|
||||||
if job.status in {JobStatus.TRANSCRIBED, JobStatus.COMPLETED}:
|
|
||||||
raise JobCancelBlockedError(
|
|
||||||
"Job cancel is not allowed for transcribed/completed jobs",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Use resubmit for reprocessing needs, or leave the terminal job unchanged.",
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
job.status = JobStatus.FAILED
|
|
||||||
job.date_updated = now
|
|
||||||
|
|
||||||
for job_source in job.job_sources:
|
|
||||||
if job_source.status == JobSourceStatus.TRANSCRIBED:
|
|
||||||
continue
|
|
||||||
job_source.status = JobSourceStatus.FAILED
|
|
||||||
job_source.raw_transcription = None
|
|
||||||
job_source.error_detail = "Cancelled by user"
|
|
||||||
job_source.executed_at = now
|
|
||||||
if job_source.source is not None:
|
|
||||||
job_source.source.raw_transcription = None
|
|
||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
|
||||||
return job
|
|
||||||
|
|
||||||
async def resubmit_non_transcribed_sources(self, *, job_id: UUID, session: AsyncSession | None = None) -> int:
|
|
||||||
"""Reset non-transcribed source executions and queue the job for reprocessing."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Job)
|
|
||||||
.options(
|
|
||||||
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
.where(Job.id == job_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
job = (await _session.exec(query)).first()
|
|
||||||
if job is None:
|
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
|
||||||
|
|
||||||
if job.status == JobStatus.PROCESSING:
|
|
||||||
raise JobResubmitBlockedError(
|
|
||||||
"Job resubmit is blocked while processing is active",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Cancel processing first, then resubmit remaining sources.",
|
|
||||||
)
|
|
||||||
|
|
||||||
candidates = [job_source for job_source in job.job_sources if job_source.status != JobSourceStatus.TRANSCRIBED]
|
|
||||||
if not candidates:
|
|
||||||
raise JobResubmitBlockedError(
|
|
||||||
"Job has no non-transcribed sources to resubmit",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Only failed or pending sources can be resubmitted.",
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
for job_source in candidates:
|
|
||||||
job_source.status = JobSourceStatus.PENDING
|
|
||||||
job_source.raw_transcription = None
|
|
||||||
job_source.error_detail = None
|
|
||||||
job_source.executed_at = now
|
|
||||||
if job_source.source is not None:
|
|
||||||
job_source.source.raw_transcription = None
|
|
||||||
|
|
||||||
job.status = JobStatus.QUEUED
|
|
||||||
job.date_updated = now
|
|
||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
|
||||||
return len(candidates)
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.errors import AppError
|
|
||||||
from transcription.errors import ErrorCategory
|
|
||||||
|
|
||||||
from ..db.models import Document
|
|
||||||
from ..db.models import Job
|
|
||||||
from ..db.models import JobSource
|
|
||||||
from ..db.models import JobSourceStatus
|
|
||||||
from ..db.models import Source
|
|
||||||
from .documents import UploadJobResult
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
|
||||||
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
|
||||||
|
|
||||||
|
|
||||||
class UploadError(AppError):
|
|
||||||
"""Raised when uploaded content cannot be persisted safely."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class JobCreateResult:
|
|
||||||
"""Summary of explicit Job create records."""
|
|
||||||
|
|
||||||
document_id: UUID
|
|
||||||
job_id: UUID
|
|
||||||
source_ids: tuple[UUID, ...]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PendingStoredUpload:
|
|
||||||
"""Pre-staged upload artifact tied to a source id."""
|
|
||||||
|
|
||||||
source_id: UUID
|
|
||||||
original_filename: str
|
|
||||||
stored_path: Path
|
|
||||||
|
|
||||||
|
|
||||||
async def create_upload_job(
|
|
||||||
*,
|
|
||||||
filename: str,
|
|
||||||
file_bytes: bytes,
|
|
||||||
session: AsyncSession,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
) -> UploadJobResult:
|
|
||||||
"""Create upload-backed document and queued job records."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
document_id = uuid4()
|
|
||||||
source_id = uuid4()
|
|
||||||
stored_path = store_file(
|
|
||||||
filename=filename,
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
settings=runtime_settings,
|
|
||||||
relative_directory=Path("documents") / str(document_id),
|
|
||||||
filename_stem=str(source_id),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
document, job = await _create_upload_records(
|
|
||||||
session=session,
|
|
||||||
document_id=document_id,
|
|
||||||
source_id=source_id,
|
|
||||||
original_filename=filename,
|
|
||||||
stored_path=stored_path,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
_best_effort_delete(stored_path)
|
|
||||||
raise UploadError(
|
|
||||||
"Failed to create upload database records",
|
|
||||||
category=ErrorCategory.INFRA_TRANSIENT,
|
|
||||||
suggestion="Retry upload. If this keeps happening, verify database availability.",
|
|
||||||
retriable=True,
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
|
|
||||||
return UploadJobResult(
|
|
||||||
document_id=document.id,
|
|
||||||
job_id=job.id,
|
|
||||||
stored_path=stored_path,
|
|
||||||
original_filename=Path(filename).name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_job_for_document(
|
|
||||||
*,
|
|
||||||
document_id: UUID,
|
|
||||||
uploads: Sequence[tuple[str, bytes]],
|
|
||||||
session: AsyncSession,
|
|
||||||
provider: str | None = None,
|
|
||||||
model: str | None = None,
|
|
||||||
prompt_name: str | None = None,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
) -> JobCreateResult:
|
|
||||||
"""Create a queued job for an existing document with one or more uploaded sources."""
|
|
||||||
if not uploads:
|
|
||||||
raise UploadError(
|
|
||||||
"At least one upload is required to create a job",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Upload one or more files and try again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
sorted_uploads = sorted(uploads, key=lambda item: Path(item[0]).name.casefold())
|
|
||||||
stored_uploads: list[PendingStoredUpload] = []
|
|
||||||
for filename, file_bytes in sorted_uploads:
|
|
||||||
source_id = uuid4()
|
|
||||||
stored_uploads.append(
|
|
||||||
PendingStoredUpload(
|
|
||||||
source_id=source_id,
|
|
||||||
original_filename=filename,
|
|
||||||
stored_path=store_file(
|
|
||||||
filename=filename,
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
settings=runtime_settings,
|
|
||||||
relative_directory=Path("documents") / str(document_id),
|
|
||||||
filename_stem=str(source_id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
job, source_ids = await _create_job_for_document_records(
|
|
||||||
session=session,
|
|
||||||
document_id=document_id,
|
|
||||||
stored_uploads=stored_uploads,
|
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
for upload in stored_uploads:
|
|
||||||
_best_effort_delete(upload.stored_path)
|
|
||||||
raise UploadError(
|
|
||||||
"Failed to create job records from uploads",
|
|
||||||
category=ErrorCategory.INFRA_TRANSIENT,
|
|
||||||
suggestion="Retry creation. If this keeps happening, verify database availability.",
|
|
||||||
retriable=True,
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
logger.info("Created explicit job document_id=%s job_id=%s sources=%s", document_id, job.id, len(source_ids))
|
|
||||||
return JobCreateResult(
|
|
||||||
document_id=document_id,
|
|
||||||
job_id=job.id,
|
|
||||||
source_ids=tuple(source_ids),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_upload_records(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
document_id: UUID,
|
|
||||||
source_id: UUID,
|
|
||||||
original_filename: str,
|
|
||||||
stored_path: Path,
|
|
||||||
) -> tuple[Document, Job]:
|
|
||||||
document = Document(
|
|
||||||
id=document_id,
|
|
||||||
name=Path(original_filename).name,
|
|
||||||
)
|
|
||||||
session.add(document)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
job = Job(document_id=document.id)
|
|
||||||
session.add(job)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
source = Source(
|
|
||||||
id=source_id,
|
|
||||||
document_id=document.id,
|
|
||||||
page_number=1,
|
|
||||||
upload_name=Path(original_filename).name,
|
|
||||||
filename=stored_path.name,
|
|
||||||
file_path=str(stored_path),
|
|
||||||
)
|
|
||||||
session.add(source)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
session.add(
|
|
||||||
JobSource(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source.id,
|
|
||||||
status=JobSourceStatus.PENDING,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(document)
|
|
||||||
await session.refresh(job)
|
|
||||||
return document, job
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_job_for_document_records(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
document_id: UUID,
|
|
||||||
stored_uploads: Sequence[PendingStoredUpload],
|
|
||||||
provider: str | None,
|
|
||||||
model: str | None,
|
|
||||||
prompt_name: str | None,
|
|
||||||
) -> tuple[Job, list[UUID]]:
|
|
||||||
document = await session.get(Document, document_id)
|
|
||||||
if document is None:
|
|
||||||
raise UploadError(
|
|
||||||
f"Document with id {document_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Select an existing document and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_sources = (
|
|
||||||
await session.exec(select(Source).where(Source.document_id == document_id))
|
|
||||||
).all()
|
|
||||||
next_page_number = (max((source.page_number for source in existing_sources), default=0) + 1)
|
|
||||||
|
|
||||||
job = Job(
|
|
||||||
document_id=document_id,
|
|
||||||
provider=(provider or None),
|
|
||||||
model=(model or None),
|
|
||||||
prompt_name=(prompt_name or None),
|
|
||||||
)
|
|
||||||
session.add(job)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
source_ids: list[UUID] = []
|
|
||||||
for page_offset, upload in enumerate(stored_uploads):
|
|
||||||
source = Source(
|
|
||||||
id=upload.source_id,
|
|
||||||
document_id=document_id,
|
|
||||||
page_number=next_page_number + page_offset,
|
|
||||||
upload_name=Path(upload.original_filename).name,
|
|
||||||
filename=upload.stored_path.name,
|
|
||||||
file_path=str(upload.stored_path),
|
|
||||||
)
|
|
||||||
session.add(source)
|
|
||||||
await session.flush()
|
|
||||||
source_ids.append(source.id)
|
|
||||||
|
|
||||||
session.add(
|
|
||||||
JobSource(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source.id,
|
|
||||||
status=JobSourceStatus.PENDING,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(job)
|
|
||||||
return job, source_ids
|
|
||||||
|
|
||||||
|
|
||||||
def _best_effort_delete(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
if path.exists():
|
|
||||||
path.unlink()
|
|
||||||
except OSError:
|
|
||||||
logger.warning("Failed to clean up upload file after DB error: %s", path)
|
|
||||||
|
|
||||||
|
|
||||||
def store_file(
|
|
||||||
*,
|
|
||||||
filename: str,
|
|
||||||
file_bytes: bytes,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
relative_directory: Path | None = None,
|
|
||||||
filename_stem: str | None = None,
|
|
||||||
) -> Path:
|
|
||||||
"""Persist an uploaded file to the configured upload directory."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS)
|
|
||||||
return _store_file_bytes(
|
|
||||||
filename=filename,
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
settings=runtime_settings,
|
|
||||||
relative_directory=relative_directory,
|
|
||||||
filename_stem=filename_stem,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def store_person_portrait(
|
|
||||||
*,
|
|
||||||
person_id: UUID,
|
|
||||||
filename: str,
|
|
||||||
file_bytes: bytes,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
) -> Path:
|
|
||||||
"""Persist a portrait upload under persons/<person_id>."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS)
|
|
||||||
return _store_file_bytes(
|
|
||||||
filename=filename,
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
settings=runtime_settings,
|
|
||||||
relative_directory=Path("persons") / str(person_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _store_file_bytes(
|
|
||||||
*,
|
|
||||||
filename: str,
|
|
||||||
file_bytes: bytes,
|
|
||||||
settings: Settings,
|
|
||||||
relative_directory: Path | None = None,
|
|
||||||
filename_stem: str | None = None,
|
|
||||||
) -> Path:
|
|
||||||
upload_dir = settings.upload_dir
|
|
||||||
target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
|
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
stored_name = _build_stored_filename(filename=filename, filename_stem=filename_stem)
|
|
||||||
stored_path = target_dir / stored_name
|
|
||||||
|
|
||||||
try:
|
|
||||||
stored_path.write_bytes(file_bytes)
|
|
||||||
except OSError as exc:
|
|
||||||
raise UploadError(
|
|
||||||
"Failed to persist upload file",
|
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
|
||||||
suggestion="Check upload directory permissions and available disk space, then retry.",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
logger.info("Stored uploaded file: %s", stored_path)
|
|
||||||
return stored_path
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions: set[str]) -> None:
|
|
||||||
if not file_bytes:
|
|
||||||
raise UploadError(
|
|
||||||
"Upload payload is empty",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Select a non-empty file and try again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
safe_name = Path(filename).name
|
|
||||||
if not safe_name:
|
|
||||||
raise UploadError(
|
|
||||||
"Upload filename is required",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Choose a file with a valid filename and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
suffix = Path(safe_name).suffix.lower()
|
|
||||||
if suffix not in supported_extensions:
|
|
||||||
raise UploadError(
|
|
||||||
f"Unsupported upload extension: {suffix}",
|
|
||||||
category=ErrorCategory.USER_INPUT,
|
|
||||||
suggestion="Upload a supported image or document file and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_stored_filename(*, filename: str, filename_stem: str | None = None) -> str:
|
|
||||||
safe_name = Path(filename).name
|
|
||||||
suffix = Path(safe_name).suffix.lower()
|
|
||||||
stem = filename_stem or str(uuid4())
|
|
||||||
return f"{stem}{suffix}"
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
"""Prompt loading and provider-backed transcription service."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import mimetypes
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from datetime import UTC
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db.models import Job
|
|
||||||
from transcription.db.models import JobSource
|
|
||||||
from transcription.db.models import JobSourceStatus
|
|
||||||
from transcription.db.models import Source
|
|
||||||
from transcription.errors import AppError
|
|
||||||
from transcription.errors import ErrorCategory
|
|
||||||
from transcription.providers import ProviderAuthError
|
|
||||||
from transcription.providers import ProviderError
|
|
||||||
from transcription.providers import ProviderResponseError
|
|
||||||
from transcription.providers import TranscriptionProvider
|
|
||||||
from transcription.providers import TranscriptionResult
|
|
||||||
from transcription.providers import get_transcription_provider
|
|
||||||
|
|
||||||
from .base import ServiceBase
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
DEFAULT_PROMPT_FILE = "transcribe_document.md"
|
|
||||||
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
|
||||||
|
|
||||||
|
|
||||||
class PromptLoadError(AppError):
|
|
||||||
"""Raised when prompt artifacts cannot be loaded safely."""
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionError(AppError):
|
|
||||||
"""Raised when transcription execution fails."""
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionNotFoundError(TranscriptionError):
|
|
||||||
"""Raised when a transcription-related resource is not found."""
|
|
||||||
|
|
||||||
|
|
||||||
class SourceDeleteBlockedError(TranscriptionError):
|
|
||||||
"""Raised when source deletion is blocked by dependency policy."""
|
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionService(ServiceBase):
|
|
||||||
"""Service class for job transcription output and page-level source revisions."""
|
|
||||||
|
|
||||||
provider: TranscriptionProvider
|
|
||||||
|
|
||||||
def __init__(self, session_factory: async_sessionmaker[AsyncSession] | None = None):
|
|
||||||
super().__init__(session_factory=session_factory)
|
|
||||||
self.provider = get_transcription_provider(settings=self.settings)
|
|
||||||
|
|
||||||
async def create_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
|
|
||||||
"""Create a new source page record in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
_session.add(source)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
|
||||||
return source
|
|
||||||
|
|
||||||
async def read_source(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
|
|
||||||
"""Read an existing source page record."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
source = await _session.get(Source, source_id)
|
|
||||||
if source is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Source with id {source_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the source id and retry.",
|
|
||||||
)
|
|
||||||
return source
|
|
||||||
|
|
||||||
async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
|
|
||||||
"""Read a source page record with job-source context for UI detail rendering."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Source)
|
|
||||||
.options(
|
|
||||||
selectinload(Source.job_sources).selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
.where(Source.id == source_id)
|
|
||||||
.execution_options(populate_existing=True)
|
|
||||||
)
|
|
||||||
source = (await _session.exec(query)).first()
|
|
||||||
|
|
||||||
if source is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Source with id {source_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the source id and retry.",
|
|
||||||
)
|
|
||||||
return source
|
|
||||||
|
|
||||||
async def update_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
|
|
||||||
"""Update an existing source page record."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
merged = await _session.merge(source)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a source page record."""
|
|
||||||
source_file_path = source.file_path
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
await _session.delete(source)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
self._delete_source_file(source_file_path=source_file_path)
|
|
||||||
|
|
||||||
async def delete_unlinked_source(self, *, source_id: UUID, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a source only when no JobSource links exist."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
source = await _session.get(
|
|
||||||
Source,
|
|
||||||
source_id,
|
|
||||||
options=(
|
|
||||||
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if source is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Source with id {source_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the source id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if source.job_sources:
|
|
||||||
raise SourceDeleteBlockedError(
|
|
||||||
"Source delete blocked because it is linked to one or more jobs",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Remove JobSource links first, then retry deletion.",
|
|
||||||
)
|
|
||||||
|
|
||||||
source_file_path = source.file_path
|
|
||||||
await _session.delete(source)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
self._delete_source_file(source_file_path=source_file_path)
|
|
||||||
|
|
||||||
async def list_sources(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
document_id: UUID | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[Source]:
|
|
||||||
"""List source pages, optionally filtered by document."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(Source)
|
|
||||||
if document_id is not None:
|
|
||||||
query = query.where(Source.document_id == document_id)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def query_sources(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
document_id: UUID | None = None,
|
|
||||||
page_number: int | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[Source]:
|
|
||||||
"""Query source pages using the provided filters."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(Source)
|
|
||||||
if document_id is not None:
|
|
||||||
query = query.where(Source.document_id == document_id)
|
|
||||||
if page_number is not None:
|
|
||||||
query = query.where(Source.page_number == page_number)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def create_job_source(
|
|
||||||
self,
|
|
||||||
job_source: JobSource,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> JobSource:
|
|
||||||
"""Create a new job_source execution record in the database."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
_session.add(job_source)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job_source,))
|
|
||||||
return job_source
|
|
||||||
|
|
||||||
async def read_job_source(self, job_source_id: UUID, *, session: AsyncSession | None = None) -> JobSource:
|
|
||||||
"""Read an existing job_source record."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
job_source = await _session.get(
|
|
||||||
JobSource,
|
|
||||||
job_source_id,
|
|
||||||
options=(selectinload(JobSource.source),), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
if job_source is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"JobSource with id {job_source_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the job source id and retry.",
|
|
||||||
)
|
|
||||||
return job_source
|
|
||||||
|
|
||||||
async def update_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> JobSource:
|
|
||||||
"""Update an existing job_source record."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
merged = await _session.merge(job_source)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def delete_job_source(self, job_source: JobSource, *, session: AsyncSession | None = None) -> None:
|
|
||||||
"""Delete a job_source record."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
await _session.delete(job_source)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
async def delete_source_from_job_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
job_id: UUID,
|
|
||||||
source_id: UUID,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Delete a source from an active job context with dependency guardrails.
|
|
||||||
|
|
||||||
Policy:
|
|
||||||
- Allowed when exactly one JobSource link exists and it points at ``job_id``.
|
|
||||||
- Blocked when additional JobSource links exist (history/shared dependencies).
|
|
||||||
"""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
source = await _session.get(
|
|
||||||
Source,
|
|
||||||
source_id,
|
|
||||||
options=(
|
|
||||||
selectinload(Source.job_sources), # pyright: ignore[reportArgumentType]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if source is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Source with id {source_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the source id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
linked_job_sources = list(source.job_sources)
|
|
||||||
matching_links = [job_source for job_source in linked_job_sources if job_source.job_id == job_id]
|
|
||||||
if not matching_links:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Source {source_id} is not linked to job {job_id}",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Open the source from its linked job context and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(linked_job_sources) > len(matching_links):
|
|
||||||
raise SourceDeleteBlockedError(
|
|
||||||
"Source delete blocked by related job history",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Remove additional JobSource links first, then retry deletion.",
|
|
||||||
)
|
|
||||||
|
|
||||||
for job_source in matching_links:
|
|
||||||
await _session.delete(job_source)
|
|
||||||
|
|
||||||
source_file_path = source.file_path
|
|
||||||
await _session.delete(source)
|
|
||||||
await self._finalize(session=_session, caller_session=session)
|
|
||||||
|
|
||||||
self._delete_source_file(source_file_path=source_file_path)
|
|
||||||
|
|
||||||
def _delete_source_file(self, *, source_file_path: str) -> None:
|
|
||||||
"""Best-effort cleanup for source media files."""
|
|
||||||
candidate_path = Path(source_file_path)
|
|
||||||
resolved_path = candidate_path if candidate_path.is_absolute() else self.settings.upload_dir / candidate_path
|
|
||||||
|
|
||||||
if not resolved_path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
resolved_path.unlink()
|
|
||||||
logger.info("Deleted source file: %s", resolved_path)
|
|
||||||
except OSError:
|
|
||||||
logger.warning("Failed to delete source file: %s", resolved_path)
|
|
||||||
|
|
||||||
async def list_job_sources(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
job_id: UUID | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[JobSource]:
|
|
||||||
"""List job-source records, optionally filtered by job."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = select(JobSource).options(
|
|
||||||
selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
|
|
||||||
selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
if job_id is not None:
|
|
||||||
query = query.where(JobSource.job_id == job_id)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def transcribe_document(
|
|
||||||
self,
|
|
||||||
image_path: str | Path,
|
|
||||||
job_id: UUID,
|
|
||||||
*,
|
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Transcribe a local image using the configured prompt and provider."""
|
|
||||||
result = await transcribe_document_image(
|
|
||||||
image_path=image_path,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
settings=self.settings,
|
|
||||||
provider=self.provider,
|
|
||||||
)
|
|
||||||
await self.update_job_transcription(
|
|
||||||
job_id=job_id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
prompt_name=result.prompt_name,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update_job_transcription(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
job_id: UUID,
|
|
||||||
text: str | None,
|
|
||||||
error_detail: str | None = None,
|
|
||||||
provider: str | None = None,
|
|
||||||
model: str | None = None,
|
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Persist transcription output for the first ordered source in a job's document.
|
|
||||||
|
|
||||||
This compatibility helper keeps legacy single-source workflows working.
|
|
||||||
New multi-source flows should use ``update_job_source_transcription``.
|
|
||||||
"""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
job = await _session.get(Job, job_id)
|
|
||||||
if job is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Job with id {job_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the job id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
job.provider = provider or job.provider or self.settings.provider.value
|
|
||||||
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
|
|
||||||
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
|
|
||||||
job.date_updated = datetime.now(UTC)
|
|
||||||
|
|
||||||
source = await _session.exec(
|
|
||||||
select(Source)
|
|
||||||
.where(Source.document_id == job.document_id)
|
|
||||||
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
source_row = source.first()
|
|
||||||
if source_row is not None:
|
|
||||||
await self.update_job_source_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source_row.id,
|
|
||||||
text=text,
|
|
||||||
error_detail=error_detail,
|
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
prompt_name=prompt_name,
|
|
||||||
session=_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
|
||||||
return job
|
|
||||||
|
|
||||||
async def update_job_source_transcription(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
job_id: UUID,
|
|
||||||
source_id: UUID,
|
|
||||||
text: str | None,
|
|
||||||
error_detail: str | None = None,
|
|
||||||
provider: str | None = None,
|
|
||||||
model: str | None = None,
|
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> JobSource:
|
|
||||||
"""Persist transcription fields for one source within a specific job."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
job = await _session.get(Job, job_id)
|
|
||||||
if job is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Job with id {job_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the job id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
source = await _session.get(Source, source_id)
|
|
||||||
if source is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Source with id {source_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the source id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if source.document_id != job.document_id:
|
|
||||||
raise TranscriptionError(
|
|
||||||
f"Source {source_id} does not belong to job {job_id}",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Link the source to the same document as the job and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
job.provider = provider or job.provider or self.settings.provider.value
|
|
||||||
job.model = model or job.model or _resolve_transcript_model(provider=self.provider, settings=self.settings)
|
|
||||||
job.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
|
|
||||||
job.date_updated = datetime.now(UTC)
|
|
||||||
|
|
||||||
source.raw_transcription = text
|
|
||||||
|
|
||||||
existing_job_source = await _session.exec(
|
|
||||||
select(JobSource).where(JobSource.job_id == job_id).where(JobSource.source_id == source_id)
|
|
||||||
)
|
|
||||||
job_source = existing_job_source.first()
|
|
||||||
if job_source is None:
|
|
||||||
job_source = JobSource(
|
|
||||||
job_id=job_id,
|
|
||||||
source_id=source_id,
|
|
||||||
status=JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED,
|
|
||||||
raw_transcription=text,
|
|
||||||
error_detail=error_detail,
|
|
||||||
)
|
|
||||||
_session.add(job_source)
|
|
||||||
else:
|
|
||||||
job_source.raw_transcription = text
|
|
||||||
job_source.error_detail = error_detail
|
|
||||||
job_source.status = JobSourceStatus.TRANSCRIBED if text is not None else JobSourceStatus.FAILED
|
|
||||||
job_source.executed_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(job, source, job_source))
|
|
||||||
return job_source
|
|
||||||
|
|
||||||
async def upsert_revision_for_source(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
source_id: UUID,
|
|
||||||
text: str,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Source:
|
|
||||||
"""Persist a human revision on a source page."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
source = await _session.get(Source, source_id)
|
|
||||||
if source is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
|
||||||
f"Source with id {source_id} not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the source id and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
source.revised_text = text
|
|
||||||
source.date_revised = datetime.now(UTC)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
|
||||||
return source
|
|
||||||
|
|
||||||
async def read_revision_by_source(
|
|
||||||
self,
|
|
||||||
source_id: UUID,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Source | None:
|
|
||||||
"""Read the source record for a given page, including any revision text."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
return await _session.get(Source, source_id)
|
|
||||||
|
|
||||||
async def list_revisions_by_job(
|
|
||||||
self,
|
|
||||||
job_id: UUID,
|
|
||||||
*,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Sequence[Source]:
|
|
||||||
"""List source pages for a job that carry revision text."""
|
|
||||||
async with self._session_scope(session) as _session:
|
|
||||||
query = (
|
|
||||||
select(Source)
|
|
||||||
.join(JobSource, JobSource.source_id == Source.id)
|
|
||||||
.where(JobSource.job_id == job_id)
|
|
||||||
.where(Source.revised_text.is_not(None))
|
|
||||||
.order_by(Source.date_revised) # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_transcript_model(*, provider: TranscriptionProvider, settings: Settings) -> str:
|
|
||||||
provider_model = getattr(provider, "model", None)
|
|
||||||
if isinstance(provider_model, str) and provider_model.strip():
|
|
||||||
return provider_model
|
|
||||||
|
|
||||||
if settings.provider_model and settings.provider_model.strip():
|
|
||||||
return settings.provider_model
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
async def transcribe_document_image(
|
|
||||||
image_path: str | Path,
|
|
||||||
*,
|
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
provider: TranscriptionProvider | None = None,
|
|
||||||
) -> TranscriptionResult:
|
|
||||||
"""Transcribe a local image using the configured prompt and provider."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
|
|
||||||
image_bytes, mime_type = load_image_payload(image_path)
|
|
||||||
|
|
||||||
adapter = provider or get_transcription_provider(settings=runtime_settings)
|
|
||||||
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
|
|
||||||
|
|
||||||
with handle_transcription_errors():
|
|
||||||
result = await adapter.transcribe(
|
|
||||||
prompt_text=prompt_text,
|
|
||||||
image_bytes=image_bytes,
|
|
||||||
mime_type=mime_type,
|
|
||||||
)
|
|
||||||
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
|
|
||||||
"""Load and validate prompt text from PROMPT_DIR."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
prompt_path = runtime_settings.prompt_dir / prompt_name
|
|
||||||
|
|
||||||
if not prompt_path.exists() or not prompt_path.is_file():
|
|
||||||
raise PromptLoadError(
|
|
||||||
f"Prompt file not found: {prompt_path}",
|
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
|
||||||
suggestion="Verify PROMPT_DIR and prompt file configuration, then retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt_text = prompt_path.read_text(encoding="utf-8").strip()
|
|
||||||
if not prompt_text:
|
|
||||||
raise PromptLoadError(
|
|
||||||
f"Prompt file is empty: {prompt_path}",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Populate the prompt file with valid instructions and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Loaded prompt artifact: %s", prompt_path)
|
|
||||||
return prompt_text
|
|
||||||
|
|
||||||
|
|
||||||
def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
|
|
||||||
"""Read image bytes and detect mime type for supported uploads."""
|
|
||||||
path = Path(image_path)
|
|
||||||
|
|
||||||
if not path.exists() or not path.is_file():
|
|
||||||
raise TranscriptionError(
|
|
||||||
f"Image file not found: {path}",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Verify the uploaded file exists and retry from the jobs page.",
|
|
||||||
)
|
|
||||||
|
|
||||||
suffix = path.suffix.lower()
|
|
||||||
if suffix not in SUPPORTED_EXTENSIONS:
|
|
||||||
raise TranscriptionError(
|
|
||||||
f"Unsupported file type: {suffix}",
|
|
||||||
category=ErrorCategory.USER_INPUT,
|
|
||||||
suggestion="Use JPG, JPEG, PNG, TIFF, or PDF files.",
|
|
||||||
)
|
|
||||||
|
|
||||||
mime_type, _ = mimetypes.guess_type(path.name)
|
|
||||||
if suffix in {".tif", ".tiff"}:
|
|
||||||
mime_type = "image/tiff"
|
|
||||||
if not mime_type:
|
|
||||||
raise TranscriptionError(
|
|
||||||
f"Unable to determine MIME type for: {path}",
|
|
||||||
category=ErrorCategory.VALIDATION,
|
|
||||||
suggestion="Re-save the file in a supported format and retry.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return path.read_bytes(), mime_type
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def handle_transcription_errors():
|
|
||||||
"""Context manager to handle transcription errors."""
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
except ProviderAuthError as exc:
|
|
||||||
raise TranscriptionError(
|
|
||||||
"Provider authentication failed",
|
|
||||||
category=ErrorCategory.INFRA_PERSISTENT,
|
|
||||||
suggestion="Verify provider API credentials and retry.",
|
|
||||||
) from exc
|
|
||||||
except ProviderResponseError as exc:
|
|
||||||
raise TranscriptionError(
|
|
||||||
"Provider returned an invalid response",
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion="Retry once. If it persists, try a different provider model or inspect provider status.",
|
|
||||||
retriable=True,
|
|
||||||
) from exc
|
|
||||||
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,
|
|
||||||
) from exc
|
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
||||||
|
|
||||||
from ..config import Settings
|
|
||||||
from ..config import get_settings
|
|
||||||
from ..db.models import Job
|
|
||||||
from ..db.models import JobSourceStatus
|
|
||||||
from ..db.models import JobStatus
|
|
||||||
from ..db.models import Source
|
|
||||||
from ..errors import AppError
|
|
||||||
from ..errors import ErrorCategory
|
|
||||||
from ..errors import classify_unexpected_error
|
|
||||||
from ..errors import format_error_detail
|
|
||||||
from ..providers import TranscriptionResult
|
|
||||||
from . import ServiceBundle
|
|
||||||
from .transcription import DEFAULT_PROMPT_FILE
|
|
||||||
from .transcription import transcribe_document_image
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def advance_job(
|
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job | None:
|
|
||||||
"""Advance a single job by lifecycle status."""
|
|
||||||
settings = settings or get_settings()
|
|
||||||
match job.status:
|
|
||||||
case JobStatus.QUEUED:
|
|
||||||
return await process_queued_job(job=job, services=services, settings=settings, session=session)
|
|
||||||
case JobStatus.FAILED:
|
|
||||||
if job.retry_count < settings.worker_max_retries:
|
|
||||||
return await services.jobs.update_job_state(
|
|
||||||
job_id=job.id,
|
|
||||||
status=JobStatus.QUEUED,
|
|
||||||
retry_count_increment=1,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.error(f"Job {job.id} has failed and reached max retries.")
|
|
||||||
return
|
|
||||||
case _:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
async def process_queued_job(
|
|
||||||
*,
|
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job | None:
|
|
||||||
"""Process one complete transcription attempt for a queued job."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
if job.status != JobStatus.QUEUED:
|
|
||||||
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Transaction A: claim job for processing.
|
|
||||||
if session is None:
|
|
||||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
|
|
||||||
else:
|
|
||||||
# If we're sharing the session, need to make sure setting the Job to PROCESSING is committed before we start
|
|
||||||
# the transcription, otherwise other workers may see the job as still QUEUED and try to process it.
|
|
||||||
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
|
||||||
sources = _resolve_job_sources(source_job)
|
|
||||||
if not sources and not source_job.job_sources:
|
|
||||||
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
|
|
||||||
sources = list(sorted(candidate_sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
|
|
||||||
|
|
||||||
if not sources:
|
|
||||||
return await services.jobs.mark_job_status(job.id, JobStatus.TRANSCRIBED, session=session)
|
|
||||||
|
|
||||||
successful_pages: list[tuple[Source, TranscriptionResult]] = []
|
|
||||||
failed_pages: list[tuple[Source, AppError]] = []
|
|
||||||
externally_stopped = False
|
|
||||||
|
|
||||||
for source in sources:
|
|
||||||
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
|
||||||
externally_stopped = True
|
|
||||||
break
|
|
||||||
|
|
||||||
started_at = asyncio.get_running_loop().time()
|
|
||||||
try:
|
|
||||||
result = await asyncio.wait_for(
|
|
||||||
transcribe_document_image(source.file_path),
|
|
||||||
timeout=runtime_settings.worker_provider_timeout_seconds,
|
|
||||||
)
|
|
||||||
elapsed_seconds = asyncio.get_running_loop().time() - started_at
|
|
||||||
logger.info(
|
|
||||||
"Provider response diagnostics operation=worker.provider_response "
|
|
||||||
"job_id=%s document_id=%s source_id=%s provider=%s model=%s "
|
|
||||||
"finish_reason=%s usage_input_tokens=%s usage_output_tokens=%s usage_total_tokens=%s "
|
|
||||||
"latency_seconds=%.3f text_chars=%s text_lines=%s",
|
|
||||||
job.id,
|
|
||||||
job.document_id,
|
|
||||||
source.id,
|
|
||||||
result.provider,
|
|
||||||
result.model,
|
|
||||||
result.finish_reason or "unknown",
|
|
||||||
result.usage_input_tokens,
|
|
||||||
result.usage_output_tokens,
|
|
||||||
result.usage_total_tokens,
|
|
||||||
elapsed_seconds,
|
|
||||||
len(result.text),
|
|
||||||
_line_count(result.text),
|
|
||||||
)
|
|
||||||
|
|
||||||
_validate_transcription_quality(result=result, settings=runtime_settings)
|
|
||||||
successful_pages.append((source, result))
|
|
||||||
except TimeoutError:
|
|
||||||
error = AppError(
|
|
||||||
f"Provider call timed out after {runtime_settings.worker_provider_timeout_seconds:.1f}s",
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion="Retry the job. If this repeats, verify provider latency and request payload size.",
|
|
||||||
retriable=True,
|
|
||||||
)
|
|
||||||
failed_pages.append((source, error))
|
|
||||||
logger.error(
|
|
||||||
"Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
|
||||||
job.id,
|
|
||||||
job.document_id,
|
|
||||||
source.id,
|
|
||||||
error.error_id,
|
|
||||||
error.category.value,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
match exc:
|
|
||||||
case AppError() as error:
|
|
||||||
pass
|
|
||||||
case _:
|
|
||||||
error = classify_unexpected_error(exc, operation="worker.process_job")
|
|
||||||
|
|
||||||
failed_pages.append((source, error))
|
|
||||||
logger.error(
|
|
||||||
"Source failed operation=worker.process_job job_id=%s document_id=%s source_id=%s error_id=%s category=%s",
|
|
||||||
job.id,
|
|
||||||
job.document_id,
|
|
||||||
source.id,
|
|
||||||
error.error_id,
|
|
||||||
error.category.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
if await _job_no_longer_processing(job_id=job.id, services=services, session=session):
|
|
||||||
externally_stopped = True
|
|
||||||
break
|
|
||||||
|
|
||||||
terminal_status = JobStatus.TRANSCRIBED
|
|
||||||
if externally_stopped:
|
|
||||||
terminal_status = JobStatus.FAILED
|
|
||||||
elif failed_pages and successful_pages:
|
|
||||||
terminal_status = JobStatus.PARTIAL_SUCCESS
|
|
||||||
elif failed_pages and not successful_pages:
|
|
||||||
terminal_status = JobStatus.FAILED
|
|
||||||
|
|
||||||
updated_job = await _finalize_batch_outcome(
|
|
||||||
job=job,
|
|
||||||
services=services,
|
|
||||||
successful_pages=successful_pages,
|
|
||||||
failed_pages=failed_pages,
|
|
||||||
status=terminal_status,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Job finished operation=worker.process_job job_id=%s document_id=%s status=%s success_pages=%s failed_pages=%s",
|
|
||||||
updated_job.id,
|
|
||||||
updated_job.document_id,
|
|
||||||
updated_job.status.value,
|
|
||||||
len(successful_pages),
|
|
||||||
len(failed_pages),
|
|
||||||
)
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
|
|
||||||
async def process_next_queued_job(
|
|
||||||
*,
|
|
||||||
services: ServiceBundle,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Process the next queued job if one exists."""
|
|
||||||
job = await services.jobs.read_next_queued_job(session=session)
|
|
||||||
|
|
||||||
if job is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
await advance_job(job=job, services=services, settings=settings, session=session)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def _finalize_transcribed(
|
|
||||||
*,
|
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
result: TranscriptionResult,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Transaction B: job transcription output + TRANSCRIBED in one commit."""
|
|
||||||
if session is None:
|
|
||||||
async with services.jobs._session_scope() as local_session:
|
|
||||||
await services.transcriptions.update_job_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
prompt_name=result.prompt_name,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
updated_job = await services.jobs.mark_job_status(
|
|
||||||
job.id,
|
|
||||||
JobStatus.TRANSCRIBED,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
await local_session.commit()
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
await services.transcriptions.update_job_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
prompt_name=result.prompt_name,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
updated_job = await services.jobs.mark_job_status(
|
|
||||||
job.id,
|
|
||||||
JobStatus.TRANSCRIBED,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
|
|
||||||
async def _finalize_retry(
|
|
||||||
*,
|
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
error: AppError,
|
|
||||||
settings: Settings,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Transaction C: job error detail + QUEUED + retry increment in one commit."""
|
|
||||||
if session is None:
|
|
||||||
async with services.jobs._session_scope() as local_session:
|
|
||||||
await services.transcriptions.update_job_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
updated_job = await services.jobs.update_job_state(
|
|
||||||
job_id=job.id,
|
|
||||||
status=JobStatus.QUEUED,
|
|
||||||
retry_count_increment=1,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
await local_session.commit()
|
|
||||||
else:
|
|
||||||
await services.transcriptions.update_job_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
updated_job = await services.jobs.update_job_state(
|
|
||||||
job_id=job.id,
|
|
||||||
status=JobStatus.QUEUED,
|
|
||||||
retry_count_increment=1,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
if settings.worker_retry_backoff_seconds > 0:
|
|
||||||
await asyncio.sleep(settings.worker_retry_backoff_seconds)
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
|
|
||||||
async def _finalize_failed(
|
|
||||||
*,
|
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
error: AppError,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Transaction B: job error detail + FAILED in one commit."""
|
|
||||||
if session is None:
|
|
||||||
async with services.jobs._session_scope() as local_session:
|
|
||||||
await services.transcriptions.update_job_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
updated_job = await services.jobs.mark_job_status(
|
|
||||||
job.id,
|
|
||||||
JobStatus.FAILED,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
await local_session.commit()
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
await services.transcriptions.update_job_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
updated_job = await services.jobs.mark_job_status(
|
|
||||||
job.id,
|
|
||||||
JobStatus.FAILED,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_primary_source(job: Job) -> Source | None:
|
|
||||||
if not job.job_sources:
|
|
||||||
return None
|
|
||||||
return next((job_source.source for job_source in job.job_sources if job_source.source is not None), None)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_job_sources(job: Job) -> list[Source]:
|
|
||||||
"""Resolve non-transcribed linked sources for a job in deterministic page order."""
|
|
||||||
if not job.job_sources:
|
|
||||||
return []
|
|
||||||
|
|
||||||
sources = [
|
|
||||||
job_source.source
|
|
||||||
for job_source in job.job_sources
|
|
||||||
if job_source.source is not None and job_source.status != JobSourceStatus.TRANSCRIBED
|
|
||||||
]
|
|
||||||
return list(sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
|
|
||||||
|
|
||||||
|
|
||||||
async def _job_no_longer_processing(
|
|
||||||
*,
|
|
||||||
job_id,
|
|
||||||
services: ServiceBundle,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Return True when job status changed externally from PROCESSING."""
|
|
||||||
latest_job = await services.jobs.read_job(job_id=job_id, session=session)
|
|
||||||
return latest_job.status != JobStatus.PROCESSING
|
|
||||||
|
|
||||||
|
|
||||||
async def _finalize_batch_outcome(
|
|
||||||
*,
|
|
||||||
job: Job,
|
|
||||||
services: ServiceBundle,
|
|
||||||
successful_pages: list[tuple[Source, TranscriptionResult]],
|
|
||||||
failed_pages: list[tuple[Source, AppError]],
|
|
||||||
status: JobStatus,
|
|
||||||
session: AsyncSession | None = None,
|
|
||||||
) -> Job:
|
|
||||||
"""Transaction B: write per-source outcomes and terminal job status atomically."""
|
|
||||||
if session is None:
|
|
||||||
async with services.jobs._session_scope() as local_session:
|
|
||||||
for source, result in successful_pages:
|
|
||||||
await services.transcriptions.update_job_source_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source.id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
prompt_name=result.prompt_name,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
for source, error in failed_pages:
|
|
||||||
await services.transcriptions.update_job_source_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=local_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_job = await services.jobs.mark_job_status(job.id, status, session=local_session)
|
|
||||||
await local_session.commit()
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
for source, result in successful_pages:
|
|
||||||
await services.transcriptions.update_job_source_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source.id,
|
|
||||||
text=result.text,
|
|
||||||
error_detail=None,
|
|
||||||
provider=result.provider,
|
|
||||||
model=result.model,
|
|
||||||
prompt_name=result.prompt_name,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
for source, error in failed_pages:
|
|
||||||
await services.transcriptions.update_job_source_transcription(
|
|
||||||
job_id=job.id,
|
|
||||||
source_id=source.id,
|
|
||||||
text=None,
|
|
||||||
error_detail=format_error_detail(error),
|
|
||||||
prompt_name=DEFAULT_PROMPT_FILE,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_job = await services.jobs.mark_job_status(job.id, status, session=session)
|
|
||||||
await session.commit()
|
|
||||||
return updated_job
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
|
|
||||||
text_chars = len(result.text)
|
|
||||||
text_lines = _line_count(result.text)
|
|
||||||
|
|
||||||
if settings.worker_fail_on_finish_reason_length and (result.finish_reason or "").lower() == "length":
|
|
||||||
raise AppError(
|
|
||||||
"Provider output appears truncated (finish_reason=length)",
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion=(
|
|
||||||
"Retry the job. If this repeats, use a faster model, reduce input complexity, "
|
|
||||||
"or increase provider output budget."
|
|
||||||
),
|
|
||||||
retriable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.worker_min_transcription_chars > 0 and text_chars < settings.worker_min_transcription_chars:
|
|
||||||
raise AppError(
|
|
||||||
(
|
|
||||||
"Transcription output below configured minimum character threshold "
|
|
||||||
f"({text_chars} < {settings.worker_min_transcription_chars})"
|
|
||||||
),
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion=(
|
|
||||||
"Retry the job. If this repeats, switch model or raise minimum thresholds based on document type."
|
|
||||||
),
|
|
||||||
retriable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.worker_min_transcription_lines > 0 and text_lines < settings.worker_min_transcription_lines:
|
|
||||||
raise AppError(
|
|
||||||
(
|
|
||||||
"Transcription output below configured minimum line threshold "
|
|
||||||
f"({text_lines} < {settings.worker_min_transcription_lines})"
|
|
||||||
),
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion=(
|
|
||||||
"Retry the job. If this repeats, switch model or raise minimum thresholds based on document type."
|
|
||||||
),
|
|
||||||
retriable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _line_count(text: str) -> int:
|
|
||||||
stripped = text.strip()
|
|
||||||
if not stripped:
|
|
||||||
return 0
|
|
||||||
return sum(1 for line in stripped.splitlines() if line.strip())
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
"""UI page registration exports."""
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.pages.home_page import register_page as register_home_page
|
|
||||||
from transcription.ui.pages.documents_page import register_page as register_documents_page
|
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
|
||||||
from transcription.ui.pages.people_page import register_page as register_people_page
|
|
||||||
from transcription.ui.pages.sources_page import register_page as register_sources_page
|
|
||||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
|
||||||
from transcription.ui.resources import read_css
|
|
||||||
|
|
||||||
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
|
||||||
|
|
||||||
|
|
||||||
def _register_global_styles(app: FastAPI) -> None:
|
|
||||||
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
|
||||||
return
|
|
||||||
|
|
||||||
ui.add_css(read_css("theme.css"), shared=True)
|
|
||||||
|
|
||||||
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
|
||||||
|
|
||||||
|
|
||||||
def register_pages(app: FastAPI) -> None:
|
|
||||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
|
||||||
_register_global_styles(app)
|
|
||||||
register_home_page()
|
|
||||||
register_upload_page()
|
|
||||||
register_documents_page()
|
|
||||||
register_people_page()
|
|
||||||
register_sources_page()
|
|
||||||
register_jobs_page()
|
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Reusable UI component exports."""
|
|
||||||
|
|
||||||
from transcription.ui.components.app_shell import NAV_ITEMS
|
|
||||||
from transcription.ui.components.app_shell import render_app_shell
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
|
||||||
from transcription.ui.components.primitives import destructive_button
|
|
||||||
from transcription.ui.components.primitives import render_empty_state
|
|
||||||
from transcription.ui.components.primitives import section_header_row
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"NAV_ITEMS",
|
|
||||||
"destructive_button",
|
|
||||||
"render_app_shell",
|
|
||||||
"render_document_panzoom",
|
|
||||||
"render_empty_state",
|
|
||||||
"render_navigation_header",
|
|
||||||
"section_header_row",
|
|
||||||
]
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""Reusable app shell primitives for page-level layout."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.resources import read_css
|
|
||||||
|
|
||||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
|
||||||
("Documents", "/documents", "description"),
|
|
||||||
("People", "/people", "group"),
|
|
||||||
("Sources", "/sources", "folder"),
|
|
||||||
("Jobs", "/jobs", "work_history"),
|
|
||||||
)
|
|
||||||
|
|
||||||
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
|
|
||||||
|
|
||||||
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
|
||||||
if item_path == "/jobs":
|
|
||||||
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
|
||||||
if item_path == "/documents":
|
|
||||||
return current_path == "/documents" or current_path.startswith("/documents/")
|
|
||||||
if item_path == "/people":
|
|
||||||
return current_path == "/people" or current_path.startswith("/people/")
|
|
||||||
if item_path == "/sources":
|
|
||||||
return current_path == "/sources" or current_path.startswith("/sources/")
|
|
||||||
return current_path == item_path
|
|
||||||
|
|
||||||
|
|
||||||
def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None:
|
|
||||||
is_active = _is_active_path(current_path=current_path, item_path=path)
|
|
||||||
classes = "app-shell__nav-item"
|
|
||||||
if is_active:
|
|
||||||
classes = f"{classes} app-shell__nav-item--active"
|
|
||||||
|
|
||||||
ui.button(
|
|
||||||
label,
|
|
||||||
icon=icon,
|
|
||||||
on_click=lambda _=None, route=path: ui.navigate.to(route),
|
|
||||||
).props("flat no-caps").classes(classes)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_path(current_path: str | None) -> str:
|
|
||||||
normalized = (current_path or "").strip()
|
|
||||||
if not normalized:
|
|
||||||
return "/homepage"
|
|
||||||
return normalized.rstrip("/") or "/"
|
|
||||||
|
|
||||||
|
|
||||||
def render_app_shell(*, current_path: str | None = None) -> None:
|
|
||||||
"""Render the shared application shell header."""
|
|
||||||
ui.add_css(read_css("components/app_shell.css"))
|
|
||||||
normalized_path = _normalize_path(current_path)
|
|
||||||
|
|
||||||
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
|
|
||||||
with ui.element("a").props('href="/ui/homepage"').style(
|
|
||||||
"display:flex; align-items:center; gap:0.75rem; text-decoration:none; color:inherit;"
|
|
||||||
).classes("app-shell__brand no-wrap"):
|
|
||||||
ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark")
|
|
||||||
ui.label("VibeScribe").classes("app-shell__brand-name")
|
|
||||||
|
|
||||||
with ui.element("nav").props('aria-label="Primary navigation"').classes("app-shell__nav"):
|
|
||||||
for label, path, icon in NAV_ITEMS:
|
|
||||||
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
|
|
||||||
|
|
||||||
with ui.row().classes("app-shell__actions no-wrap"):
|
|
||||||
ui.label("Saved").classes("app-shell__save-state")
|
|
||||||
ui.button(icon="more_horiz").props("flat round dense").tooltip("More actions")
|
|
||||||
|
|
||||||
|
|
||||||
def render_navigation_header(*, current_path: str | None = None) -> None:
|
|
||||||
"""Render the app shell using the legacy page-level entry point."""
|
|
||||||
render_app_shell(current_path=current_path)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# transcription/ui/components/cards.py
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def archival_card(title: str | None = None, extra_classes: str = ""):
|
|
||||||
"""Reusable container for Flat 2.0 Bento Grid cards."""
|
|
||||||
with ui.card().classes(f"w-full ui-card-surface p-4 {extra_classes}") as card:
|
|
||||||
if title:
|
|
||||||
ui.label(title.upper()).classes(
|
|
||||||
"text-xs font-bold ui-text-muted tracking-wider mb-3 ui-header-divider pb-1"
|
|
||||||
)
|
|
||||||
yield card
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# transcription/ui/components/data_display.py
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
|
|
||||||
def metadata_row(label: str, value: str):
|
|
||||||
"""Render a high-density, low-contrast key-value pair."""
|
|
||||||
with ui.row().classes("justify-between w-full border-b ui-border-subtle pb-1 text-xs"):
|
|
||||||
ui.label(label).classes("ui-text-muted")
|
|
||||||
ui.label(value).classes("font-semibold ui-text-primary")
|
|
||||||
|
|
||||||
|
|
||||||
def archival_badge(text: str):
|
|
||||||
"""Standardized Aged Sepia badge."""
|
|
||||||
return ui.badge(text, color="secondary", text_color="dark").classes("text-[10px]")
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
"""Panzoom-backed document preview component."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db.models import Source
|
|
||||||
|
|
||||||
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
|
||||||
UPLOADS_URL_PREFIX = "/uploads"
|
|
||||||
|
|
||||||
|
|
||||||
def render_document_panzoom(*, source: Source) -> None:
|
|
||||||
"""Render a source preview with pan and zoom interactions."""
|
|
||||||
_register_panzoom_assets()
|
|
||||||
|
|
||||||
host_id = f"document-panzoom-{uuid4().hex}"
|
|
||||||
document_url = _document_url(source)
|
|
||||||
document_kind = _document_kind(source)
|
|
||||||
|
|
||||||
with ui.card().classes("w-full q-pa-md vibe-card"):
|
|
||||||
with ui.row().classes("w-full items-center justify-between no-wrap"):
|
|
||||||
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.label(source.filename).classes("text-caption vibe-text-muted ellipsis").style(
|
|
||||||
"max-width: 60%; text-align: right;"
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
|
||||||
ui.element("div").classes("w-full document-panzoom-host rounded-borders q-mt-md")
|
|
||||||
# .style(f"height: {height};")
|
|
||||||
) as host:
|
|
||||||
host.props(f"id={host_id}")
|
|
||||||
with ui.element("div").classes("document-panzoom-surface"):
|
|
||||||
if document_kind == "pdf":
|
|
||||||
ui.html(
|
|
||||||
f'<iframe class="document-panzoom-iframe" '
|
|
||||||
f'src="{document_url}" title="{source.filename}" '
|
|
||||||
"data-panzoom-target></iframe>"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ui.html(
|
|
||||||
f'<img class="document-panzoom-media" '
|
|
||||||
f'src="{document_url}" alt="{source.filename}" '
|
|
||||||
"data-panzoom-target data-panzoom-media />"
|
|
||||||
)
|
|
||||||
|
|
||||||
_attach_panzoom(host_id)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _register_panzoom_assets() -> None:
|
|
||||||
ui.add_head_html(
|
|
||||||
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
|
|
||||||
shared=True,
|
|
||||||
)
|
|
||||||
ui.add_head_html(
|
|
||||||
"""
|
|
||||||
<style>
|
|
||||||
.document-panzoom-host {
|
|
||||||
overflow: hidden;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-panzoom-surface {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-panzoom-media {
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-panzoom-iframe {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
background: var(--theme-surface-raised);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
""",
|
|
||||||
shared=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _document_url(source: Source) -> str:
|
|
||||||
file_path = Path(source.file_path)
|
|
||||||
upload_dir = get_settings().upload_dir
|
|
||||||
|
|
||||||
relative_path: Path
|
|
||||||
try:
|
|
||||||
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
parts = file_path.parts
|
|
||||||
if "uploads" in parts:
|
|
||||||
uploads_index = parts.index("uploads")
|
|
||||||
relative_path = Path(*parts[uploads_index + 1 :])
|
|
||||||
else:
|
|
||||||
relative_path = Path(file_path.name)
|
|
||||||
|
|
||||||
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
|
||||||
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
|
||||||
|
|
||||||
|
|
||||||
def _document_kind(source: Source) -> str:
|
|
||||||
suffix = Path(source.file_path).suffix.lower()
|
|
||||||
if suffix == ".pdf":
|
|
||||||
return "pdf"
|
|
||||||
return "image"
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_panzoom(host_id: str) -> None:
|
|
||||||
ui.run_javascript(
|
|
||||||
f"""
|
|
||||||
(function() {{
|
|
||||||
if (!window.Panzoom) return;
|
|
||||||
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
|
|
||||||
const host = document.getElementById({host_id!r});
|
|
||||||
if (!host) return;
|
|
||||||
const target = host.querySelector('[data-panzoom-target]');
|
|
||||||
const media = host.querySelector('[data-panzoom-media]');
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const cleanup = () => {{
|
|
||||||
const existing = window.__transcriptionPanzoom[{host_id!r}];
|
|
||||||
if (existing?.resizeObserver) existing.resizeObserver.disconnect();
|
|
||||||
if (existing?.wheelHandler) host.removeEventListener('wheel', existing.wheelHandler);
|
|
||||||
if (existing?.instance) existing.instance.destroy();
|
|
||||||
}};
|
|
||||||
|
|
||||||
const computeFitScale = () => {{
|
|
||||||
const hostRect = host.getBoundingClientRect();
|
|
||||||
if (hostRect.width <= 0 || hostRect.height <= 0) return null;
|
|
||||||
return 1;
|
|
||||||
}};
|
|
||||||
|
|
||||||
const buildInstance = () => {{
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
const fitScale = computeFitScale();
|
|
||||||
if (fitScale === null) return false;
|
|
||||||
|
|
||||||
const minScale = Math.min(fitScale, 0.01);
|
|
||||||
const instance = Panzoom(target, {{
|
|
||||||
startX: 0,
|
|
||||||
startY: 0,
|
|
||||||
startScale: fitScale,
|
|
||||||
minScale: minScale,
|
|
||||||
maxScale: 256,
|
|
||||||
step: 0.2,
|
|
||||||
roundPixels: false,
|
|
||||||
panOnlyWhenZoomed: true,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}});
|
|
||||||
|
|
||||||
const wheelHandler = (event) => instance.zoomWithWheel(event);
|
|
||||||
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {{
|
|
||||||
instance.reset({{ animate: false }});
|
|
||||||
}});
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {{
|
|
||||||
const nextFitScale = computeFitScale();
|
|
||||||
if (nextFitScale === null) return;
|
|
||||||
instance.setOptions({{
|
|
||||||
startScale: nextFitScale,
|
|
||||||
minScale: Math.min(nextFitScale, 0.01),
|
|
||||||
}});
|
|
||||||
instance.reset({{ animate: false }});
|
|
||||||
}});
|
|
||||||
resizeObserver.observe(host);
|
|
||||||
|
|
||||||
window.__transcriptionPanzoom[{host_id!r}] = {{
|
|
||||||
instance,
|
|
||||||
wheelHandler,
|
|
||||||
resizeObserver,
|
|
||||||
}};
|
|
||||||
return true;
|
|
||||||
}};
|
|
||||||
|
|
||||||
const initWhenReady = (retries = 15) => {{
|
|
||||||
if (buildInstance()) return;
|
|
||||||
if (retries <= 0) return;
|
|
||||||
requestAnimationFrame(() => initWhenReady(retries - 1));
|
|
||||||
}};
|
|
||||||
|
|
||||||
if (media && media.tagName === 'IMG' && !media.complete) {{
|
|
||||||
media.addEventListener('load', () => initWhenReady(), {{ once: true }});
|
|
||||||
return;
|
|
||||||
}}
|
|
||||||
|
|
||||||
initWhenReady();
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Shared UI error rendering helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.errors import AppError
|
|
||||||
from transcription.errors import ErrorCategory
|
|
||||||
from transcription.errors import classify_unexpected_error
|
|
||||||
|
|
||||||
|
|
||||||
def to_app_error(exc: Exception, *, operation: str) -> AppError:
|
|
||||||
"""Normalize any exception for consistent UI display."""
|
|
||||||
if isinstance(exc, AppError):
|
|
||||||
return exc
|
|
||||||
return classify_unexpected_error(exc, operation=operation)
|
|
||||||
|
|
||||||
|
|
||||||
def show_error(exc: Exception, *, title: str, operation: str) -> None:
|
|
||||||
"""Display a visible, actionable UI error with trace id."""
|
|
||||||
error = to_app_error(exc, operation=operation)
|
|
||||||
ui.notify(
|
|
||||||
f"{title}: {error.message} (ref: {error.error_id})",
|
|
||||||
type="negative",
|
|
||||||
timeout=0,
|
|
||||||
close_button="Dismiss",
|
|
||||||
)
|
|
||||||
|
|
||||||
with ui.card().classes("vibe-card--error q-mt-md q-pa-md"):
|
|
||||||
ui.label(title).classes("text-subtitle1")
|
|
||||||
ui.label(error.message)
|
|
||||||
ui.label(f"Suggested action: {error.suggestion}").classes("text-weight-medium")
|
|
||||||
ui.label(f"Error reference: {error.error_id}").classes("text-caption")
|
|
||||||
ui.label(f"Category: {error.category.value}").classes("text-caption")
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_error(exc: Exception, *, operation: str) -> str:
|
|
||||||
"""Return short one-line summary for status labels."""
|
|
||||||
error = to_app_error(exc, operation=operation)
|
|
||||||
if error.category == ErrorCategory.INTERNAL_UNEXPECTED:
|
|
||||||
return f"Unexpected error (ref: {error.error_id})"
|
|
||||||
return f"{error.message} (ref: {error.error_id})"
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def section_header_row(*, classes: str = ""):
|
|
||||||
"""Render a standardized section header row container."""
|
|
||||||
base_classes = "w-full items-center justify-between pb-2 ui-header-divider"
|
|
||||||
with ui.row().classes(f"{base_classes} {classes}".strip()) as row:
|
|
||||||
yield row
|
|
||||||
|
|
||||||
|
|
||||||
def render_empty_state(message: str, *, italic: bool = False, extra_classes: str = "") -> None:
|
|
||||||
"""Render standardized empty-state helper text."""
|
|
||||||
classes = "text-xs ui-text-muted"
|
|
||||||
if italic:
|
|
||||||
classes = f"{classes} italic"
|
|
||||||
ui.label(message).classes(f"{classes} {extra_classes}".strip())
|
|
||||||
|
|
||||||
|
|
||||||
def destructive_button(
|
|
||||||
label: str,
|
|
||||||
*,
|
|
||||||
on_click,
|
|
||||||
icon: str,
|
|
||||||
variant: str = "outlined",
|
|
||||||
extra_classes: str = "",
|
|
||||||
):
|
|
||||||
"""Render a standardized destructive action button."""
|
|
||||||
button = ui.button(label, on_click=on_click, icon=icon)
|
|
||||||
if variant == "solid":
|
|
||||||
button.props("unelevated color=negative")
|
|
||||||
else:
|
|
||||||
button.props("outlined color=negative")
|
|
||||||
if extra_classes:
|
|
||||||
button.classes(extra_classes)
|
|
||||||
return button
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
from .jobs import JobTableRow
|
|
||||||
from .jobs import render_jobs_table
|
|
||||||
|
|
||||||
__all__ = ["JobTableRow", "render_jobs_table"]
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"""Common logic for generating table widgets."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nicegui import events
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_row_id(args: Any) -> str | None:
|
|
||||||
if isinstance(args, dict):
|
|
||||||
if isinstance(args.get("row"), dict):
|
|
||||||
row_id = args["row"].get("id")
|
|
||||||
return str(row_id) if row_id is not None else None
|
|
||||||
row_id = args.get("id")
|
|
||||||
return str(row_id) if row_id is not None else None
|
|
||||||
|
|
||||||
if isinstance(args, list):
|
|
||||||
for value in args:
|
|
||||||
if isinstance(value, dict):
|
|
||||||
row_id = value.get("id")
|
|
||||||
if row_id is not None:
|
|
||||||
return str(row_id)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _bind_row_click_handler(
|
|
||||||
table: Any,
|
|
||||||
*,
|
|
||||||
on_row_click_id: Callable[[str], None],
|
|
||||||
) -> None:
|
|
||||||
def handle_row_click(event: events.GenericEventArguments) -> None:
|
|
||||||
row_id = _extract_row_id(event.args)
|
|
||||||
if row_id is None:
|
|
||||||
return
|
|
||||||
on_row_click_id(row_id)
|
|
||||||
|
|
||||||
table.on("rowClick", handle_row_click)
|
|
||||||
logger.debug("Row click handler bound to table")
|
|
||||||
|
|
||||||
|
|
||||||
def build_table(
|
|
||||||
rows: list[dict[str, Any]],
|
|
||||||
columns: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
default_sort_by: str | None = None,
|
|
||||||
default_descending: bool = False,
|
|
||||||
classes: str = "app-table",
|
|
||||||
on_row_click_id: Callable[[str], None] | None = None,
|
|
||||||
) -> Any:
|
|
||||||
pagination: dict[str, Any] = {"rowsPerPage": 25}
|
|
||||||
if default_sort_by is not None:
|
|
||||||
pagination["sortBy"] = default_sort_by
|
|
||||||
pagination["descending"] = default_descending
|
|
||||||
|
|
||||||
# Quasar props enforce behavior; visual styling is centralized in theme.css.
|
|
||||||
table = (
|
|
||||||
ui.table(
|
|
||||||
rows=rows,
|
|
||||||
columns=columns,
|
|
||||||
row_key="id",
|
|
||||||
pagination=pagination,
|
|
||||||
)
|
|
||||||
.classes(f"w-full ui-table {classes}")
|
|
||||||
.props(
|
|
||||||
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
|
|
||||||
'header-cell-class="ui-table-header text-xs uppercase tracking-wider" '
|
|
||||||
'table-class="ui-table-body text-xs"'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
|
||||||
if on_row_click_id is not None:
|
|
||||||
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
|
||||||
return table
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user