generated from john/python-template
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7192a33dc | ||
|
|
5932c0d3a1 | ||
|
|
edb6967888 | ||
|
|
759d4c2434 | ||
|
|
323f12d911 | ||
|
|
f80834d589 | ||
|
|
752346025b | ||
|
|
4f6e1fd913 | ||
|
|
47aef0e26e | ||
|
|
c098013a68 | ||
|
|
49e2e48df1 | ||
|
|
0ab7ad50f2 | ||
|
|
9653060c2a | ||
|
|
ed6f9dfe25 | ||
|
|
5946867ff3 | ||
|
|
646a360aca | ||
|
|
2b3d33e50e | ||
|
|
dfe6f121ff | ||
|
|
51ac2d0b98 | ||
|
|
61cc8a200b | ||
|
|
c46d1bd0bc |
@@ -42,6 +42,13 @@ Use a fresh database. There will be no migrations, data conversion, legacy compa
|
|||||||
- Test both service-owned sessions and caller-provided sessions so flush/commit behavior remains correct.
|
- 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`.
|
- 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
|
## Done When
|
||||||
|
|
||||||
- A fresh database is created directly from the V2 SQLModel metadata.
|
- A fresh database is created directly from the V2 SQLModel metadata.
|
||||||
|
|||||||
@@ -5,20 +5,21 @@ I have several thousand pages of family history told through letters, postcards,
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
1. Preserve our family history
|
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 them).
|
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 text easily available and searchable by family members, as well as AI (which may have different requirements).
|
3. Make the document text easily available and easily searchable.
|
||||||
4. Ability create timelines or assemble the historical record of the family or specific individuals from across the complete document archive. Perhaps use AI to create the timelines in a more narrative form.
|
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
|
## Source material
|
||||||
1. **letters, cards, diaries** - handwritten; mostly stored in tubs, with little organization
|
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.
|
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. **newspaper clippings, event programs, invitations, and other ephemera**
|
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
|
## Methodology
|
||||||
|
|
||||||
1. Follow current best practices per "A Guide to Documentary Editing" by Mary-Jo Kline. (See [Transcription Methodology](transcription_methodology.md))
|
1. Follow current best practices per **A Guide to Documentary Editing** by Mary-Jo Kline. (See [Transcription Methodology](transcription_methodology.md))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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')
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# 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. |
|
||||||
@@ -12,10 +12,10 @@ def create_cli_app() -> FastAPI:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
settings = parse_cli_settings()
|
settings = parse_cli_settings()
|
||||||
application = "transcription.__main__:create_cli_app" if settings.reload else create_app(settings=settings)
|
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
application,
|
"transcription.__main__:create_cli_app",
|
||||||
factory=settings.reload,
|
factory=True,
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
port=settings.port,
|
port=settings.port,
|
||||||
log_level=settings.log_level,
|
log_level=settings.log_level,
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ from fastapi import APIRouter
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def healthz() -> dict[str, str]:
|
|
||||||
"""Return a simple health status payload."""
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/healthz")
|
@router.get("/healthz")
|
||||||
def healthz_route() -> dict[str, str]:
|
def healthz_route() -> dict[str, str]:
|
||||||
"""Route wrapper for health status payload."""
|
"""Route wrapper for health status payload."""
|
||||||
return healthz()
|
return healthz()
|
||||||
|
|
||||||
|
|
||||||
|
def healthz() -> dict[str, str]:
|
||||||
|
"""Return a simple health status payload."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ from .config import Settings
|
|||||||
from .config import configure_logging
|
from .config import configure_logging
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import create_all
|
from .db import create_all
|
||||||
|
from .db import dispose_database_runtime
|
||||||
from .db import initialize_database_runtime
|
from .db import initialize_database_runtime
|
||||||
from .db.engine import get_database_url
|
|
||||||
from .db.engine import resolve_engine
|
|
||||||
from .db.session import dispose_session_factory
|
|
||||||
from .services import ServiceBundle
|
from .services import ServiceBundle
|
||||||
from .services.jobs import JobService
|
from .services.jobs import JobService
|
||||||
from .ui import register_pages
|
from .ui.pages import register_pages
|
||||||
from .worker import worker_consumer_lifespan
|
from .worker import worker_consumer_lifespan
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -41,7 +39,7 @@ async def _lifespan(app: FastAPI):
|
|||||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
|
|
||||||
if settings.should_bootstrap_schema:
|
if settings.should_bootstrap_schema:
|
||||||
await create_all(engine=resolve_engine(settings=settings))
|
await create_all(engine=app.state.runtime.engine)
|
||||||
|
|
||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -49,10 +47,7 @@ async def _lifespan(app: FastAPI):
|
|||||||
await _recover_stale_processing_jobs(app)
|
await _recover_stale_processing_jobs(app)
|
||||||
|
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
stack.push_async_callback(
|
stack.push_async_callback(dispose_database_runtime)
|
||||||
dispose_session_factory,
|
|
||||||
database_url=get_database_url(settings),
|
|
||||||
)
|
|
||||||
stop_event, worker_notifier = await stack.enter_async_context(
|
stop_event, worker_notifier = await stack.enter_async_context(
|
||||||
worker_consumer_lifespan(
|
worker_consumer_lifespan(
|
||||||
session_factory=app.state.runtime.session_factory,
|
session_factory=app.state.runtime.session_factory,
|
||||||
@@ -95,13 +90,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/ui", include_in_schema=False)
|
@app.get("/ui", include_in_schema=False)
|
||||||
async def ui_redirect() -> RedirectResponse:
|
async def ui_redirect() -> RedirectResponse:
|
||||||
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
return RedirectResponse(url="/ui/documents", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
app.include_router(health_router)
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
register_pages(app)
|
register_pages(app)
|
||||||
app.include_router(health_router)
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
+170
-50
@@ -1,28 +1,53 @@
|
|||||||
"""SQLModel domain models for the transcription system.
|
"""SQLModel domain models for the V2 transcription system."""
|
||||||
|
|
||||||
Core V1 lifecycle:
|
|
||||||
Document -> one-to-many -> Source
|
|
||||||
Document -> one-to-many -> Job
|
|
||||||
Source -> one-to-one? -> Revision (optional)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
|
from datetime import date
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import JSON
|
||||||
|
from sqlalchemy import Column
|
||||||
from sqlalchemy import UniqueConstraint
|
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 Field
|
||||||
from sqlmodel import Relationship
|
from sqlmodel import Relationship
|
||||||
from sqlmodel import SQLModel
|
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):
|
class JobStatus(StrEnum):
|
||||||
QUEUED = "queued"
|
QUEUED = "queued"
|
||||||
PROCESSING = "processing"
|
PROCESSING = "processing"
|
||||||
TRANSCRIBED = "transcribed"
|
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"
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
@@ -31,32 +56,90 @@ class Document(SQLModel, table=True):
|
|||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
name: str
|
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))
|
||||||
|
|
||||||
# Relationships
|
jobs: list["Job"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||||
jobs: list["Job"] = Relationship(back_populates="document")
|
sources: list["Source"] = Relationship(back_populates="document", sa_relationship_kwargs={"lazy": "selectin"})
|
||||||
sources: list["Source"] = Relationship(back_populates="document")
|
document_people: list["DocumentPerson"] = Relationship(
|
||||||
|
back_populates="document", sa_relationship_kwargs={"lazy": "selectin"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def authors(self):
|
||||||
|
"""Return linked people whose role is AUTHOR."""
|
||||||
|
return [
|
||||||
|
link.person
|
||||||
|
for link in self.document_people
|
||||||
|
if link.role == DocumentPersonRole.AUTHOR and link.person is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def author(self):
|
||||||
|
"""Return the first linked author for convenience in read paths."""
|
||||||
|
return self.authors[0] if self.authors else None
|
||||||
|
|
||||||
|
|
||||||
class Source(SQLModel, table=True):
|
class Person(SQLModel, table=True):
|
||||||
"""A document source (image or PDF)."""
|
"""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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def authored_documents(self):
|
||||||
|
"""Return documents where this person is linked as AUTHOR."""
|
||||||
|
return [
|
||||||
|
link.document
|
||||||
|
for link in self.document_people
|
||||||
|
if link.role == DocumentPersonRole.AUTHOR and link.document is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentPerson(SQLModel, table=True):
|
||||||
|
"""Associates documents with people in a given role."""
|
||||||
|
|
||||||
|
__tablename__: str = "document_person"
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
document_id: UUID = Field(foreign_key="document.id")
|
document_id: UUID = Field(foreign_key="document.id")
|
||||||
job_id: UUID = Field(foreign_key="job.id")
|
person_id: UUID = Field(foreign_key="person.id")
|
||||||
upload_name: str
|
role: DocumentPersonRole = Field(default=DocumentPersonRole.AUTHOR)
|
||||||
"""The filename of the source that was uploaded for transcription."""
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
filename: str
|
|
||||||
"""The system generated unique source name."""
|
|
||||||
file_path: str
|
|
||||||
"""The location where the sources are stored on the local filesystem."""
|
|
||||||
date_uploaded: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
|
||||||
|
|
||||||
# Relationships
|
__table_args__ = (UniqueConstraint("document_id", "person_id", "role", name="uq_document_person_role"),)
|
||||||
document: Optional["Document"] = Relationship(back_populates="sources")
|
|
||||||
job: Optional["Job"] = Relationship(back_populates="sources")
|
document: Optional["Document"] = Relationship(
|
||||||
revision: Optional["Revision"] = Relationship(
|
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
|
||||||
back_populates="source",
|
)
|
||||||
sa_relationship_kwargs={"uselist": False},
|
person: Optional["Person"] = Relationship(
|
||||||
|
back_populates="document_people", sa_relationship_kwargs={"lazy": "selectin"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,41 +153,78 @@ class Job(SQLModel, table=True):
|
|||||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
date_updated: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
provider: str | None = None
|
provider: str | None = None
|
||||||
"""Name of the transcription provider used to generate this transcript."""
|
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
"""Model identifier used to generate this transcript."""
|
|
||||||
prompt_name: str | None = None
|
prompt_name: str | None = None
|
||||||
"""Name of the prompt used to generate this transcript."""
|
|
||||||
text: str | None = None
|
|
||||||
"""The transcribed text. This may be None if the job failed or is still in progress."""
|
|
||||||
error_detail: str | None = None
|
|
||||||
"""Details of any error that occurred during transcription."""
|
|
||||||
|
|
||||||
# Relationships
|
document: Optional["Document"] = Relationship(back_populates="jobs", sa_relationship_kwargs={"lazy": "selectin"})
|
||||||
document: Optional["Document"] = Relationship(back_populates="jobs")
|
job_sources: list["JobSource"] = Relationship(back_populates="job", sa_relationship_kwargs={"lazy": "selectin"})
|
||||||
sources: list["Source"] = Relationship(back_populates="job")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filename(self) -> str:
|
def filename(self) -> str:
|
||||||
"""Return the filename of the associated source, when available."""
|
"""Return the filename of the associated source, when available."""
|
||||||
if not self.sources:
|
if not self.job_sources:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
return self.sources[0].filename
|
|
||||||
|
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 Revision(SQLModel, table=True):
|
class Source(SQLModel, table=True):
|
||||||
"""A revision of a transcription text."""
|
"""A document source image or PDF page."""
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
|
document_id: UUID = Field(foreign_key="document.id")
|
||||||
|
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__: str = "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")
|
source_id: UUID = Field(foreign_key="source.id")
|
||||||
"""ID for the associated source."""
|
status: JobSourceStatus = Field(default=JobSourceStatus.PENDING)
|
||||||
revision: int = Field(default=1, ge=1)
|
raw_transcription: str | None = None
|
||||||
"""Revision number of this transcription revision, starting at 1."""
|
ai_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||||
text: str
|
raw_api_response: dict[str, Any] | None = Field(default=None, sa_column=Column(JSONBCompat(), nullable=True))
|
||||||
"""The revised text."""
|
error_detail: str | None = None
|
||||||
date_created: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
executed_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
__table_args__ = (UniqueConstraint("source_id", name="uq_revision_source_id"),)
|
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"})
|
||||||
# Relationships
|
|
||||||
source: Optional["Source"] = Relationship(back_populates="revision")
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import inspect
|
|
||||||
from sqlalchemy import text
|
|
||||||
from sqlalchemy.engine import Connection
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
@@ -18,14 +15,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
async def create_all(*, engine: AsyncEngine | None = None) -> None:
|
||||||
"""Create all tables on the selected engine."""
|
"""Create any missing tables on the selected engine."""
|
||||||
# Import models so SQLModel metadata is fully registered before bootstrap.
|
# Import models so SQLModel metadata is fully registered before bootstrap.
|
||||||
from transcription.db import models as _models # noqa: F401
|
from transcription.db import models as _models # noqa: F401
|
||||||
|
|
||||||
active_engine = engine or resolve_engine()
|
active_engine = engine or resolve_engine()
|
||||||
async with active_engine.begin() as connection:
|
async with active_engine.begin() as connection:
|
||||||
await connection.run_sync(SQLModel.metadata.create_all)
|
await connection.run_sync(SQLModel.metadata.create_all)
|
||||||
await connection.run_sync(_ensure_sqlite_compat_columns)
|
|
||||||
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,38 +34,3 @@ async def get_next_queued_job(*, session: AsyncSession) -> Job | None:
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
) # fmt: skip
|
) # fmt: skip
|
||||||
return result.first()
|
return result.first()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
|
||||||
"""Apply lightweight dev/test SQLite compatibility column patches.
|
|
||||||
|
|
||||||
This keeps local bootstrap resilient when models evolve but no full
|
|
||||||
migration tooling is in place yet.
|
|
||||||
"""
|
|
||||||
if connection.engine.url.get_backend_name() != "sqlite":
|
|
||||||
return
|
|
||||||
|
|
||||||
inspector = inspect(connection)
|
|
||||||
table_names = set(inspector.get_table_names())
|
|
||||||
|
|
||||||
if "job" in table_names:
|
|
||||||
job_columns = {column["name"] for column in inspector.get_columns("job")}
|
|
||||||
if "retry_count" not in job_columns:
|
|
||||||
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
|
|
||||||
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
|
||||||
|
|
||||||
if "revision" in table_names:
|
|
||||||
revision_columns = {column["name"] for column in inspector.get_columns("revision")}
|
|
||||||
if "source_id" in revision_columns:
|
|
||||||
has_unique_source = False
|
|
||||||
for index in inspector.get_indexes("revision"):
|
|
||||||
if index.get("unique") and index.get("column_names") == ["source_id"]:
|
|
||||||
has_unique_source = True
|
|
||||||
break
|
|
||||||
if not has_unique_source:
|
|
||||||
connection.execute(
|
|
||||||
text("CREATE UNIQUE INDEX IF NOT EXISTS ux_revision_source_id ON revision(source_id)")
|
|
||||||
)
|
|
||||||
logger.warning(
|
|
||||||
"Applied SQLite compatibility schema patch table=revision unique_index=ux_revision_source_id"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from dataclasses import field
|
from dataclasses import field
|
||||||
|
|
||||||
|
from ..db.session import SessionFactory
|
||||||
from .documents import DocumentService
|
from .documents import DocumentService
|
||||||
from .jobs import JobService
|
from .jobs import JobService
|
||||||
from .transcription import TranscriptionService
|
from .transcription import TranscriptionService
|
||||||
@@ -17,3 +18,12 @@ class ServiceBundle:
|
|||||||
documents: DocumentService = field(default_factory=DocumentService)
|
documents: DocumentService = field(default_factory=DocumentService)
|
||||||
jobs: JobService = field(default_factory=JobService)
|
jobs: JobService = field(default_factory=JobService)
|
||||||
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
|
transcriptions: TranscriptionService = field(default_factory=TranscriptionService)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_session_factory(cls, session_factory: SessionFactory) -> "ServiceBundle":
|
||||||
|
"""Create a ServiceBundle from a session factory."""
|
||||||
|
return cls(
|
||||||
|
documents=DocumentService(session_factory=session_factory),
|
||||||
|
jobs=JobService(session_factory=session_factory),
|
||||||
|
transcriptions=TranscriptionService(session_factory=session_factory),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -10,6 +12,8 @@ from sqlmodel import select
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..db.models import Document
|
from ..db.models import Document
|
||||||
|
from ..db.models import DocumentPerson
|
||||||
|
from ..db.models import Person
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
@@ -33,6 +37,14 @@ class DocumentAlreadyExistsError(DocumentError):
|
|||||||
"""Raised when a document with the same name already exists in the database."""
|
"""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)
|
@dataclass(frozen=True)
|
||||||
class UploadJobResult:
|
class UploadJobResult:
|
||||||
"""Summary of created upload records."""
|
"""Summary of created upload records."""
|
||||||
@@ -100,6 +112,7 @@ class DocumentService(ServiceBase):
|
|||||||
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
||||||
"""Update an existing document in the database."""
|
"""Update an existing document in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
|
document.updated_at = datetime.now(UTC)
|
||||||
merged = await _session.merge(document)
|
merged = await _session.merge(document)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
return merged
|
return merged
|
||||||
@@ -107,7 +120,163 @@ class DocumentService(ServiceBase):
|
|||||||
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a document from the database."""
|
"""Delete a document from the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
await _session.delete(document)
|
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)
|
||||||
|
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing.document_people:
|
||||||
|
raise PersonDeleteBlockedError(
|
||||||
|
"Person delete blocked by linked documents",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Remove linked DocumentPerson records first, then retry deletion.",
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
# Query Operations
|
# Query Operations
|
||||||
@@ -128,3 +297,51 @@ class DocumentService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
result = await _session.exec(select(Document))
|
result = await _session.exec(select(Document))
|
||||||
return result.all()
|
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()
|
||||||
|
|||||||
@@ -7,12 +7,19 @@ from sqlalchemy.orm import selectinload
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import ErrorCategory
|
||||||
from ..db.models import Job
|
from ..db.models import Job
|
||||||
|
from ..db.models import JobSource
|
||||||
from ..db.models import JobStatus
|
from ..db.models import JobStatus
|
||||||
from ..db.models import Source
|
from ..db.models import Source
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
|
|
||||||
|
|
||||||
|
class JobDeleteBlockedError(AppError):
|
||||||
|
"""Raised when a job delete operation is blocked by lifecycle policy."""
|
||||||
|
|
||||||
|
|
||||||
class JobService(ServiceBase):
|
class JobService(ServiceBase):
|
||||||
"""Thin service class for managing jobs in the database."""
|
"""Thin service class for managing jobs in the database."""
|
||||||
|
|
||||||
@@ -38,7 +45,7 @@ class JobService(ServiceBase):
|
|||||||
select(Job)
|
select(Job)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType]
|
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
.where(Job.id == job_id)
|
.where(Job.id == job_id)
|
||||||
.execution_options(populate_existing=True)
|
.execution_options(populate_existing=True)
|
||||||
@@ -74,12 +81,12 @@ class JobService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job).options(
|
query = select(Job).options(
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
selectinload(Job.sources), # pyright: ignore[reportArgumentType]
|
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.where(Job.status == status)
|
query = query.where(Job.status == status)
|
||||||
if filename is not None:
|
if filename is not None:
|
||||||
query = query.where(Job.sources.any(Source.filename == filename))
|
query = query.where(Job.job_sources.any(JobSource.source.has(Source.filename == filename)))
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
@@ -94,7 +101,7 @@ class JobService(ServiceBase):
|
|||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job).options(
|
query = select(Job).options(
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
selectinload(Job.sources), # pyright: ignore[reportArgumentType]
|
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
@@ -151,10 +158,11 @@ class JobService(ServiceBase):
|
|||||||
select(Job)
|
select(Job)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
selectinload(Job.sources).selectinload(Source.revision), # pyright: ignore[reportArgumentType]
|
selectinload(Job.job_sources).selectinload(JobSource.source), # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
.where(Job.status == JobStatus.QUEUED)
|
||||||
.order_by(Job.date_created) # pyright: ignore[reportArgumentType]
|
# 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()
|
return (await _session.exec(query)).first()
|
||||||
|
|
||||||
@@ -182,3 +190,34 @@ class JobService(ServiceBase):
|
|||||||
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
await self._finalize(session=_session, caller_session=session, refresh=stale_jobs)
|
||||||
return len(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)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
@@ -13,18 +17,30 @@ from transcription.errors import ErrorCategory
|
|||||||
|
|
||||||
from ..db.models import Document
|
from ..db.models import Document
|
||||||
from ..db.models import Job
|
from ..db.models import Job
|
||||||
|
from ..db.models import JobSource
|
||||||
|
from ..db.models import JobSourceStatus
|
||||||
from ..db.models import Source
|
from ..db.models import Source
|
||||||
from .documents import UploadJobResult
|
from .documents import UploadJobResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
|
||||||
|
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
|
||||||
|
|
||||||
|
|
||||||
class UploadError(AppError):
|
class UploadError(AppError):
|
||||||
"""Raised when uploaded content cannot be persisted safely."""
|
"""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, ...]
|
||||||
|
|
||||||
|
|
||||||
async def create_upload_job(
|
async def create_upload_job(
|
||||||
*,
|
*,
|
||||||
filename: str,
|
filename: str,
|
||||||
@@ -63,6 +79,66 @@ async def create_upload_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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[tuple[str, Path]] = []
|
||||||
|
for filename, file_bytes in sorted_uploads:
|
||||||
|
stored_uploads.append(
|
||||||
|
(
|
||||||
|
filename,
|
||||||
|
store_file(
|
||||||
|
filename=filename,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
settings=runtime_settings,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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 _, stored_path in stored_uploads:
|
||||||
|
_best_effort_delete(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(
|
async def _create_upload_records(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
@@ -81,12 +157,21 @@ async def _create_upload_records(
|
|||||||
|
|
||||||
source = Source(
|
source = Source(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
job_id=job.id,
|
page_number=1,
|
||||||
upload_name=Path(original_filename).name,
|
upload_name=Path(original_filename).name,
|
||||||
filename=stored_path.name,
|
filename=stored_path.name,
|
||||||
file_path=str(stored_path),
|
file_path=str(stored_path),
|
||||||
)
|
)
|
||||||
session.add(source)
|
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.commit()
|
||||||
await session.refresh(document)
|
await session.refresh(document)
|
||||||
@@ -94,6 +179,63 @@ async def _create_upload_records(
|
|||||||
return document, job
|
return document, job
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_job_for_document_records(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
document_id: UUID,
|
||||||
|
stored_uploads: Sequence[tuple[str, Path]],
|
||||||
|
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, (original_filename, stored_path) in enumerate(stored_uploads):
|
||||||
|
source = Source(
|
||||||
|
document_id=document_id,
|
||||||
|
page_number=next_page_number + page_offset,
|
||||||
|
upload_name=Path(original_filename).name,
|
||||||
|
filename=stored_path.name,
|
||||||
|
file_path=str(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:
|
def _best_effort_delete(path: Path) -> None:
|
||||||
try:
|
try:
|
||||||
if path.exists():
|
if path.exists():
|
||||||
@@ -105,13 +247,35 @@ def _best_effort_delete(path: Path) -> None:
|
|||||||
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
||||||
"""Persist an uploaded file to the configured upload directory."""
|
"""Persist an uploaded file to the configured upload directory."""
|
||||||
runtime_settings = settings or get_settings()
|
runtime_settings = settings or get_settings()
|
||||||
_validate_upload(filename=filename, file_bytes=file_bytes)
|
_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)
|
||||||
|
|
||||||
upload_dir = runtime_settings.upload_dir
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
def store_person_portrait(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
||||||
|
"""Persist a portrait upload under uploads/portraits/person."""
|
||||||
|
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("portraits") / "person",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_file_bytes(
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
settings: Settings,
|
||||||
|
relative_directory: Path | 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)
|
stored_name = _build_stored_filename(filename)
|
||||||
stored_path = upload_dir / stored_name
|
stored_path = target_dir / stored_name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stored_path.write_bytes(file_bytes)
|
stored_path.write_bytes(file_bytes)
|
||||||
@@ -126,7 +290,7 @@ def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None =
|
|||||||
return stored_path
|
return stored_path
|
||||||
|
|
||||||
|
|
||||||
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions: set[str]) -> None:
|
||||||
if not file_bytes:
|
if not file_bytes:
|
||||||
raise UploadError(
|
raise UploadError(
|
||||||
"Upload payload is empty",
|
"Upload payload is empty",
|
||||||
@@ -143,14 +307,15 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
suffix = Path(safe_name).suffix.lower()
|
suffix = Path(safe_name).suffix.lower()
|
||||||
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
|
if suffix not in supported_extensions:
|
||||||
raise UploadError(
|
raise UploadError(
|
||||||
f"Unsupported upload extension: {suffix}",
|
f"Unsupported upload extension: {suffix}",
|
||||||
category=ErrorCategory.USER_INPUT,
|
category=ErrorCategory.USER_INPUT,
|
||||||
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
|
suggestion="Upload a supported image or document file and retry.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_stored_filename(filename: str) -> str:
|
def _build_stored_filename(filename: str) -> str:
|
||||||
safe_name = Path(filename).name
|
safe_name = Path(filename).name
|
||||||
return f"{uuid4()}_{safe_name}"
|
suffix = Path(safe_name).suffix.lower()
|
||||||
|
return f"{uuid4()}{suffix}"
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import Revision
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import ErrorCategory
|
from transcription.errors import ErrorCategory
|
||||||
@@ -50,8 +51,12 @@ class TranscriptionNotFoundError(TranscriptionError):
|
|||||||
"""Raised when a transcription-related resource is not found."""
|
"""Raised when a transcription-related resource is not found."""
|
||||||
|
|
||||||
|
|
||||||
|
class SourceDeleteBlockedError(TranscriptionError):
|
||||||
|
"""Raised when source deletion is blocked by dependency policy."""
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionService(ServiceBase):
|
class TranscriptionService(ServiceBase):
|
||||||
"""Service class for job transcription output and optional source revisions."""
|
"""Service class for job transcription output and page-level source revisions."""
|
||||||
|
|
||||||
provider: TranscriptionProvider
|
provider: TranscriptionProvider
|
||||||
|
|
||||||
@@ -59,51 +64,197 @@ class TranscriptionService(ServiceBase):
|
|||||||
super().__init__(session_factory=session_factory)
|
super().__init__(session_factory=session_factory)
|
||||||
self.provider = get_transcription_provider(settings=self.settings)
|
self.provider = get_transcription_provider(settings=self.settings)
|
||||||
|
|
||||||
async def create_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision:
|
async def create_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
|
||||||
"""Create a new revision in the database."""
|
"""Create a new source page record in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
_session.add(revision)
|
_session.add(source)
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
||||||
return revision
|
return source
|
||||||
|
|
||||||
async def read_revision(self, revision_id: UUID, *, session: AsyncSession | None = None) -> Revision:
|
async def read_source(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
|
||||||
"""Read an existing revision from the database."""
|
"""Read an existing source page record."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
revision = await _session.get(
|
source = await _session.get(Source, source_id)
|
||||||
Revision,
|
if source is None:
|
||||||
revision_id,
|
|
||||||
options=(selectinload(Revision.source),), # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
if revision is None:
|
|
||||||
raise TranscriptionNotFoundError(
|
raise TranscriptionNotFoundError(
|
||||||
f"Revision with id {revision_id} not found",
|
f"Source with id {source_id} not found",
|
||||||
category=ErrorCategory.NOT_FOUND,
|
category=ErrorCategory.NOT_FOUND,
|
||||||
suggestion="Verify the revision id and retry.",
|
suggestion="Verify the source id and retry.",
|
||||||
)
|
)
|
||||||
return revision
|
return source
|
||||||
|
|
||||||
async def update_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> Revision:
|
async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
|
||||||
"""Update an existing revision in the database."""
|
"""Read a source page record with job-source context for UI detail rendering."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
merged = await _session.merge(revision)
|
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,))
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
return merged
|
return merged
|
||||||
|
|
||||||
async def delete_revision(self, revision: Revision, *, session: AsyncSession | None = None) -> None:
|
async def delete_source(self, source: Source, *, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a revision from the database."""
|
"""Delete a source page record."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
await _session.delete(revision)
|
await _session.delete(source)
|
||||||
await self._finalize(session=_session, caller_session=session)
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
# Temporary compatibility methods for callers still using transcript naming.
|
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 read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Revision:
|
async def query_sources(
|
||||||
"""Backward-compatible alias for read_revision."""
|
self,
|
||||||
return await self.read_revision(transcript_id, session=session)
|
*,
|
||||||
|
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 delete_transcript(self, transcript: Revision, *, session: AsyncSession | None = None) -> None:
|
async def create_job_source(
|
||||||
"""Backward-compatible alias for delete_revision."""
|
self,
|
||||||
await self.delete_revision(transcript, session=session)
|
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)
|
||||||
|
|
||||||
|
await _session.delete(source)
|
||||||
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
|
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(
|
async def transcribe_document(
|
||||||
self,
|
self,
|
||||||
@@ -151,13 +302,37 @@ class TranscriptionService(ServiceBase):
|
|||||||
suggestion="Verify the job id and retry.",
|
suggestion="Verify the job id and retry.",
|
||||||
)
|
)
|
||||||
|
|
||||||
job.text = text
|
|
||||||
job.error_detail = error_detail
|
|
||||||
job.provider = provider or job.provider or self.settings.provider.value
|
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.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.prompt_name = prompt_name or job.prompt_name or DEFAULT_PROMPT_FILE
|
||||||
job.date_updated = datetime.now(UTC)
|
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:
|
||||||
|
existing_job_source = await _session.exec(
|
||||||
|
select(JobSource).where(JobSource.job_id == job.id).where(JobSource.source_id == source_row.id)
|
||||||
|
)
|
||||||
|
job_source = existing_job_source.first()
|
||||||
|
if job_source is None:
|
||||||
|
job_source = JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source_row.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,))
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return job
|
return job
|
||||||
|
|
||||||
@@ -167,8 +342,8 @@ class TranscriptionService(ServiceBase):
|
|||||||
source_id: UUID,
|
source_id: UUID,
|
||||||
text: str,
|
text: str,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Revision:
|
) -> Source:
|
||||||
"""Create or replace the single optional revision for a source."""
|
"""Persist a human revision on a source page."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
source = await _session.get(Source, source_id)
|
source = await _session.get(Source, source_id)
|
||||||
if source is None:
|
if source is None:
|
||||||
@@ -178,45 +353,35 @@ class TranscriptionService(ServiceBase):
|
|||||||
suggestion="Verify the source id and retry.",
|
suggestion="Verify the source id and retry.",
|
||||||
)
|
)
|
||||||
|
|
||||||
query = select(Revision).where(Revision.source_id == source_id)
|
source.revised_text = text
|
||||||
existing = (await _session.exec(query)).one_or_none()
|
source.date_revised = datetime.now(UTC)
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(source,))
|
||||||
if existing is None:
|
return source
|
||||||
revision = Revision(source_id=source_id, text=text)
|
|
||||||
_session.add(revision)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(revision,))
|
|
||||||
return revision
|
|
||||||
|
|
||||||
existing.text = text
|
|
||||||
merged = await _session.merge(existing)
|
|
||||||
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
async def read_revision_by_source(
|
async def read_revision_by_source(
|
||||||
self,
|
self,
|
||||||
source_id: UUID,
|
source_id: UUID,
|
||||||
*,
|
*,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Revision | None:
|
) -> Source | None:
|
||||||
"""Read the single optional revision for a source."""
|
"""Read the source record for a given page, including any revision text."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Revision).where(Revision.source_id == source_id)
|
return await _session.get(Source, source_id)
|
||||||
result = await _session.exec(query)
|
|
||||||
return result.one_or_none()
|
|
||||||
|
|
||||||
async def list_revisions_by_job(
|
async def list_revisions_by_job(
|
||||||
self,
|
self,
|
||||||
job_id: UUID,
|
job_id: UUID,
|
||||||
*,
|
*,
|
||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Sequence[Revision]:
|
) -> Sequence[Source]:
|
||||||
"""List revisions connected to all sources for a job."""
|
"""List source pages for a job that carry revision text."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = (
|
query = (
|
||||||
select(Revision)
|
select(Source)
|
||||||
.join(Source, Source.id == Revision.source_id)
|
.join(JobSource, JobSource.source_id == Source.id)
|
||||||
.where(Source.job_id == job_id)
|
.where(JobSource.job_id == job_id)
|
||||||
.order_by(Revision.date_created) # pyright: ignore[reportArgumentType]
|
.where(Source.revised_text.is_not(None))
|
||||||
|
.order_by(Source.date_revised) # pyright: ignore[reportArgumentType]
|
||||||
)
|
)
|
||||||
result = await _session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|||||||
@@ -70,7 +70,17 @@ async def process_queued_job(
|
|||||||
|
|
||||||
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
source_job = await services.jobs.read_job(job_id=job.id, session=session)
|
||||||
source = _resolve_primary_source(source_job)
|
source = _resolve_primary_source(source_job)
|
||||||
assert source is not None, f"Job {job.id} has no associated source record."
|
if source is None:
|
||||||
|
candidate_sources = await services.transcriptions.list_sources(document_id=job.document_id, session=session)
|
||||||
|
source = next(iter(sorted(candidate_sources, key=lambda item: item.page_number)), None)
|
||||||
|
|
||||||
|
if source is None:
|
||||||
|
error = AppError(
|
||||||
|
f"Job {job.id} has no associated source record.",
|
||||||
|
category=ErrorCategory.VALIDATION,
|
||||||
|
suggestion="Attach at least one source to the job and retry.",
|
||||||
|
)
|
||||||
|
return await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||||
started_at = asyncio.get_running_loop().time()
|
started_at = asyncio.get_running_loop().time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -151,6 +161,7 @@ async def process_next_queued_job(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Process the next queued job if one exists."""
|
"""Process the next queued job if one exists."""
|
||||||
job = await services.jobs.read_next_queued_job(session=session)
|
job = await services.jobs.read_next_queued_job(session=session)
|
||||||
|
|
||||||
if job is None:
|
if job is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -291,9 +302,9 @@ async def _finalize_failed(
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_primary_source(job: Job) -> Source | None:
|
def _resolve_primary_source(job: Job) -> Source | None:
|
||||||
if not job.sources:
|
if not job.job_sources:
|
||||||
return None
|
return None
|
||||||
return job.sources[0]
|
return next((job_source.source for job_source in job.job_sources if job_source.source is not None), None)
|
||||||
|
|
||||||
|
|
||||||
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
|
def _validate_transcription_quality(*, result: TranscriptionResult, settings: Settings) -> None:
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
"""UI page registration exports."""
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_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_upload_page()
|
|
||||||
register_jobs_page()
|
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ 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_app_shell
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
from transcription.ui.components.page_content import render_page_content
|
from transcription.ui.components.primitives import destructive_button
|
||||||
from transcription.ui.components.page_header import render_page_header
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
|
from transcription.ui.components.primitives import section_header_row
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"NAV_ITEMS",
|
"NAV_ITEMS",
|
||||||
|
"destructive_button",
|
||||||
"render_app_shell",
|
"render_app_shell",
|
||||||
"render_document_panzoom",
|
"render_document_panzoom",
|
||||||
|
"render_empty_state",
|
||||||
"render_navigation_header",
|
"render_navigation_header",
|
||||||
"render_page_content",
|
"section_header_row",
|
||||||
"render_page_header",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,14 +7,23 @@ from nicegui import ui
|
|||||||
from transcription.ui.resources import read_css
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
("Upload", "/upload", "upload_file"),
|
("Documents", "/documents", "description"),
|
||||||
|
("People", "/people", "group"),
|
||||||
|
("Sources", "/sources", "folder"),
|
||||||
("Jobs", "/jobs", "work_history"),
|
("Jobs", "/jobs", "work_history"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from transcription.ui.theme import VIBESCRIBE_LOGO_SVG
|
||||||
|
|
||||||
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||||
if item_path == "/jobs":
|
if item_path == "/jobs":
|
||||||
return current_path == "/jobs" or current_path.startswith("/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
|
return current_path == item_path
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +43,7 @@ def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -
|
|||||||
def _normalize_path(current_path: str | None) -> str:
|
def _normalize_path(current_path: str | None) -> str:
|
||||||
normalized = (current_path or "").strip()
|
normalized = (current_path or "").strip()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return "/upload"
|
return "/jobs"
|
||||||
return normalized.rstrip("/") or "/"
|
return normalized.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +54,7 @@ def render_app_shell(*, current_path: str | None = None) -> None:
|
|||||||
|
|
||||||
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
|
with ui.header().classes("app-shell"), ui.element("div").classes("app-shell__inner"):
|
||||||
with ui.row().classes("app-shell__brand no-wrap"):
|
with ui.row().classes("app-shell__brand no-wrap"):
|
||||||
ui.label("VS").classes("app-shell__brand-mark")
|
ui.html(VIBESCRIBE_LOGO_SVG).classes("app-shell__brand-mark")
|
||||||
ui.label("VibeScribe").classes("app-shell__brand-name")
|
ui.label("VibeScribe").classes("app-shell__brand-name")
|
||||||
|
|
||||||
with ui.element("nav").props('aria-label="Primary navigation"').classes("app-shell__nav"):
|
with ui.element("nav").props('aria-label="Primary navigation"').classes("app-shell__nav"):
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 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]")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Documents list and detail page registration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
|
||||||
|
from .cards import archival_card
|
||||||
|
from .data_display import archival_badge
|
||||||
|
from .data_display import metadata_row
|
||||||
|
from .primitives import render_empty_state
|
||||||
|
|
||||||
|
|
||||||
|
def render_archival_metadata(document: Document, author_link: DocumentPerson | None = None) -> None:
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
|
with archival_card(title="Archival Metadata"):
|
||||||
|
metadata_row("Author:", author_link.person.full_name if author_link and author_link.person else "Not set")
|
||||||
|
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
|
||||||
|
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
|
||||||
|
metadata_row("Location Created:", document.location_created or "Not set")
|
||||||
|
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full mt-2"):
|
||||||
|
ui.label("Archival Notes:").classes("ui-text-muted text-xs mb-1")
|
||||||
|
ui.label(document.notes or "No notes added.").classes("p-2 ui-note-box text-xs")
|
||||||
|
|
||||||
|
with archival_card(title="System Logistics"):
|
||||||
|
ui.label(f"Created: {document.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
|
ui.label(f"Updated: {document.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
|
|
||||||
|
|
||||||
|
def render_doc_people_details(document: Document) -> None:
|
||||||
|
with archival_card(title="Related People"):
|
||||||
|
if not document.document_people:
|
||||||
|
render_empty_state("No linked people yet.", italic=True)
|
||||||
|
else:
|
||||||
|
with ui.column().classes("w-full gap-2"):
|
||||||
|
for link in document.document_people:
|
||||||
|
person_label = link.person.full_name if link.person is not None else "Unknown person"
|
||||||
|
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
|
||||||
|
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
|
||||||
|
archival_badge(link.role.value)
|
||||||
|
|
||||||
|
|
||||||
|
def render_doc_job_details(document: Document) -> None:
|
||||||
|
with archival_card(title="Pipeline Jobs"):
|
||||||
|
with ui.row().classes("w-full justify-between items-center mb-2"):
|
||||||
|
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-2 mt-2"):
|
||||||
|
ui.button(
|
||||||
|
"View Jobs",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"),
|
||||||
|
icon="work_history",
|
||||||
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
|
ui.button(
|
||||||
|
"+ Add Job",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||||
|
icon="add",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
from nicegui.binding import bindable_dataclass
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ...db.models import Document
|
||||||
|
from ...db.models import DocumentPersonRole
|
||||||
|
from ...services.people import get_name_options
|
||||||
|
from .cards import archival_card
|
||||||
|
|
||||||
|
PROPS = "outlined bg-white"
|
||||||
|
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bindable_dataclass
|
||||||
|
class DocumentEditForm:
|
||||||
|
id: UUID | None = None
|
||||||
|
name: str | None = None
|
||||||
|
document_type: str | None = None
|
||||||
|
document_date: str | None = None
|
||||||
|
document_date_raw: str | None = None
|
||||||
|
location_created: str | None = None
|
||||||
|
archive_identifier: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
author_id: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_table_model(cls, model: Document):
|
||||||
|
existing_author = next(
|
||||||
|
(item for item in model.document_people if item.role == DocumentPersonRole.AUTHOR),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
id=model.id,
|
||||||
|
name=model.name,
|
||||||
|
document_type=model.document_type,
|
||||||
|
document_date=model.document_date.isoformat() if model.document_date else None,
|
||||||
|
document_date_raw=model.document_date_raw,
|
||||||
|
location_created=model.location_created,
|
||||||
|
archive_identifier=model.archive_identifier,
|
||||||
|
notes=model.notes,
|
||||||
|
author_id=str(existing_author.person_id) if existing_author is not None else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, session: AsyncSession) -> None:
|
||||||
|
"""Save the form data to the database."""
|
||||||
|
doc = session.get(Document, self.id)
|
||||||
|
if not doc:
|
||||||
|
logger.error("Document with ID %s not found in the database.", self.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def render_document_edit_form(document: Document, session: AsyncSession) -> DocumentEditForm:
|
||||||
|
edit_form = DocumentEditForm.from_table_model(document)
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-3"):
|
||||||
|
(ui.input("Document name").classes("w-full").props("autofocus").props(PROPS).bind_value(edit_form, "name"))
|
||||||
|
(
|
||||||
|
ui.input("Document type")
|
||||||
|
.classes("w-full")
|
||||||
|
.props("autofocus")
|
||||||
|
.props(PROPS)
|
||||||
|
.bind_value(edit_form, "document_type")
|
||||||
|
)
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||||
|
(
|
||||||
|
ui.input("Exact date (YYYY-MM-DD)")
|
||||||
|
.props(PROPS)
|
||||||
|
.props('type="date"')
|
||||||
|
.bind_value(edit_form, "document_date")
|
||||||
|
)
|
||||||
|
(ui.input("Approximate date").bind_value(edit_form, "document_date_raw").props(PROPS))
|
||||||
|
ui.input("Document location").classes("w-full").props(PROPS).bind_value(edit_form, "location_created")
|
||||||
|
ui.input("Archive identifier").classes("w-full").props(PROPS).bind_value(edit_form, "archive_identifier")
|
||||||
|
(
|
||||||
|
ui.textarea("Notes")
|
||||||
|
.classes("w-full")
|
||||||
|
.props(PROPS)
|
||||||
|
.props("autogrow")
|
||||||
|
.bind_value(edit_form, "notes")
|
||||||
|
.props("rows=4")
|
||||||
|
)
|
||||||
|
|
||||||
|
people = await get_name_options(session=session)
|
||||||
|
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {
|
||||||
|
str(person_id): name for person_id, name in people.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_author_change(event) -> None:
|
||||||
|
selected = str(event.value or "").strip()
|
||||||
|
if selected == CREATE_NEW_PERSON_OPTION:
|
||||||
|
ui.navigate.to("/people/new")
|
||||||
|
|
||||||
|
(
|
||||||
|
ui.select(
|
||||||
|
author_options,
|
||||||
|
label="Author (Person)",
|
||||||
|
value=edit_form.author_id or "",
|
||||||
|
on_change=on_author_change,
|
||||||
|
)
|
||||||
|
.classes("w-full")
|
||||||
|
.props(PROPS)
|
||||||
|
.bind_value(edit_form, "author_id")
|
||||||
|
)
|
||||||
|
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
|
||||||
|
|
||||||
|
return edit_form
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""Reusable job detail rendering helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.db.models import Job
|
|
||||||
from transcription.db.models import Revision
|
|
||||||
from transcription.db.models import Source
|
|
||||||
from transcription.ui.components.document_panzoom import render_document_panzoom
|
|
||||||
from transcription.ui.components.transcript import render_original_transcription_card
|
|
||||||
from transcription.ui.components.transcript import render_revision_row
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _status_chip_classes(status: str) -> str:
|
|
||||||
if status == "queued":
|
|
||||||
return "vibe-status--queued"
|
|
||||||
if status == "processing":
|
|
||||||
return "vibe-status--processing"
|
|
||||||
if status == "transcribed":
|
|
||||||
return "vibe-status--transcribed"
|
|
||||||
if status == "failed":
|
|
||||||
return "vibe-status--failed"
|
|
||||||
return "vibe-status--default"
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_row(label: str, value: str) -> None:
|
|
||||||
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
|
|
||||||
ui.label(label).classes("text-caption vibe-text-muted text-uppercase w-28")
|
|
||||||
ui.label(value).classes("text-body2 text-right break-all")
|
|
||||||
|
|
||||||
|
|
||||||
def _render_source_section(source: Source) -> None:
|
|
||||||
with ui.card().classes("w-full q-pa-md vibe-card"):
|
|
||||||
ui.label("Source").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.separator().classes("q-my-sm")
|
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
|
||||||
_metadata_row("Upload name", source.upload_name)
|
|
||||||
_metadata_row("Stored filename", source.filename)
|
|
||||||
_metadata_row("File path", source.file_path)
|
|
||||||
_metadata_row("Uploaded", source.date_uploaded.isoformat())
|
|
||||||
|
|
||||||
ui.separator().classes("q-my-md")
|
|
||||||
render_document_panzoom(source=source)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_revision_section(revision: Revision | None) -> None:
|
|
||||||
with ui.card().classes("w-full q-pa-md vibe-card"):
|
|
||||||
ui.label("Revision").classes("text-subtitle1 text-weight-medium")
|
|
||||||
ui.separator().classes("q-my-sm")
|
|
||||||
|
|
||||||
if revision is None:
|
|
||||||
ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted")
|
|
||||||
return
|
|
||||||
|
|
||||||
render_revision_row(revision=revision, initially_expanded=True)
|
|
||||||
|
|
||||||
|
|
||||||
def render_job_detail(*, job: Job, source: Source | None, revision: Revision | None) -> None:
|
|
||||||
"""Render all sections for the job detail page."""
|
|
||||||
logger.debug("Rendering job detail for job ID %s", job.id)
|
|
||||||
status_text = job.status.value
|
|
||||||
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
|
||||||
with ui.card().classes("w-full q-pa-lg vibe-card"):
|
|
||||||
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
|
||||||
with ui.column().classes("q-gutter-none"):
|
|
||||||
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
|
||||||
ui.label(str(job.id)).classes("text-caption vibe-text-muted")
|
|
||||||
status_chip_classes = (
|
|
||||||
"q-px-sm q-py-xs rounded-borders "
|
|
||||||
"vibe-status text-weight-medium text-capitalize "
|
|
||||||
f"{_status_chip_classes(status_text)}"
|
|
||||||
)
|
|
||||||
ui.label(status_text).classes(status_chip_classes)
|
|
||||||
|
|
||||||
ui.separator().classes("q-my-md vibe-separator")
|
|
||||||
with ui.column().classes("w-full q-gutter-y-xs"):
|
|
||||||
_metadata_row("Created", job.date_created.isoformat())
|
|
||||||
_metadata_row("Updated", job.date_updated.isoformat())
|
|
||||||
_metadata_row("Retries", str(job.retry_count))
|
|
||||||
|
|
||||||
render_original_transcription_card(job=job)
|
|
||||||
|
|
||||||
if source is not None:
|
|
||||||
_render_source_section(source)
|
|
||||||
|
|
||||||
_render_revision_section(revision)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""High-level placeholder content for a transcription workspace."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.resources import read_css
|
|
||||||
|
|
||||||
|
|
||||||
def render_page_content(
|
|
||||||
*,
|
|
||||||
source_name: str = "document-placeholder.jpg",
|
|
||||||
raw_text: str = "AI transcription output will appear here.",
|
|
||||||
revised_text: str = "Human revision text will appear here.",
|
|
||||||
) -> None:
|
|
||||||
"""Render the primary editor workspace and supporting context sidebar."""
|
|
||||||
ui.add_css(read_css("components/page_content.css"))
|
|
||||||
|
|
||||||
with ui.element("div").classes("page-content"):
|
|
||||||
with ui.element("section").classes("page-content__editor"):
|
|
||||||
with ui.row().classes("page-content__heading"):
|
|
||||||
with ui.column().classes("gap-0"):
|
|
||||||
ui.label("Active source").classes("page-content__kicker")
|
|
||||||
ui.label("Page transcription").classes("page-content__title")
|
|
||||||
ui.badge("Page 1 of 1").classes("page-content__badge")
|
|
||||||
|
|
||||||
with ui.element("div").classes("page-content__workspace"):
|
|
||||||
with ui.element("section").classes("source-placeholder"):
|
|
||||||
with ui.row().classes("source-placeholder__toolbar"):
|
|
||||||
ui.label(source_name)
|
|
||||||
ui.icon("image", size="1.25rem")
|
|
||||||
with ui.column().classes("source-placeholder__body"):
|
|
||||||
ui.icon("description", size="4rem")
|
|
||||||
ui.label("Source preview")
|
|
||||||
|
|
||||||
with ui.column().classes("transcription-placeholder"):
|
|
||||||
with ui.element("section").classes("transcription-placeholder__section"):
|
|
||||||
ui.label("AI raw output").classes("transcription-placeholder__title")
|
|
||||||
ui.label(raw_text).classes("transcription-placeholder__text")
|
|
||||||
|
|
||||||
with ui.element("section").classes("transcription-placeholder__section"):
|
|
||||||
ui.label("Human revision").classes("transcription-placeholder__title")
|
|
||||||
ui.textarea(value=revised_text).props("outlined autogrow").classes("w-full")
|
|
||||||
|
|
||||||
with ui.element("aside").props('aria-label="Document context"').classes("page-content__sidebar"):
|
|
||||||
with ui.element("section").classes("page-content__sidebar-section"):
|
|
||||||
ui.label("People").classes("page-content__sidebar-title")
|
|
||||||
ui.label("Author · Placeholder Person")
|
|
||||||
ui.label("Recipient · Placeholder Person")
|
|
||||||
|
|
||||||
with ui.element("section").classes("page-content__sidebar-section"):
|
|
||||||
ui.label("AI processing").classes("page-content__sidebar-title")
|
|
||||||
ui.badge("Completed", color="positive")
|
|
||||||
ui.label("Provider · Placeholder provider")
|
|
||||||
ui.label("Model · Placeholder model")
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""High-level page header for document-oriented views."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
from transcription.ui.resources import read_css
|
|
||||||
|
|
||||||
type PageHeaderAction = Callable[[], Awaitable[None] | None]
|
|
||||||
|
|
||||||
|
|
||||||
def render_page_header(
|
|
||||||
*,
|
|
||||||
eyebrow: str = "Letter · Placeholder Collection",
|
|
||||||
title: str = "Untitled archival document",
|
|
||||||
metadata: tuple[str, ...] = ("Date unknown", "Location unknown"),
|
|
||||||
on_details: PageHeaderAction | None = None,
|
|
||||||
on_review: PageHeaderAction | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Render document identity, metadata, and page-level actions."""
|
|
||||||
ui.add_css(read_css("components/page_header.css"))
|
|
||||||
|
|
||||||
with ui.element("section").classes("page-header"):
|
|
||||||
with ui.column().classes("page-header__identity"):
|
|
||||||
ui.label(eyebrow).classes("page-header__eyebrow")
|
|
||||||
ui.label(title).classes("page-header__title")
|
|
||||||
with ui.row().classes("page-header__metadata"):
|
|
||||||
for value in metadata:
|
|
||||||
ui.label(value)
|
|
||||||
|
|
||||||
with ui.row().classes("page-header__actions"):
|
|
||||||
ui.button("Document details", icon="info", on_click=on_details).props("outline no-caps")
|
|
||||||
ui.button("Mark reviewed", icon="task_alt", on_click=on_review).props("unelevated no-caps")
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
@@ -57,6 +57,7 @@ def build_table(
|
|||||||
pagination["sortBy"] = default_sort_by
|
pagination["sortBy"] = default_sort_by
|
||||||
pagination["descending"] = default_descending
|
pagination["descending"] = default_descending
|
||||||
|
|
||||||
|
# Quasar props enforce behavior; visual styling is centralized in theme.css.
|
||||||
table = (
|
table = (
|
||||||
ui.table(
|
ui.table(
|
||||||
rows=rows,
|
rows=rows,
|
||||||
@@ -64,9 +65,14 @@ def build_table(
|
|||||||
row_key="id",
|
row_key="id",
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
)
|
)
|
||||||
.classes(classes)
|
.classes(f"w-full ui-table {classes}")
|
||||||
.props('table-style="table-layout: fixed; width: 100%;"')
|
.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))
|
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
||||||
if on_row_click_id is not None:
|
if on_row_click_id is not None:
|
||||||
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Documents table rendering helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
|
from transcription.ui.components.table.common import build_table
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DocumentTableRow:
|
||||||
|
"""Read model consumed by the documents table component."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
document_type: str
|
||||||
|
archive_identifier: str
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"name": row.name,
|
||||||
|
"document_type": row.document_type or "Unspecified",
|
||||||
|
"archive_identifier": row.archive_identifier or "N/A",
|
||||||
|
"created_at": row.created_at,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
|
||||||
|
"""Render documents table and open detail page when clicking a row."""
|
||||||
|
if not rows:
|
||||||
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
|
render_empty_state("No documents in repository yet.")
|
||||||
|
return
|
||||||
|
|
||||||
|
build_table(
|
||||||
|
rows=_serialize_rows(rows),
|
||||||
|
columns=[
|
||||||
|
{"name": "name", "label": "Document Title", "field": "name", "sortable": True, "classes": "font-serif font-semibold"},
|
||||||
|
{"name": "document_type", "label": "Type", "field": "document_type", "sortable": True},
|
||||||
|
{"name": "archive_identifier", "label": "Archive Ref", "field": "archive_identifier", "sortable": True, "classes": "font-mono"},
|
||||||
|
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
|
||||||
|
],
|
||||||
|
default_sort_by="name",
|
||||||
|
classes="app-table w-full",
|
||||||
|
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
|
||||||
|
)
|
||||||
@@ -11,6 +11,8 @@ from uuid import UUID
|
|||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
from .common import build_table
|
from .common import build_table
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"status": row.status,
|
"status": row.status.upper(),
|
||||||
"filename": row.filename,
|
"filename": row.filename,
|
||||||
"retry_count": row.retry_count,
|
"retry_count": row.retry_count,
|
||||||
"date_created": _format_timestamp(row.date_created),
|
"date_created": _format_timestamp(row.date_created),
|
||||||
@@ -55,15 +57,16 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
|||||||
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
||||||
"""Render jobs table and open a detail page when clicking a row."""
|
"""Render jobs table and open a detail page when clicking a row."""
|
||||||
if not rows:
|
if not rows:
|
||||||
ui.label("No jobs yet.")
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
|
render_empty_state("No active or historical processing jobs found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
build_table(
|
build_table(
|
||||||
rows=_serialize_rows(rows),
|
rows=_serialize_rows(rows),
|
||||||
columns=[
|
columns=[
|
||||||
{"name": "id", "label": "Job ID", "field": "id", "sortable": True},
|
{"name": "id", "label": "Job ID", "field": "id", "sortable": True, "classes": "font-mono"},
|
||||||
{"name": "status", "label": "Status", "field": "status", "sortable": True},
|
{"name": "status", "label": "Status", "field": "status", "sortable": True, "classes": "font-semibold ui-link-primary"},
|
||||||
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
|
{"name": "filename", "label": "Source Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
|
||||||
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
||||||
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
|
{"name": "date_created", "label": "Created", "field": "date_created", "sortable": True},
|
||||||
{"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True},
|
{"name": "date_updated", "label": "Updated", "field": "date_updated", "sortable": True},
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""People table rendering helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
|
from transcription.ui.components.table.common import build_table
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PersonTableRow:
|
||||||
|
"""Read model consumed by the people table component."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
full_name: str
|
||||||
|
display_name: str
|
||||||
|
maiden_name: str
|
||||||
|
birth_date: str
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"full_name": row.full_name,
|
||||||
|
"display_name": row.display_name or "Not set",
|
||||||
|
"maiden_name": row.maiden_name or "N/A",
|
||||||
|
"birth_date": row.birth_date or "Unknown",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_people_table(rows: Sequence[PersonTableRow]) -> None:
|
||||||
|
"""Render people table and open detail page when clicking a row."""
|
||||||
|
if not rows:
|
||||||
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
|
render_empty_state("No person records found in repository.")
|
||||||
|
return
|
||||||
|
|
||||||
|
build_table(
|
||||||
|
rows=_serialize_rows(rows),
|
||||||
|
columns=[
|
||||||
|
{"name": "full_name", "label": "Full Name", "field": "full_name", "sortable": True, "classes": "font-serif font-semibold"},
|
||||||
|
{"name": "display_name", "label": "Display Name", "field": "display_name", "sortable": True},
|
||||||
|
{"name": "maiden_name", "label": "Maiden Name", "field": "maiden_name", "sortable": True},
|
||||||
|
{"name": "birth_date", "label": "Birth Date", "field": "birth_date", "sortable": True},
|
||||||
|
],
|
||||||
|
default_sort_by="full_name",
|
||||||
|
classes="app-table w-full",
|
||||||
|
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Sources table rendering helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.primitives import render_empty_state
|
||||||
|
from transcription.ui.components.table.common import build_table
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SourceTableRow:
|
||||||
|
"""Read model consumed by the sources table component."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
page_number: int
|
||||||
|
upload_name: str
|
||||||
|
filename: str
|
||||||
|
document_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"page_number": row.page_number,
|
||||||
|
"upload_name": row.upload_name,
|
||||||
|
"filename": row.filename,
|
||||||
|
"document_id": str(row.document_id),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
|
||||||
|
"""Render sources table and open detail page when clicking a row."""
|
||||||
|
if not rows:
|
||||||
|
with archival_card(extra_classes="p-8 text-center"):
|
||||||
|
render_empty_state("No source file records found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
build_table(
|
||||||
|
rows=_serialize_rows(rows),
|
||||||
|
columns=[
|
||||||
|
{"name": "page_number", "label": "Page", "field": "page_number", "sortable": True},
|
||||||
|
{"name": "upload_name", "label": "Upload Title", "field": "upload_name", "sortable": True, "classes": "font-serif"},
|
||||||
|
{"name": "filename", "label": "Stored Filename", "field": "filename", "sortable": True, "classes": "font-mono"},
|
||||||
|
{"name": "document_id", "label": "Document ID", "field": "document_id", "sortable": True, "classes": "font-mono"},
|
||||||
|
],
|
||||||
|
default_sort_by="page_number",
|
||||||
|
classes="app-table w-full",
|
||||||
|
on_row_click_id=lambda source_id: ui.navigate.to(f"/sources/{source_id}"),
|
||||||
|
)
|
||||||
@@ -10,14 +10,16 @@ from typing import Any
|
|||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import Revision
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import Source
|
||||||
|
|
||||||
type RevisionAction = Callable[[Revision], Awaitable[None] | None]
|
type RevisionAction = Callable[[Source], Awaitable[None] | None]
|
||||||
|
|
||||||
|
|
||||||
def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> Any:
|
def render_original_transcription_card(*, job: Job, classes: str = "w-full") -> Any:
|
||||||
"""Render the immutable original job transcription output."""
|
"""Render the immutable original job transcription output."""
|
||||||
status_label = "Failed" if job.error_detail else "Transcribed"
|
latest_error_detail = _latest_job_error_detail(job)
|
||||||
|
status_label = "Failed" if latest_error_detail else "Transcribed"
|
||||||
header = f"Original Transcription | {status_label}"
|
header = f"Original Transcription | {status_label}"
|
||||||
provider = job.provider or "unknown"
|
provider = job.provider or "unknown"
|
||||||
model = job.model or "unknown"
|
model = job.model or "unknown"
|
||||||
@@ -30,28 +32,33 @@ def render_original_transcription_card(*, job: Job, classes: str = "w-full") ->
|
|||||||
_metadata_row(label="Prompt", value=job.prompt_name or "unknown")
|
_metadata_row(label="Prompt", value=job.prompt_name or "unknown")
|
||||||
_metadata_row(label="Updated", value=_format_created_at(job.date_updated))
|
_metadata_row(label="Updated", value=_format_created_at(job.date_updated))
|
||||||
|
|
||||||
if job.text:
|
latest_transcription = _latest_job_transcription(job)
|
||||||
with ui.card().classes("w-full q-pa-sm"):
|
|
||||||
ui.markdown(job.text)
|
|
||||||
|
|
||||||
if job.error_detail:
|
if latest_transcription:
|
||||||
|
with ui.card().classes("w-full q-pa-sm"):
|
||||||
|
ui.markdown(latest_transcription)
|
||||||
|
|
||||||
|
if latest_error_detail:
|
||||||
with ui.card().classes("w-full vibe-card--error q-pa-sm"):
|
with ui.card().classes("w-full vibe-card--error q-pa-sm"):
|
||||||
ui.label("Failure detail").classes("text-caption text-uppercase")
|
ui.label("Failure detail").classes("text-caption text-uppercase")
|
||||||
ui.label(job.error_detail).classes("text-body2")
|
ui.label(latest_error_detail).classes("text-body2")
|
||||||
|
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
def render_revision_row(
|
def render_revision_row(
|
||||||
*,
|
*,
|
||||||
revision: Revision,
|
revision: Source | None,
|
||||||
initially_expanded: bool = False,
|
initially_expanded: bool = False,
|
||||||
classes: str = "w-full",
|
classes: str = "w-full",
|
||||||
on_delete: RevisionAction | None = None,
|
on_delete: RevisionAction | None = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Render a collapsible row for the single optional source revision."""
|
"""Render a collapsible row for the single optional source revision."""
|
||||||
header = "Revision | User-authored"
|
if revision is None:
|
||||||
caption = _format_created_at(revision.date_created)
|
return None
|
||||||
|
|
||||||
|
header = "Source revision | User-authored"
|
||||||
|
caption = _format_created_at(revision.date_revised or revision.date_uploaded)
|
||||||
|
|
||||||
expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card")
|
expansion = ui.expansion(value=initially_expanded, group="group").classes(f"{classes} rounded-borders vibe-card")
|
||||||
|
|
||||||
@@ -63,7 +70,7 @@ def render_revision_row(
|
|||||||
|
|
||||||
if on_delete is not None:
|
if on_delete is not None:
|
||||||
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"):
|
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"):
|
||||||
ui.label("Delete this transcript revision?").classes("text-body1")
|
ui.label("Delete this source revision?").classes("text-body1")
|
||||||
with ui.row().classes("w-full justify-end q-gutter-sm"):
|
with ui.row().classes("w-full justify-end q-gutter-sm"):
|
||||||
ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat")
|
ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat")
|
||||||
ui.button("Delete", on_click=lambda: delete_dialog.submit(True)).props(
|
ui.button("Delete", on_click=lambda: delete_dialog.submit(True)).props(
|
||||||
@@ -84,15 +91,29 @@ def render_revision_row(
|
|||||||
ui.button(icon="delete", on_click=delete_current_transcript).props(
|
ui.button(icon="delete", on_click=delete_current_transcript).props(
|
||||||
'flat round dense color="negative"'
|
'flat round dense color="negative"'
|
||||||
)
|
)
|
||||||
_metadata_row(label="Created", value=_format_created_at(revision.date_created))
|
_metadata_row(label="Created", value=_format_created_at(revision.date_revised or revision.date_uploaded))
|
||||||
|
|
||||||
if revision.text:
|
if revision.revised_text:
|
||||||
with ui.card().classes("w-full q-pa-sm"):
|
with ui.card().classes("w-full q-pa-sm"):
|
||||||
ui.markdown(revision.text)
|
ui.markdown(revision.revised_text)
|
||||||
|
|
||||||
return expansion
|
return expansion
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_job_transcription(job: Job) -> str | None:
|
||||||
|
for job_source in sorted(job.job_sources, key=lambda item: item.executed_at, reverse=True):
|
||||||
|
if job_source.raw_transcription:
|
||||||
|
return job_source.raw_transcription
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_job_error_detail(job: Job) -> str | None:
|
||||||
|
for job_source in sorted(job.job_sources, key=lambda item: item.executed_at, reverse=True):
|
||||||
|
if job_source.error_detail:
|
||||||
|
return job_source.error_detail
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _format_created_at(value: datetime) -> str:
|
def _format_created_at(value: datetime) -> str:
|
||||||
"""Return a compact UTC-like timestamp for row captions."""
|
"""Return a compact UTC-like timestamp for row captions."""
|
||||||
return value.strftime("%Y-%m-%d %H:%M:%S %Z")
|
return value.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Reusable upload widget for document submission."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
from nicegui.binding import bindable_dataclass
|
|
||||||
from nicegui.events import UploadEventArguments
|
|
||||||
|
|
||||||
from transcription.errors import AppError
|
|
||||||
from transcription.services.documents import UploadJobResult
|
|
||||||
from transcription.ui.components.error_presenter import show_error
|
|
||||||
from transcription.ui.components.error_presenter import summarize_error
|
|
||||||
from transcription.worker import WorkerNotifier
|
|
||||||
|
|
||||||
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
|
|
||||||
|
|
||||||
|
|
||||||
@bindable_dataclass
|
|
||||||
class UploadWidgetState:
|
|
||||||
"""Simple state container for upload feedback."""
|
|
||||||
|
|
||||||
loading: bool = False
|
|
||||||
message: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def render_upload_widget(*, submitter: UploadSubmitter, notifier: WorkerNotifier | None = None) -> None:
|
|
||||||
"""Render upload controls and common status/error handling."""
|
|
||||||
state = UploadWidgetState()
|
|
||||||
status_label = ui.label("Upload a document to start transcription.")
|
|
||||||
status_label.bind_text(state, "message")
|
|
||||||
|
|
||||||
async def on_upload(event: UploadEventArguments) -> None:
|
|
||||||
if state.loading:
|
|
||||||
ui.notify("Upload already in progress. Please wait.", type="warning")
|
|
||||||
return
|
|
||||||
|
|
||||||
state.loading = True
|
|
||||||
status_label.text = "Uploading..."
|
|
||||||
try:
|
|
||||||
payload = await event.file.read()
|
|
||||||
result = await submitter(event.file.name, payload)
|
|
||||||
job_id = result.job_id
|
|
||||||
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
|
|
||||||
status_label.text = state.message
|
|
||||||
if notifier is not None:
|
|
||||||
notifier.notify()
|
|
||||||
ui.notify(state.message, type="positive")
|
|
||||||
except AppError as exc:
|
|
||||||
state.message = summarize_error(exc, operation="upload.submit")
|
|
||||||
status_label.text = f"Upload failed: {state.message}"
|
|
||||||
show_error(exc, title="Upload failed", operation="upload.submit")
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
state.message = summarize_error(exc, operation="upload.submit")
|
|
||||||
status_label.text = f"Upload failed: {state.message}"
|
|
||||||
show_error(exc, title="Upload failed", operation="upload.submit")
|
|
||||||
finally:
|
|
||||||
state.loading = False
|
|
||||||
|
|
||||||
ui.upload(
|
|
||||||
on_upload=on_upload,
|
|
||||||
auto_upload=True,
|
|
||||||
label="Select document file",
|
|
||||||
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf"')
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Media viewer components for high-contrast image inspection."""
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
|
||||||
|
def dark_room_viewer(
|
||||||
|
image_path: str | None,
|
||||||
|
count_label: str = "1 Source Linked",
|
||||||
|
*,
|
||||||
|
container_height: str = "500px",
|
||||||
|
) -> None:
|
||||||
|
"""Isolated high-contrast container for image inspection with pan and zoom capabilities."""
|
||||||
|
with ui.card().classes("ui-bg-viewer ui-border-viewer rounded-sm p-3 flex flex-col justify-between w-full"):
|
||||||
|
# Viewer Header Bar
|
||||||
|
with ui.row().classes("w-full justify-between items-center mb-2 ui-text-inverse text-xs"):
|
||||||
|
ui.label("SOURCE MEDIA VIEWER").classes("font-mono font-bold tracking-wider")
|
||||||
|
ui.label(count_label).classes("ui-text-accent")
|
||||||
|
|
||||||
|
# Interactive Pan/Zoom Canvas Area
|
||||||
|
if image_path:
|
||||||
|
# Container with fixed height and hidden overflow for contained panning/zooming
|
||||||
|
with ui.element("div").classes(
|
||||||
|
"relative w-full overflow-hidden border ui-border-viewer ui-bg-viewer-overlay rounded-sm flex items-center justify-center cursor-grab active:cursor-grabbing"
|
||||||
|
).style(f"height: {container_height};") as viewport:
|
||||||
|
|
||||||
|
# Image element targeted by client-side pan/zoom JS
|
||||||
|
img = (
|
||||||
|
ui.image(image_path)
|
||||||
|
.classes("max-h-full max-w-full select-none transition-transform duration-75 ease-out")
|
||||||
|
.style("transform-origin: center center;")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Client-side JavaScript state management for smooth panning and scaling
|
||||||
|
js_pan_zoom = f"""
|
||||||
|
const viewport = getElement('{viewport.id}');
|
||||||
|
const img = getElement('{img.id}');
|
||||||
|
|
||||||
|
let scale = 1;
|
||||||
|
let pointX = 0;
|
||||||
|
let pointY = 0;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
function updateTransform() {{
|
||||||
|
img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`;
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Mouse Wheel Zooming
|
||||||
|
viewport.onwheel = function(e) {{
|
||||||
|
e.preventDefault();
|
||||||
|
const xs = (e.clientX - pointX) / scale;
|
||||||
|
const ys = (e.clientY - pointY) / scale;
|
||||||
|
|
||||||
|
const delta = -e.deltaY;
|
||||||
|
(delta > 0) ? (scale *= 1.15) : (scale /= 1.15);
|
||||||
|
scale = Math.min(Math.max(0.5, scale), 8); // Constrain zoom level (0.5x to 8x)
|
||||||
|
|
||||||
|
updateTransform();
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Mouse Drag Panning
|
||||||
|
viewport.onmousedown = function(e) {{
|
||||||
|
e.preventDefault();
|
||||||
|
startX = e.clientX - pointX;
|
||||||
|
startY = e.clientY - pointY;
|
||||||
|
isDragging = true;
|
||||||
|
}};
|
||||||
|
|
||||||
|
window.onmouseup = function() {{
|
||||||
|
isDragging = false;
|
||||||
|
}};
|
||||||
|
|
||||||
|
viewport.onmousemove = function(e) {{
|
||||||
|
if (!isDragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
pointX = e.clientX - startX;
|
||||||
|
pointY = e.clientY - startY;
|
||||||
|
updateTransform();
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Global function handles for external control toolbar
|
||||||
|
window.resetZoom_{img.id} = function() {{ scale = 1; pointX = 0; pointY = 0; updateTransform(); }};
|
||||||
|
window.zoomIn_{img.id} = function() {{ scale = Math.min(scale * 1.25, 8); updateTransform(); }};
|
||||||
|
window.zoomOut_{img.id} = function() {{ scale = Math.max(scale / 1.25, 0.5); updateTransform(); }};
|
||||||
|
"""
|
||||||
|
ui.run_javascript(js_pan_zoom)
|
||||||
|
|
||||||
|
# Control Toolbar
|
||||||
|
with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"):
|
||||||
|
ui.button(
|
||||||
|
icon="zoom_in",
|
||||||
|
on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"),
|
||||||
|
).props("flat round dense color=white text-xs").tooltip("Zoom In")
|
||||||
|
|
||||||
|
ui.button(
|
||||||
|
icon="zoom_out",
|
||||||
|
on_click=lambda: ui.run_javascript(f"window.zoomOut_{img.id}()"),
|
||||||
|
).props("flat round dense color=white text-xs").tooltip("Zoom Out")
|
||||||
|
|
||||||
|
ui.button(
|
||||||
|
icon="center_focus_strong",
|
||||||
|
on_click=lambda: ui.run_javascript(f"window.resetZoom_{img.id}()"),
|
||||||
|
).props("flat round dense color=white text-xs").tooltip("Reset View")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Fallback state when no image source is linked
|
||||||
|
with ui.column().classes(
|
||||||
|
"w-full flex-grow items-center justify-center border ui-border-viewer ui-bg-viewer-overlay-soft rounded-sm p-8"
|
||||||
|
).style(f"min-height: {container_height};"):
|
||||||
|
ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from transcription.db.session import SessionFactory
|
||||||
|
from transcription.db.session import resolve_session_factory
|
||||||
|
|
||||||
|
from ..services import ServiceBundle
|
||||||
|
|
||||||
|
type SessionFactoryDep = Annotated[SessionFactory, Depends(resolve_session_factory)]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service_bundle(session_factory: SessionFactoryDep) -> ServiceBundle:
|
||||||
|
return ServiceBundle.from_session_factory(session_factory)
|
||||||
|
|
||||||
|
|
||||||
|
type ServicesDep = Annotated[ServiceBundle, Depends(_get_service_bundle)]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.pages.documents import register_pages as register_documents_pages
|
||||||
|
|
||||||
|
from ..theme import register_global_styles
|
||||||
|
from .jobs_page import register_page as register_jobs_page
|
||||||
|
from .people_page import register_page as register_people_page
|
||||||
|
from .sources_page import register_page as register_sources_page
|
||||||
|
from .upload_page import register_page as register_upload_page
|
||||||
|
|
||||||
|
__all__ = ["register_pages"]
|
||||||
|
|
||||||
|
|
||||||
|
def register_pages(app: FastAPI) -> None:
|
||||||
|
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||||
|
register_global_styles(app)
|
||||||
|
register_upload_page()
|
||||||
|
register_documents_pages()
|
||||||
|
register_people_page()
|
||||||
|
register_sources_page()
|
||||||
|
register_jobs_page()
|
||||||
|
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from ...dependency import ServicesDep
|
||||||
|
from ...dependency import SessionFactoryDep
|
||||||
|
from .delete_document import render_delete_document_page
|
||||||
|
from .document_detail import render_document_detail_page
|
||||||
|
from .document_overview import render_document_overview_page
|
||||||
|
from .edit_document import render_document_edit_page
|
||||||
|
from .new_document import render_new_document_page
|
||||||
|
|
||||||
|
__all__ = ["register_pages"]
|
||||||
|
|
||||||
|
|
||||||
|
def register_pages() -> None:
|
||||||
|
"""Register documents list and detail routes."""
|
||||||
|
|
||||||
|
@ui.page("/documents")
|
||||||
|
async def documents_page(services: ServicesDep) -> None:
|
||||||
|
await render_document_overview_page(services=services)
|
||||||
|
|
||||||
|
@ui.page("/documents/new")
|
||||||
|
async def document_create_page(request: Request, services: ServicesDep) -> None:
|
||||||
|
await render_new_document_page(request, services=services)
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}")
|
||||||
|
async def document_detail_page(document_id: str, services: ServicesDep) -> None:
|
||||||
|
await render_document_detail_page(document_id, services=services)
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/edit")
|
||||||
|
async def document_edit_page(document_id: str, services: ServicesDep, session_factory: SessionFactoryDep) -> None:
|
||||||
|
await render_document_edit_page(document_id, services=services, session_factory=session_factory)
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/delete")
|
||||||
|
async def document_delete_page(document_id: str, services: ServicesDep) -> None:
|
||||||
|
await render_delete_document_page(document_id, services=services)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.components.primitives import destructive_button
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
|
from ...dependency import ServicesDep
|
||||||
|
|
||||||
|
|
||||||
|
async def render_delete_document_page(document_id: str, services: ServicesDep) -> None:
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await services.documents.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.delete.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Delete Document")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-2"):
|
||||||
|
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
|
has_sources = bool(document.sources)
|
||||||
|
has_jobs = bool(document.jobs)
|
||||||
|
|
||||||
|
if has_sources or has_jobs:
|
||||||
|
ui.label("Delete is blocked because related records exist.").classes(
|
||||||
|
"text-xs text-red-800 font-bold mt-2"
|
||||||
|
)
|
||||||
|
categories: list[str] = []
|
||||||
|
if has_sources:
|
||||||
|
categories.append("Sources")
|
||||||
|
if has_jobs:
|
||||||
|
categories.append("Jobs")
|
||||||
|
ui.label(f"Dependencies present: {', '.join(categories)}").classes("text-xs ui-text-muted")
|
||||||
|
ui.label("Remove related records first, then retry deletion.").classes("text-xs ui-text-muted italic")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
|
ui.button(
|
||||||
|
"Back to Document",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
||||||
|
"flat text-xs"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("This action permanently deletes the document.").classes("text-xs text-red-800 font-medium")
|
||||||
|
|
||||||
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await services.documents.delete_document(document)
|
||||||
|
except DocumentDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
ui.navigate.to(f"/documents/{document.id}/delete")
|
||||||
|
return
|
||||||
|
except DocumentError as exc:
|
||||||
|
if exc.category == ErrorCategory.NOT_FOUND:
|
||||||
|
ui.notify("Document not found.", type="warning")
|
||||||
|
ui.navigate.to("/documents")
|
||||||
|
return
|
||||||
|
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete failed", operation="documents.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Document deleted", type="positive")
|
||||||
|
ui.navigate.to("/documents")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
destructive_button(
|
||||||
|
"Delete document permanently",
|
||||||
|
on_click=submit_delete,
|
||||||
|
icon="delete_forever",
|
||||||
|
variant="solid",
|
||||||
|
)
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Documents list and detail page registration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.components.primitives import destructive_button
|
||||||
|
from transcription.ui.components.primitives import section_header_row
|
||||||
|
from transcription.ui.components.viewers import dark_room_viewer
|
||||||
|
from transcription.ui.dependency import ServicesDep
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
|
from ...components import document_details as details
|
||||||
|
|
||||||
|
|
||||||
|
async def render_document_detail_page(document_id: str, services: ServicesDep) -> None:
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await services.documents.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
author_link = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in document.document_people
|
||||||
|
if item.role == DocumentPersonRole.AUTHOR and item.person is not None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Main Bento Grid Wrapper
|
||||||
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
|
# Header Bar
|
||||||
|
with section_header_row():
|
||||||
|
page_header(document.name, subtitle=f"Type: {document.document_type or 'Unspecified'} | ID: {document.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Edit Document",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/edit"),
|
||||||
|
icon="edit",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
destructive_button(
|
||||||
|
"Delete",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/delete"),
|
||||||
|
icon="delete",
|
||||||
|
extra_classes="text-xs",
|
||||||
|
)
|
||||||
|
|
||||||
|
# High-Density Bento Grid Layout
|
||||||
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
|
# ZONE 1: Source Image Viewer (Cols 1-5)
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-5"):
|
||||||
|
source_path = document.sources[0].file_path if document.sources else None
|
||||||
|
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
|
||||||
|
with ui.row().classes("w-full justify-between items-center mt-2"):
|
||||||
|
ui.button(
|
||||||
|
"View All Sources",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
|
||||||
|
icon="description",
|
||||||
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
|
ui.button(
|
||||||
|
"+ Add Source",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
|
||||||
|
icon="add",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
|
||||||
|
details.render_archival_metadata(document=document, author_link=author_link)
|
||||||
|
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-3 gap-4"):
|
||||||
|
details.render_doc_people_details(document=document)
|
||||||
|
details.render_doc_job_details(document=document)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.components.primitives import section_header_row
|
||||||
|
from transcription.ui.components.table.documents import DocumentTableRow
|
||||||
|
from transcription.ui.components.table.documents import render_documents_table
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
|
from ...dependency import ServicesDep
|
||||||
|
|
||||||
|
|
||||||
|
async def render_document_overview_page(services: ServicesDep) -> None:
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header("Archival Documents")
|
||||||
|
ui.button(
|
||||||
|
"Create new document",
|
||||||
|
on_click=lambda: ui.navigate.to("/documents/new"),
|
||||||
|
icon="note_add",
|
||||||
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
|
try:
|
||||||
|
documents = sorted(
|
||||||
|
await services.documents.list_documents(),
|
||||||
|
key=lambda item: item.created_at,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.list")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Format documents into read-model rows for the table renderer
|
||||||
|
rows = [
|
||||||
|
DocumentTableRow(
|
||||||
|
id=doc.id,
|
||||||
|
name=doc.name,
|
||||||
|
document_type=doc.document_type or "",
|
||||||
|
archive_identifier=doc.archive_identifier or "",
|
||||||
|
created_at=doc.created_at.strftime("%b %d, %Y"),
|
||||||
|
)
|
||||||
|
for doc in documents
|
||||||
|
]
|
||||||
|
render_documents_table(rows)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
|
||||||
|
from ....db.session import SessionFactory
|
||||||
|
from ...components.app_shell import render_navigation_header
|
||||||
|
from ...components.document_form import CREATE_NEW_PERSON_OPTION
|
||||||
|
from ...components.document_form import DocumentEditForm
|
||||||
|
from ...components.document_form import render_document_edit_form
|
||||||
|
from ...components.error_presenter import show_error
|
||||||
|
from ...dependency import ServicesDep
|
||||||
|
from ...theme import page_header
|
||||||
|
|
||||||
|
|
||||||
|
def _build_updated_document(edit_form: DocumentEditForm, document: Document) -> tuple[Document | None, str | None]:
|
||||||
|
candidate_name = (edit_form.name or "").strip()
|
||||||
|
candidate_type = (edit_form.document_type or "").strip()
|
||||||
|
if not candidate_name:
|
||||||
|
return None, "Document name is required."
|
||||||
|
if not candidate_type:
|
||||||
|
return None, "Document type is required."
|
||||||
|
|
||||||
|
parsed_date: date | None = None
|
||||||
|
candidate_date_text = (edit_form.document_date or "").strip()
|
||||||
|
if candidate_date_text:
|
||||||
|
try:
|
||||||
|
parsed_date = date.fromisoformat(candidate_date_text)
|
||||||
|
except ValueError:
|
||||||
|
return None, "Exact date must use YYYY-MM-DD."
|
||||||
|
|
||||||
|
return (
|
||||||
|
Document(
|
||||||
|
id=document.id,
|
||||||
|
name=candidate_name,
|
||||||
|
document_type=candidate_type,
|
||||||
|
document_date=parsed_date,
|
||||||
|
document_date_raw=(edit_form.document_date_raw or "").strip() or None,
|
||||||
|
location_created=(edit_form.location_created or "").strip() or None,
|
||||||
|
notes=(edit_form.notes or "").strip() or None,
|
||||||
|
archive_identifier=(edit_form.archive_identifier or "").strip() or None,
|
||||||
|
created_at=document.created_at,
|
||||||
|
updated_at=document.updated_at,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_author_links(
|
||||||
|
services: ServicesDep,
|
||||||
|
document: Document,
|
||||||
|
selected_author: str,
|
||||||
|
) -> None:
|
||||||
|
existing_author_links = [
|
||||||
|
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
|
||||||
|
]
|
||||||
|
if not selected_author:
|
||||||
|
for link in existing_author_links:
|
||||||
|
await services.documents.delete_document_person(link)
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_author_id = UUID(selected_author)
|
||||||
|
if any(link.person_id == selected_author_id for link in existing_author_links):
|
||||||
|
return
|
||||||
|
|
||||||
|
for link in existing_author_links:
|
||||||
|
await services.documents.delete_document_person(link)
|
||||||
|
await services.documents.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=selected_author_id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def render_document_edit_page(
|
||||||
|
document_id: str,
|
||||||
|
services: ServicesDep,
|
||||||
|
session_factory: SessionFactory,
|
||||||
|
) -> None:
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_document_id = UUID(document_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid document id").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document = await services.documents.read_document_detail(document_id=parsed_document_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="documents.edit.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Edit Document Record", subtitle="Document name and document type are required.")
|
||||||
|
|
||||||
|
edit_form = await render_document_edit_form(document=document, session=session_factory())
|
||||||
|
|
||||||
|
async def submit_edit() -> None:
|
||||||
|
candidate, validation_error = _build_updated_document(edit_form, document)
|
||||||
|
if validation_error:
|
||||||
|
ui.notify(validation_error, type="warning")
|
||||||
|
return
|
||||||
|
if candidate is None:
|
||||||
|
ui.notify("Unable to build updated document.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await services.documents.update_document(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="documents.edit.save")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_author = (edit_form.author_id or "").strip()
|
||||||
|
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||||
|
ui.navigate.to("/people/new")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await _sync_author_links(services=services, document=document, selected_author=selected_author)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Author update failed", operation="documents.edit.link_author")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Document updated", type="positive")
|
||||||
|
ui.navigate.to(f"/documents/{document.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
|
from ...dependency import ServicesDep
|
||||||
|
|
||||||
|
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
|
||||||
|
|
||||||
|
|
||||||
|
async def render_new_document_page(request: Request, services: ServicesDep) -> None:
|
||||||
|
render_navigation_header(current_path="/documents")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Create Document", subtitle="Document name is required.")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-3"):
|
||||||
|
name_input = ui.input(label="Document name").props("outlined bg-white").classes("w-full")
|
||||||
|
document_type_input = ui.input(label="Document type").props("outlined bg-white").classes("w-full")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
|
||||||
|
date_input = ui.input(label="Exact date (YYYY-MM-DD)").props('outlined bg-white type="date"')
|
||||||
|
date_raw_input = ui.input(label="Approximate date").props("outlined bg-white")
|
||||||
|
|
||||||
|
location_input = ui.input(label="Document location").props("outlined bg-white").classes("w-full")
|
||||||
|
archive_input = ui.input(label="Archive identifier").props("outlined bg-white").classes("w-full")
|
||||||
|
notes_input = ui.textarea(label="Notes").props("outlined bg-white autogrow").classes("w-full")
|
||||||
|
|
||||||
|
people = sorted(await services.documents.list_people(), key=lambda item: item.full_name.casefold())
|
||||||
|
author_options = {"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"} | {
|
||||||
|
str(person.id): person.full_name for person in people
|
||||||
|
}
|
||||||
|
|
||||||
|
def on_author_change(event) -> None:
|
||||||
|
selected = str(event.value or "").strip()
|
||||||
|
if selected == CREATE_NEW_PERSON_OPTION:
|
||||||
|
ui.navigate.to("/people/new")
|
||||||
|
|
||||||
|
author_select = (
|
||||||
|
ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change)
|
||||||
|
.props("outlined bg-white")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
ui.link("Create new person", "/people/new").classes("text-xs ui-link-primary font-medium")
|
||||||
|
|
||||||
|
return_to = request.query_params.get("return_to")
|
||||||
|
|
||||||
|
async def submit_create() -> None:
|
||||||
|
candidate_name = (name_input.value or "").strip()
|
||||||
|
if not candidate_name:
|
||||||
|
ui.notify("Document name is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed_date: date | None = None
|
||||||
|
candidate_date_text = (date_input.value or "").strip()
|
||||||
|
if candidate_date_text:
|
||||||
|
try:
|
||||||
|
parsed_date = date.fromisoformat(candidate_date_text)
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate = Document(
|
||||||
|
name=candidate_name,
|
||||||
|
document_type=(document_type_input.value or "").strip() or None,
|
||||||
|
document_date=parsed_date,
|
||||||
|
document_date_raw=(date_raw_input.value or "").strip() or None,
|
||||||
|
location_created=(location_input.value or "").strip() or None,
|
||||||
|
notes=(notes_input.value or "").strip() or None,
|
||||||
|
archive_identifier=(archive_input.value or "").strip() or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await services.documents.create_document(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Create failed", operation="documents.create")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_author = (author_select.value or "").strip()
|
||||||
|
if selected_author == CREATE_NEW_PERSON_OPTION:
|
||||||
|
ui.navigate.to("/people/new")
|
||||||
|
return
|
||||||
|
if selected_author:
|
||||||
|
try:
|
||||||
|
parsed_person_id = UUID(selected_author)
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Selected author is invalid.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await services.documents.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=created.id,
|
||||||
|
person_id=parsed_person_id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Author link failed", operation="documents.create.link_author")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Document created", type="positive")
|
||||||
|
if return_to == "jobs_new":
|
||||||
|
ui.navigate.to(f"/jobs/new?document_id={created.id}")
|
||||||
|
return
|
||||||
|
ui.navigate.to(f"/documents/{created.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Save document", on_click=submit_create, icon="save").classes("ui-btn-primary")
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to("/documents"), icon="arrow_back").props("flat")
|
||||||
@@ -2,24 +2,32 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.db.models import Job
|
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.session import session_scope
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobDeleteBlockedError
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
from transcription.services.transcription import TranscriptionService
|
from transcription.services.store import create_job_for_document
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.data_display import archival_badge
|
||||||
|
from transcription.ui.components.data_display import metadata_row
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
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
|
||||||
from transcription.ui.components.table.jobs import render_jobs_table
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
from ...db.session import SessionFactoryDep
|
from ...db.session import SessionFactoryDep
|
||||||
from ..components.document_panzoom import render_document_panzoom
|
|
||||||
from ..components.table.jobs import JobTableRow
|
from ..components.table.jobs import JobTableRow
|
||||||
from ..components.transcript import render_original_transcription_card
|
|
||||||
from ..components.transcript import render_revision_row
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None: # noqa: PLR0915
|
def register_page() -> None: # noqa: PLR0915
|
||||||
@@ -27,9 +35,19 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
|
|
||||||
@ui.page("/jobs")
|
@ui.page("/jobs")
|
||||||
async def jobs_page(session_factory: SessionFactoryDep) -> None:
|
async def jobs_page(session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header("Transcription Pipeline Jobs")
|
||||||
|
with ui.row().classes("items-center gap-2"):
|
||||||
|
ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
|
||||||
|
"ui-btn-primary"
|
||||||
|
)
|
||||||
|
ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
async def render_table() -> None:
|
async def render_table() -> None:
|
||||||
jobs = [
|
jobs = [
|
||||||
@@ -45,115 +63,268 @@ def register_page() -> None: # noqa: PLR0915
|
|||||||
]
|
]
|
||||||
render_jobs_table(jobs)
|
render_jobs_table(jobs)
|
||||||
|
|
||||||
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
|
||||||
await render_table()
|
await render_table()
|
||||||
|
|
||||||
|
@ui.page("/jobs/new")
|
||||||
|
async def job_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
documents_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
|
page_header(
|
||||||
|
"Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
|
||||||
|
)
|
||||||
|
|
||||||
|
documents = await documents_service.list_documents()
|
||||||
|
if not documents:
|
||||||
|
with archival_card(extra_classes="p-6 text-center"):
|
||||||
|
render_empty_state(
|
||||||
|
"No documents available. Create a Document before creating a Job.",
|
||||||
|
extra_classes="text-red-800 font-medium mb-4",
|
||||||
|
)
|
||||||
|
with ui.row().classes("justify-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Create document",
|
||||||
|
on_click=lambda: ui.navigate.to("/documents/new?return_to=jobs_new"),
|
||||||
|
icon="note_add",
|
||||||
|
).classes("ui-btn-primary")
|
||||||
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
uploaded_files: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-3"):
|
||||||
|
document_options = {str(document.id): document.name for document in documents}
|
||||||
|
document_select = (
|
||||||
|
ui.select(document_options, label="Target Document").props("outlined bg-white").classes("w-full")
|
||||||
|
)
|
||||||
|
requested_document_id = request.query_params.get("document_id")
|
||||||
|
if requested_document_id in document_options:
|
||||||
|
document_select.value = requested_document_id
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
|
provider_input = ui.input(label="Provider").props("outlined bg-white")
|
||||||
|
model_input = ui.input(label="Model").props("outlined bg-white")
|
||||||
|
prompt_input = ui.input(label="Prompt").props("outlined bg-white")
|
||||||
|
|
||||||
|
with archival_card(title="Source Files"):
|
||||||
|
ui.label(
|
||||||
|
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
|
||||||
|
).classes("text-xs ui-text-muted mb-2")
|
||||||
|
|
||||||
|
@ui.refreshable
|
||||||
|
def render_upload_list() -> None:
|
||||||
|
if not uploaded_files:
|
||||||
|
render_empty_state("No files uploaded yet.", italic=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
def remove_file(index: int) -> None:
|
||||||
|
if 0 <= index < len(uploaded_files):
|
||||||
|
removed_name, _ = uploaded_files.pop(index)
|
||||||
|
ui.notify(f"Removed {removed_name}", type="info")
|
||||||
|
render_upload_list.refresh()
|
||||||
|
|
||||||
|
def clear_files() -> None:
|
||||||
|
uploaded_files.clear()
|
||||||
|
ui.notify("Cleared queued files", type="info")
|
||||||
|
render_upload_list.refresh()
|
||||||
|
|
||||||
|
ordered_uploads = sorted(
|
||||||
|
enumerate(uploaded_files),
|
||||||
|
key=lambda item: Path(item[1][0]).name.casefold(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.column().classes("gap-1 w-full mt-2"):
|
||||||
|
for index, (filename, _) in ordered_uploads:
|
||||||
|
with ui.row().classes("w-full items-center justify-between ui-row-surface p-2"):
|
||||||
|
ui.label(Path(filename).name).classes("text-xs font-mono ui-text-primary")
|
||||||
|
ui.button(icon="delete", on_click=lambda idx=index: remove_file(idx)).props(
|
||||||
|
"flat round dense color=negative text-xs"
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.row().classes("w-full justify-end mt-2"):
|
||||||
|
ui.button("Clear files", on_click=clear_files, icon="clear_all").props(
|
||||||
|
"flat dense"
|
||||||
|
).classes("text-xs text-red-800")
|
||||||
|
|
||||||
|
async def on_upload(event) -> None:
|
||||||
|
payload = await event.file.read()
|
||||||
|
uploaded_files.append((event.file.name, payload))
|
||||||
|
ui.notify(f"Added {event.file.name}", type="positive")
|
||||||
|
render_upload_list.refresh()
|
||||||
|
|
||||||
|
ui.upload(
|
||||||
|
on_upload=on_upload,
|
||||||
|
auto_upload=True,
|
||||||
|
label="Select source files or a folder",
|
||||||
|
).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
|
||||||
|
|
||||||
|
render_upload_list()
|
||||||
|
|
||||||
|
async def submit_create() -> None:
|
||||||
|
selected_document = document_select.value
|
||||||
|
if not selected_document:
|
||||||
|
ui.notify("Document is required.", type="warning")
|
||||||
|
return
|
||||||
|
if not uploaded_files:
|
||||||
|
ui.notify("At least one source file is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
document_id = UUID(str(selected_document))
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Selected document id is invalid.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session_scope(session_factory=session_factory) as session:
|
||||||
|
result = await create_job_for_document(
|
||||||
|
document_id=document_id,
|
||||||
|
uploads=uploaded_files,
|
||||||
|
provider=(provider_input.value or None),
|
||||||
|
model=(model_input.value or None),
|
||||||
|
prompt_name=(prompt_input.value or None),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Create job failed", operation="jobs.create")
|
||||||
|
return
|
||||||
|
|
||||||
|
resolve_worker_notifier(request.app.state).notify()
|
||||||
|
ui.notify(f"Created job {result.job_id}", type="positive")
|
||||||
|
ui.navigate.to(f"/jobs/{result.job_id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Submit for transcription", on_click=submit_create, icon="play_arrow").classes(
|
||||||
|
"ui-btn-primary"
|
||||||
|
)
|
||||||
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None: # noqa: PLR0915
|
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
jobs_service = JobService(session_factory=session_factory)
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
transcription_service = TranscriptionService(session_factory=session_factory)
|
|
||||||
render_navigation_header(current_path="/jobs")
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsed_job_id = UUID(job_id)
|
parsed_job_id = UUID(job_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ui.label("Invalid job id").classes("text-h6 text-negative")
|
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
job = await jobs_service.read_job(job_id=parsed_job_id)
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ui.label("Job not found").classes("text-h6 text-negative")
|
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
||||||
return
|
return
|
||||||
|
|
||||||
source = _resolve_primary_source(job)
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row(classes="justify-between items-center"):
|
||||||
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
|
page_header(f"Job Record: {job.id}")
|
||||||
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
with ui.row().classes("items-center gap-2"):
|
||||||
if source is not None:
|
archival_badge(job.status.value.upper())
|
||||||
render_document_panzoom(source=source)
|
destructive_button(
|
||||||
else:
|
"Delete Job",
|
||||||
ui.label("No source preview is available for this job.").classes("text-body2 vibe-text-muted")
|
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}/delete"),
|
||||||
with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
icon="delete",
|
||||||
with ui.row():
|
extra_classes="text-xs",
|
||||||
ui.button(icon="arrow_back", on_click=ui.navigate.back)
|
|
||||||
with ui.row().classes("w-full items-center justify-between"):
|
|
||||||
ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
|
|
||||||
match job.status:
|
|
||||||
case JobStatus.TRANSCRIBED:
|
|
||||||
ui.chip(job.status.value.upper(), color="positive", text_color="white").props("outline")
|
|
||||||
case _:
|
|
||||||
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
|
||||||
|
|
||||||
render_original_transcription_card(job=job)
|
|
||||||
|
|
||||||
async def delete_revision_by_id(revision_id: UUID) -> None:
|
|
||||||
try:
|
|
||||||
revision = await transcription_service.read_revision(revision_id=revision_id)
|
|
||||||
await transcription_service.delete_revision(revision)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Delete failed", operation="jobs.delete_revision")
|
|
||||||
return
|
|
||||||
|
|
||||||
ui.notify("Deleted revision", type="positive")
|
|
||||||
await render_revision_panel.refresh()
|
|
||||||
|
|
||||||
@ui.refreshable
|
|
||||||
async def render_revision_panel() -> None:
|
|
||||||
refreshed_job = await jobs_service.read_job(job_id=parsed_job_id)
|
|
||||||
refreshed_source = _resolve_primary_source(refreshed_job)
|
|
||||||
if refreshed_source is None:
|
|
||||||
ui.label("No source is available for revision editing.").classes("text-body2 vibe-text-muted")
|
|
||||||
return
|
|
||||||
|
|
||||||
current_revision = refreshed_source.revision
|
|
||||||
default_revision_text = (
|
|
||||||
current_revision.text if current_revision is not None else (refreshed_job.text or "")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ui.label("Revision Editor").classes("text-subtitle1 text-weight-medium")
|
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
|
||||||
editor = ui.textarea(label="Revision text", value=default_revision_text).props("autogrow outlined")
|
with archival_card(title="Execution Logistics"):
|
||||||
editor.classes("w-full")
|
metadata_row("Provider:", job.provider or "pending")
|
||||||
|
metadata_row("Model:", job.model or "pending")
|
||||||
|
metadata_row("Prompt:", job.prompt_name or "pending")
|
||||||
|
metadata_row("Retry Count:", str(job.retry_count))
|
||||||
|
metadata_row("Last Updated:", job.date_updated.isoformat())
|
||||||
|
|
||||||
async def save_revision() -> None:
|
with archival_card(title="Document Links"):
|
||||||
candidate = (editor.value or "").strip()
|
ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
|
||||||
if not candidate:
|
with ui.column().classes("w-full gap-2"):
|
||||||
ui.notify("Revision text is required.", type="warning")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
await transcription_service.upsert_revision_for_source(
|
|
||||||
source_id=refreshed_source.id,
|
|
||||||
text=candidate,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
show_error(exc, title="Save failed", operation="jobs.save_revision")
|
|
||||||
return
|
|
||||||
|
|
||||||
ui.notify("Revision saved", type="positive")
|
|
||||||
await render_revision_panel.refresh()
|
|
||||||
|
|
||||||
with ui.row().classes("w-full justify-end"):
|
|
||||||
ui.button(
|
ui.button(
|
||||||
"Create revision" if current_revision is None else "Update revision",
|
"View Linked Document",
|
||||||
on_click=save_revision,
|
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
|
||||||
icon="save",
|
icon="description",
|
||||||
).props('unelevated color="primary"')
|
).classes("ui-btn-primary text-xs w-full")
|
||||||
|
ui.button(
|
||||||
|
"View Linked Sources",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
|
||||||
|
icon="description",
|
||||||
|
).props("flat text-xs").classes("ui-link-primary w-full")
|
||||||
|
|
||||||
if current_revision is None:
|
@ui.page("/jobs/{job_id}/delete")
|
||||||
ui.label("No revision exists for this source.").classes("text-body2 vibe-text-muted")
|
async def job_delete_page(job_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_job_id = UUID(job_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid job id").classes("text-h6 text-red-800 p-4")
|
||||||
return
|
return
|
||||||
|
|
||||||
render_revision_row(
|
try:
|
||||||
revision=current_revision,
|
job = await jobs_service.read_job(job_id=parsed_job_id)
|
||||||
initially_expanded=True,
|
except ValueError:
|
||||||
on_delete=lambda _revision, rid=current_revision.id: delete_revision_by_id(rid),
|
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Delete Processing Job")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-2"):
|
||||||
|
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
|
||||||
|
|
||||||
|
if job.status == JobStatus.PROCESSING:
|
||||||
|
ui.label("Delete is blocked while the job is processing.").classes(
|
||||||
|
"text-xs text-red-800 font-bold mt-2"
|
||||||
|
)
|
||||||
|
ui.label("Wait for processing to complete, then retry delete.").classes(
|
||||||
|
"text-xs ui-text-muted italic"
|
||||||
|
)
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
|
ui.button(
|
||||||
|
"Back to Job",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
|
||||||
|
"flat text-xs"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("This action permanently deletes the job.").classes("text-xs text-red-800 font-medium")
|
||||||
|
if job.job_sources:
|
||||||
|
ui.label("Related JobSource links will be removed as part of delete.").classes(
|
||||||
|
"text-xs ui-text-muted"
|
||||||
)
|
)
|
||||||
|
|
||||||
await render_revision_panel()
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await jobs_service.delete_job_with_guardrails(job_id=job.id)
|
||||||
|
except JobDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
ui.notify("Job not found.", type="warning")
|
||||||
|
ui.navigate.to("/jobs")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete job failed", operation="jobs.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Job deleted", type="positive")
|
||||||
|
ui.navigate.to("/jobs")
|
||||||
|
|
||||||
def _resolve_primary_source(job: Job) -> Source | None:
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
if not job.sources:
|
destructive_button(
|
||||||
return None
|
"Delete job permanently",
|
||||||
return job.sources[0]
|
on_click=submit_delete,
|
||||||
|
icon="delete_forever",
|
||||||
|
variant="solid",
|
||||||
|
)
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
|
||||||
|
|||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""People list and detail page registration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import quote
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import get_settings
|
||||||
|
from transcription.db.models import Person
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.documents import PersonDeleteBlockedError
|
||||||
|
from transcription.services.store import UploadError
|
||||||
|
from transcription.services.store import store_person_portrait
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
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
|
||||||
|
from transcription.ui.components.table.people import PersonTableRow
|
||||||
|
from transcription.ui.components.table.people import render_people_table
|
||||||
|
from transcription.ui.components.viewers import dark_room_viewer
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_date(value: str | None, *, label: str) -> date | None:
|
||||||
|
candidate = (value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(candidate)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings) -> None:
|
||||||
|
async def on_portrait_selected(event) -> None:
|
||||||
|
payload = await event.file.read()
|
||||||
|
try:
|
||||||
|
stored_path = store_person_portrait(
|
||||||
|
filename=event.file.name,
|
||||||
|
file_bytes=payload,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
except UploadError as exc:
|
||||||
|
ui.notify(str(exc), type="negative")
|
||||||
|
return
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
ui.notify("Unable to store portrait image.", type="negative")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
relative_path = stored_path.name
|
||||||
|
|
||||||
|
portrait_path_input.value = relative_path
|
||||||
|
ui.notify("Portrait uploaded.", type="positive")
|
||||||
|
|
||||||
|
ui.upload(
|
||||||
|
on_upload=on_portrait_selected,
|
||||||
|
auto_upload=True,
|
||||||
|
label="Choose portrait file",
|
||||||
|
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
|
||||||
|
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-xs ui-text-muted")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_portrait_src(path: str | None) -> str | None:
|
||||||
|
candidate = (path or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized = candidate.replace("\\", "/")
|
||||||
|
lowered = normalized.casefold()
|
||||||
|
if lowered.startswith("http://") or lowered.startswith("https://") or lowered.startswith("data:"):
|
||||||
|
return normalized
|
||||||
|
if normalized.startswith("/"):
|
||||||
|
return normalized
|
||||||
|
if lowered.startswith("uploads/"):
|
||||||
|
return f"/{normalized}"
|
||||||
|
return f"/uploads/{quote(normalized)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime_settings(request: Request) -> Settings:
|
||||||
|
app_settings = getattr(request.app.state, "settings", None)
|
||||||
|
if isinstance(app_settings, Settings):
|
||||||
|
return app_settings
|
||||||
|
return get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def register_page() -> None: # noqa: PLR0915
|
||||||
|
"""Register people list and CRUD routes."""
|
||||||
|
|
||||||
|
@ui.page("/people")
|
||||||
|
async def people_page(session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header("Archival Entities: People")
|
||||||
|
ui.button(
|
||||||
|
"Create new person",
|
||||||
|
on_click=lambda: ui.navigate.to("/people/new"),
|
||||||
|
icon="person_add",
|
||||||
|
).classes("ui-btn-primary")
|
||||||
|
|
||||||
|
try:
|
||||||
|
people = sorted(
|
||||||
|
await people_service.list_people(),
|
||||||
|
key=lambda item: item.created_at,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.list")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Format person records into read-model rows for the table renderer
|
||||||
|
rows = [
|
||||||
|
PersonTableRow(
|
||||||
|
id=person.id,
|
||||||
|
full_name=person.full_name,
|
||||||
|
display_name=person.display_name or "",
|
||||||
|
maiden_name=person.maiden_name or "",
|
||||||
|
birth_date=person.birth_date.isoformat() if person.birth_date else "",
|
||||||
|
)
|
||||||
|
for person in people
|
||||||
|
]
|
||||||
|
render_people_table(rows)
|
||||||
|
|
||||||
|
@ui.page("/people/new")
|
||||||
|
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Create Person Record", subtitle="Full name is required.")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-3"):
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
|
full_name_input = ui.input(label="Full name").props("outlined bg-white")
|
||||||
|
display_name_input = ui.input(label="Display name").props("outlined bg-white")
|
||||||
|
maiden_name_input = ui.input(label="Maiden name").props("outlined bg-white")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
|
birth_date_input = ui.input(label="Birth date (YYYY-MM-DD)").props('outlined bg-white type="date"')
|
||||||
|
birth_date_raw_input = ui.input(label="Birth date (approximate)").props("outlined bg-white")
|
||||||
|
birth_place_input = ui.input(label="Birth place").props("outlined bg-white")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
|
death_date_input = ui.input(label="Death date (YYYY-MM-DD)").props('outlined bg-white type="date"')
|
||||||
|
death_date_raw_input = ui.input(label="Death date (approximate)").props("outlined bg-white")
|
||||||
|
death_place_input = ui.input(label="Death place").props("outlined bg-white")
|
||||||
|
|
||||||
|
biography_input = ui.textarea(label="Biography").props("outlined bg-white autogrow").classes("w-full")
|
||||||
|
portrait_path_input = ui.input(label="Portrait path").props("outlined bg-white").classes("w-full")
|
||||||
|
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
||||||
|
|
||||||
|
async def submit_create() -> None:
|
||||||
|
full_name = (full_name_input.value or "").strip()
|
||||||
|
if not full_name:
|
||||||
|
ui.notify("Full name is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||||
|
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||||
|
except ValueError as exc:
|
||||||
|
ui.notify(str(exc), type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate = Person(
|
||||||
|
full_name=full_name,
|
||||||
|
display_name=(display_name_input.value or "").strip() or None,
|
||||||
|
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||||
|
birth_date=birth_date,
|
||||||
|
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||||
|
birth_place=(birth_place_input.value or "").strip() or None,
|
||||||
|
death_date=death_date,
|
||||||
|
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||||
|
death_place=(death_place_input.value or "").strip() or None,
|
||||||
|
biography=(biography_input.value or "").strip() or None,
|
||||||
|
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await people_service.create_person(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Create failed", operation="people.create")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Person created", type="positive")
|
||||||
|
ui.navigate.to(f"/people/{created.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Save person", on_click=submit_create, icon="save").classes("ui-btn-primary")
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
|
||||||
|
|
||||||
|
@ui.page("/people/{person_id}")
|
||||||
|
async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_person_id = UUID(person_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
portrait_src = _resolve_portrait_src(person.portrait_path)
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("items-center gap-2"):
|
||||||
|
ui.button(
|
||||||
|
"Edit Person",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
|
||||||
|
icon="edit",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
destructive_button(
|
||||||
|
"Delete",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
|
||||||
|
icon="delete",
|
||||||
|
extra_classes="text-xs",
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-4"):
|
||||||
|
dark_room_viewer(portrait_src, count_label="Portrait Media")
|
||||||
|
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
|
with archival_card(title="Biographical Record"):
|
||||||
|
metadata_row("Full Name:", person.full_name)
|
||||||
|
metadata_row("Display Name:", person.display_name or "Not set")
|
||||||
|
metadata_row("Maiden Name:", person.maiden_name or "Not set")
|
||||||
|
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
|
||||||
|
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
|
||||||
|
metadata_row("Birth Place:", person.birth_place or "Not set")
|
||||||
|
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
|
||||||
|
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
|
||||||
|
metadata_row("Death Place:", person.death_place or "Not set")
|
||||||
|
|
||||||
|
with archival_card(title="System Logistics"):
|
||||||
|
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
|
ui.label(f"Updated: {person.updated_at.isoformat()}").classes("text-[11px] ui-text-muted")
|
||||||
|
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
|
||||||
|
with archival_card(title="Biography"):
|
||||||
|
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
|
||||||
|
|
||||||
|
with archival_card(title="Linked Documents"):
|
||||||
|
if not person.document_people:
|
||||||
|
render_empty_state("No linked documents yet.", italic=True)
|
||||||
|
render_empty_state("Link this person from a Document workflow.")
|
||||||
|
else:
|
||||||
|
with ui.column().classes("w-full gap-2"):
|
||||||
|
for link in person.document_people:
|
||||||
|
document = link.document
|
||||||
|
if document is None:
|
||||||
|
continue
|
||||||
|
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
|
||||||
|
with ui.column().classes("gap-0"):
|
||||||
|
ui.label(document.name).classes("text-xs font-semibold ui-text-primary")
|
||||||
|
ui.label(f"Role: {link.role.value}").classes("text-[10px] ui-text-muted")
|
||||||
|
ui.button(
|
||||||
|
"Open",
|
||||||
|
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(
|
||||||
|
f"/documents/{doc_id}"
|
||||||
|
),
|
||||||
|
icon="open_in_new",
|
||||||
|
).props("flat dense text-xs").classes("ui-link-primary")
|
||||||
|
|
||||||
|
@ui.page("/people/{person_id}/edit")
|
||||||
|
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_person_id = UUID(person_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.edit.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Edit Person Record", subtitle="Full name is required.")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-3"):
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
|
full_name_input = ui.input(label="Full name", value=person.full_name).props("outlined bg-white")
|
||||||
|
display_name_input = ui.input(label="Display name", value=person.display_name or "").props(
|
||||||
|
"outlined bg-white"
|
||||||
|
)
|
||||||
|
maiden_name_input = ui.input(label="Maiden name", value=person.maiden_name or "").props(
|
||||||
|
"outlined bg-white"
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
|
birth_date_input = ui.input(
|
||||||
|
label="Birth date (YYYY-MM-DD)",
|
||||||
|
value=person.birth_date.isoformat() if person.birth_date else "",
|
||||||
|
).props('outlined bg-white type="date"')
|
||||||
|
birth_date_raw_input = ui.input(
|
||||||
|
label="Birth date (approximate)", value=person.birth_date_raw or ""
|
||||||
|
).props("outlined bg-white")
|
||||||
|
birth_place_input = ui.input(label="Birth place", value=person.birth_place or "").props(
|
||||||
|
"outlined bg-white"
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
|
||||||
|
death_date_input = ui.input(
|
||||||
|
label="Death date (YYYY-MM-DD)",
|
||||||
|
value=person.death_date.isoformat() if person.death_date else "",
|
||||||
|
).props('outlined bg-white type="date"')
|
||||||
|
death_date_raw_input = ui.input(
|
||||||
|
label="Death date (approximate)", value=person.death_date_raw or ""
|
||||||
|
).props("outlined bg-white")
|
||||||
|
death_place_input = ui.input(label="Death place", value=person.death_place or "").props(
|
||||||
|
"outlined bg-white"
|
||||||
|
)
|
||||||
|
|
||||||
|
biography_input = (
|
||||||
|
ui.textarea(label="Biography", value=person.biography or "")
|
||||||
|
.props("outlined bg-white autogrow")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
portrait_path_input = (
|
||||||
|
ui.input(label="Portrait path", value=person.portrait_path or "")
|
||||||
|
.props("outlined bg-white")
|
||||||
|
.classes("w-full")
|
||||||
|
)
|
||||||
|
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
|
||||||
|
|
||||||
|
async def submit_edit() -> None:
|
||||||
|
full_name = (full_name_input.value or "").strip()
|
||||||
|
if not full_name:
|
||||||
|
ui.notify("Full name is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
birth_date = _parse_optional_date(birth_date_input.value, label="Birth date")
|
||||||
|
death_date = _parse_optional_date(death_date_input.value, label="Death date")
|
||||||
|
except ValueError as exc:
|
||||||
|
ui.notify(str(exc), type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate = Person(
|
||||||
|
id=person.id,
|
||||||
|
full_name=full_name,
|
||||||
|
display_name=(display_name_input.value or "").strip() or None,
|
||||||
|
maiden_name=(maiden_name_input.value or "").strip() or None,
|
||||||
|
birth_date=birth_date,
|
||||||
|
birth_date_raw=(birth_date_raw_input.value or "").strip() or None,
|
||||||
|
birth_place=(birth_place_input.value or "").strip() or None,
|
||||||
|
death_date=death_date,
|
||||||
|
death_date_raw=(death_date_raw_input.value or "").strip() or None,
|
||||||
|
death_place=(death_place_input.value or "").strip() or None,
|
||||||
|
biography=(biography_input.value or "").strip() or None,
|
||||||
|
portrait_path=(portrait_path_input.value or "").strip() or None,
|
||||||
|
metadata_=person.metadata_,
|
||||||
|
created_at=person.created_at,
|
||||||
|
updated_at=person.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await people_service.update_person(candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="people.edit.save")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Person updated", type="positive")
|
||||||
|
ui.navigate.to(f"/people/{person.id}")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Save changes", on_click=submit_edit, icon="save").classes("ui-btn-primary")
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
|
|
||||||
|
@ui.page("/people/{person_id}/delete")
|
||||||
|
async def person_delete_page(person_id: str, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
people_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/people")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_person_id = UUID(person_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid person id").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
person = await people_service.read_person_detail(parsed_person_id)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Person not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="people.delete.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
|
||||||
|
page_header("Delete Person Record")
|
||||||
|
|
||||||
|
with archival_card(extra_classes="gap-2"):
|
||||||
|
ui.label(f"Person: {person.full_name}").classes("text-sm font-semibold ui-text-primary")
|
||||||
|
|
||||||
|
if person.document_people:
|
||||||
|
ui.label("Delete is blocked because linked documents exist.").classes(
|
||||||
|
"text-xs text-red-800 font-bold mt-2"
|
||||||
|
)
|
||||||
|
ui.label(f"Linked documents: {len(person.document_people)}").classes("text-xs ui-text-muted")
|
||||||
|
ui.label("Remove document links first, then retry deletion.").classes(
|
||||||
|
"text-xs ui-text-muted italic"
|
||||||
|
)
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-4"):
|
||||||
|
ui.button(
|
||||||
|
"Back to Person",
|
||||||
|
on_click=lambda: ui.navigate.to(f"/people/{person.id}"),
|
||||||
|
icon="arrow_back",
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
ui.button(
|
||||||
|
"Go to Documents", on_click=lambda: ui.navigate.to("/documents"), icon="description"
|
||||||
|
).props("flat text-xs")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.label("This action permanently deletes the person record.").classes(
|
||||||
|
"text-xs text-red-800 font-medium"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def submit_delete() -> None:
|
||||||
|
try:
|
||||||
|
await people_service.delete_person(person)
|
||||||
|
except PersonDeleteBlockedError as exc:
|
||||||
|
ui.notify(exc.message, type="warning")
|
||||||
|
ui.navigate.to(f"/people/{person.id}/delete")
|
||||||
|
return
|
||||||
|
except DocumentError as exc:
|
||||||
|
if exc.category == ErrorCategory.NOT_FOUND:
|
||||||
|
ui.notify("Person not found.", type="warning")
|
||||||
|
ui.navigate.to("/people")
|
||||||
|
return
|
||||||
|
show_error(exc, title="Delete failed", operation="people.delete")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete failed", operation="people.delete")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Person deleted", type="positive")
|
||||||
|
ui.navigate.to("/people")
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
destructive_button(
|
||||||
|
"Delete person permanently",
|
||||||
|
on_click=submit_delete,
|
||||||
|
icon="delete_forever",
|
||||||
|
variant="solid",
|
||||||
|
)
|
||||||
|
ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
|
||||||
|
"flat"
|
||||||
|
)
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""Sources list and detail page registration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.transcription import TranscriptionNotFoundError
|
||||||
|
from transcription.services.transcription import TranscriptionService
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.cards import archival_card
|
||||||
|
from transcription.ui.components.data_display import metadata_row
|
||||||
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
|
from transcription.ui.components.error_presenter import show_error
|
||||||
|
from transcription.ui.components.primitives import section_header_row
|
||||||
|
from transcription.ui.components.table.sources import SourceTableRow
|
||||||
|
from transcription.ui.components.table.sources import render_sources_table
|
||||||
|
from transcription.ui.theme import page_header
|
||||||
|
|
||||||
|
from ...db.session import SessionFactoryDep
|
||||||
|
|
||||||
|
|
||||||
|
def register_page() -> None:
|
||||||
|
"""Register source list and detail routes."""
|
||||||
|
|
||||||
|
@ui.page("/sources")
|
||||||
|
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
|
||||||
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
|
documents_service = DocumentService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
|
document_id_text = request.query_params.get("document_id")
|
||||||
|
job_id_text = request.query_params.get("job_id")
|
||||||
|
|
||||||
|
document_id = _parse_uuid(document_id_text)
|
||||||
|
job_id = _parse_uuid(job_id_text)
|
||||||
|
|
||||||
|
document_name = None
|
||||||
|
job_label = None
|
||||||
|
back_path = None
|
||||||
|
sources: list[Source] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if document_id is not None:
|
||||||
|
document = await documents_service.read_document_detail(document_id=document_id)
|
||||||
|
document_name = document.name
|
||||||
|
back_path = f"/documents/{document.id}"
|
||||||
|
sources = sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
|
elif job_id is not None:
|
||||||
|
job = await jobs_service.read_job(job_id=job_id)
|
||||||
|
job_label = str(job.id)
|
||||||
|
back_path = f"/jobs/{job.id}"
|
||||||
|
job_sources = await sources_service.list_job_sources(job_id=job.id)
|
||||||
|
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
|
||||||
|
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
|
||||||
|
else:
|
||||||
|
sources = sorted(
|
||||||
|
await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)
|
||||||
|
)
|
||||||
|
except DocumentError:
|
||||||
|
ui.label("Document not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Job not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="sources.list")
|
||||||
|
return
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
if document_name is not None:
|
||||||
|
header_title = f"Sources: {document_name}"
|
||||||
|
elif job_label is not None:
|
||||||
|
header_title = f"Sources for Job {job_label}"
|
||||||
|
else:
|
||||||
|
header_title = "Archival Source Media"
|
||||||
|
|
||||||
|
page_header(header_title)
|
||||||
|
|
||||||
|
if back_path is not None:
|
||||||
|
back_label = "Back to Document" if document_id is not None else "Back to Job"
|
||||||
|
ui.button(
|
||||||
|
back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
|
||||||
|
# Format source records into read-model rows for the table renderer
|
||||||
|
rows = [
|
||||||
|
SourceTableRow(
|
||||||
|
id=source.id,
|
||||||
|
page_number=source.page_number,
|
||||||
|
upload_name=source.upload_name,
|
||||||
|
filename=source.filename,
|
||||||
|
document_id=source.document_id,
|
||||||
|
)
|
||||||
|
for source in sources
|
||||||
|
]
|
||||||
|
render_sources_table(rows)
|
||||||
|
|
||||||
|
@ui.page("/sources/{source_id}")
|
||||||
|
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
|
||||||
|
sources_service = TranscriptionService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/sources")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_source_id = UUID(source_id)
|
||||||
|
except ValueError:
|
||||||
|
ui.label("Invalid source id").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = await sources_service.read_source_detail(source_id=parsed_source_id)
|
||||||
|
except TranscriptionNotFoundError:
|
||||||
|
ui.label("Source not found").classes("text-h6 text-red-800 p-4")
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Load failed", operation="sources.read")
|
||||||
|
return
|
||||||
|
|
||||||
|
back_path = _back_path_from_query(request.query_params)
|
||||||
|
|
||||||
|
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
|
||||||
|
with section_header_row():
|
||||||
|
page_header(
|
||||||
|
f"Source Page {source.page_number}: {source.upload_name}", subtitle=f"Source ID: {source.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if back_path is not None:
|
||||||
|
back_label = (
|
||||||
|
"Back to Document"
|
||||||
|
if "document_id" in request.query_params
|
||||||
|
else "Back to Job"
|
||||||
|
if "job_id" in request.query_params
|
||||||
|
else "Back to Sources"
|
||||||
|
)
|
||||||
|
ui.button(
|
||||||
|
back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back"
|
||||||
|
).classes("ui-btn-primary text-xs")
|
||||||
|
else:
|
||||||
|
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back").props(
|
||||||
|
"flat text-xs"
|
||||||
|
)
|
||||||
|
|
||||||
|
with ui.grid().classes("w-full grid-cols-12 gap-4"):
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-7 gap-4"):
|
||||||
|
with archival_card(title="Source Inspection Viewer", extra_classes="p-2"):
|
||||||
|
render_document_panzoom(source=source)
|
||||||
|
|
||||||
|
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"):
|
||||||
|
with archival_card(title="Source Metadata"):
|
||||||
|
metadata_row("Page Number:", str(source.page_number))
|
||||||
|
metadata_row("Upload Name:", source.upload_name)
|
||||||
|
metadata_row("Stored Filename:", source.filename)
|
||||||
|
metadata_row("Document ID:", str(source.document_id))
|
||||||
|
metadata_row("Date Uploaded:", source.date_uploaded.isoformat())
|
||||||
|
metadata_row(
|
||||||
|
"Date Revised:",
|
||||||
|
source.date_revised.isoformat() if source.date_revised else "Not revised",
|
||||||
|
)
|
||||||
|
|
||||||
|
with archival_card(title="Automated Raw Transcription"):
|
||||||
|
ui.textarea(value=_source_transcription_text(source) or "").props(
|
||||||
|
"outlined autogrow readonly bg-white"
|
||||||
|
).classes("w-full text-xs font-mono")
|
||||||
|
|
||||||
|
with archival_card(title="Curated Human Transcription"):
|
||||||
|
revision_input = (
|
||||||
|
ui.textarea(value=source.revised_text or "")
|
||||||
|
.props("outlined autogrow bg-white")
|
||||||
|
.classes("w-full text-xs")
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_revision() -> None:
|
||||||
|
candidate = (revision_input.value or "").strip()
|
||||||
|
if not candidate:
|
||||||
|
ui.notify("Revision text is required.", type="warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Save failed", operation="sources.save_revision")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify("Revision saved", type="positive")
|
||||||
|
ui.navigate.to(request.url.path + _back_query(request.query_params))
|
||||||
|
|
||||||
|
with ui.row().classes("w-full items-center gap-2 mt-2"):
|
||||||
|
ui.button("Save Revision", on_click=save_revision, icon="save").classes(
|
||||||
|
"ui-btn-primary text-xs"
|
||||||
|
)
|
||||||
|
|
||||||
|
@ui.page("/documents/{document_id}/sources")
|
||||||
|
async def document_sources_page(document_id: str) -> RedirectResponse:
|
||||||
|
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
|
||||||
|
|
||||||
|
@ui.page("/jobs/{job_id}/sources")
|
||||||
|
async def job_sources_page(job_id: str) -> RedirectResponse:
|
||||||
|
return RedirectResponse(url=f"/ui/sources?job_id={job_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_uuid(value: str | None) -> UUID | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return UUID(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str:
|
||||||
|
params: dict[str, str] = {}
|
||||||
|
if document_id is not None:
|
||||||
|
params["document_id"] = str(document_id)
|
||||||
|
if job_id is not None:
|
||||||
|
params["job_id"] = str(job_id)
|
||||||
|
return f"?{urlencode(params)}" if params else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _source_detail_path(*, source_id: UUID, document_id: UUID | None, job_id: UUID | None) -> str:
|
||||||
|
return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _back_query(query_params) -> str:
|
||||||
|
params = {}
|
||||||
|
for key in ("document_id", "job_id"):
|
||||||
|
if query_params.get(key):
|
||||||
|
params[key] = query_params.get(key)
|
||||||
|
return f"?{urlencode(params)}" if params else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _back_path_from_query(query_params) -> str | None:
|
||||||
|
document_id = query_params.get("document_id")
|
||||||
|
if document_id:
|
||||||
|
return f"/documents/{document_id}"
|
||||||
|
job_id = query_params.get("job_id")
|
||||||
|
if job_id:
|
||||||
|
return f"/jobs/{job_id}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _source_transcription_text(source: Source) -> str | None:
|
||||||
|
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
|
||||||
|
if job_source.raw_transcription:
|
||||||
|
return job_source.raw_transcription
|
||||||
|
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
|
||||||
|
if job_source.error_detail:
|
||||||
|
return job_source.error_detail
|
||||||
|
return None
|
||||||
@@ -3,29 +3,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from starlette import status
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.db import session_scope
|
|
||||||
from transcription.services.store import create_upload_job
|
|
||||||
from transcription.ui.components.app_shell import render_navigation_header
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.upload import render_upload_widget
|
|
||||||
from transcription.worker import resolve_worker_notifier
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None:
|
||||||
"""Register the upload page route."""
|
"""Register the upload page route."""
|
||||||
|
|
||||||
@ui.page("/upload", title="Upload Document")
|
@ui.page("/upload", title="Upload Document")
|
||||||
def upload_page(request: Request) -> None:
|
def upload_page(request: Request) -> RedirectResponse:
|
||||||
|
_ = request
|
||||||
render_navigation_header(current_path="/upload")
|
render_navigation_header(current_path="/upload")
|
||||||
|
return RedirectResponse(url="/ui/jobs/new", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
async def submit_upload(filename: str, file_bytes: bytes):
|
|
||||||
async with session_scope() as session:
|
|
||||||
return await create_upload_job(
|
|
||||||
filename=filename,
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
notify_worker = resolve_worker_notifier(request.app.state)
|
|
||||||
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: var(--theme-inverse-text);
|
color: var(--theme-inverse-text);
|
||||||
background: var(--theme-primary);
|
background: transparent; /* Removes the dark box */
|
||||||
font-family: "Trebuchet MS", sans-serif;
|
font-family: "Trebuchet MS", sans-serif;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
.page-content {
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 320px);
|
|
||||||
align-items: start;
|
|
||||||
border: 1px solid var(--theme-border);
|
|
||||||
color: var(--theme-text);
|
|
||||||
background: var(--theme-surface-raised);
|
|
||||||
box-shadow: var(--theme-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__editor {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__heading {
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__kicker {
|
|
||||||
color: var(--theme-text-muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__title,
|
|
||||||
.page-content__sidebar-title {
|
|
||||||
color: var(--theme-text);
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__title {
|
|
||||||
font-size: 1.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__badge {
|
|
||||||
color: var(--theme-text);
|
|
||||||
background: var(--theme-surface-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__workspace {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(280px, 0.85fr) minmax(320px, 1.15fr);
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-placeholder {
|
|
||||||
min-height: 440px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: auto 1fr;
|
|
||||||
border: 1px solid var(--theme-viewer-border);
|
|
||||||
background: var(--theme-viewer);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-placeholder__toolbar {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 48px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
color: var(--theme-inverse-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-placeholder__body {
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--theme-viewer-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder {
|
|
||||||
min-width: 0;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder__section {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder__title {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transcription-placeholder__text {
|
|
||||||
min-height: 160px;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid var(--theme-border);
|
|
||||||
background: var(--theme-surface);
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar {
|
|
||||||
min-width: 0;
|
|
||||||
border-left: 1px solid var(--theme-border);
|
|
||||||
background: var(--theme-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar-section {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--theme-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar-title {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
|
||||||
.page-content {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__sidebar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
border-top: 1px solid var(--theme-border);
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.page-content__workspace,
|
|
||||||
.page-content__sidebar {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-content__editor,
|
|
||||||
.page-content__sidebar-section {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
.page-header {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 2rem;
|
|
||||||
padding: 2rem 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__identity {
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__eyebrow {
|
|
||||||
color: var(--theme-text-muted);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__title {
|
|
||||||
color: var(--theme-text);
|
|
||||||
font-family: Georgia, serif;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__metadata {
|
|
||||||
gap: 0.75rem;
|
|
||||||
color: var(--theme-text-muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__metadata > * + *::before {
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
content: "·";
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.page-header {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__title {
|
|
||||||
font-size: 1.65rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header__actions,
|
|
||||||
.page-header__actions .q-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -103,3 +103,154 @@ input:focus-visible,
|
|||||||
outline: 3px solid var(--theme-focus);
|
outline: 3px solid var(--theme-focus);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Semantic utility classes for incremental migration away from inline hex styles. */
|
||||||
|
.ui-text-primary {
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-text-muted {
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-text-inverse {
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-page {
|
||||||
|
background: var(--theme-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-surface {
|
||||||
|
background: var(--theme-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-surface-raised {
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-surface-muted {
|
||||||
|
background: var(--theme-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-viewer {
|
||||||
|
background: var(--theme-viewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-viewer-overlay {
|
||||||
|
background: color-mix(in srgb, var(--theme-viewer) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-bg-viewer-overlay-soft {
|
||||||
|
background: color-mix(in srgb, var(--theme-viewer) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-border-subtle {
|
||||||
|
border-color: var(--theme-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-border-viewer {
|
||||||
|
border-color: var(--theme-viewer-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-header-divider {
|
||||||
|
border-bottom: 1px solid var(--theme-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-card-surface {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-row-surface {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-note-box {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-primary {
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
background: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-primary:hover {
|
||||||
|
background: var(--theme-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-secondary {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-btn-secondary:hover {
|
||||||
|
color: var(--theme-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-link-primary {
|
||||||
|
color: var(--theme-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-link-primary:hover {
|
||||||
|
color: var(--theme-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-text-accent {
|
||||||
|
color: var(--theme-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-page-title {
|
||||||
|
color: var(--theme-text);
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Palatino, serif;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-page-subtitle {
|
||||||
|
color: var(--theme-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table {
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
color: var(--theme-text);
|
||||||
|
background: var(--theme-surface-raised);
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table .q-table tbody tr:hover {
|
||||||
|
background: var(--theme-surface) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table .q-table td {
|
||||||
|
border-bottom: 1px solid var(--theme-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table-header {
|
||||||
|
color: var(--theme-inverse-text);
|
||||||
|
background: var(--theme-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-table-body {
|
||||||
|
color: var(--theme-text);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
+10
-5
@@ -15,7 +15,6 @@ from transcription.config import Settings
|
|||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
from transcription.db.engine import get_database_url
|
from transcription.db.engine import get_database_url
|
||||||
from transcription.db.engine import get_engine
|
from transcription.db.engine import get_engine
|
||||||
from transcription.db.operations import create_all
|
|
||||||
from transcription.db.session import dispose_session_factory
|
from transcription.db.session import dispose_session_factory
|
||||||
from transcription.db.session import get_session_factory
|
from transcription.db.session import get_session_factory
|
||||||
from transcription.db.session import session_scope
|
from transcription.db.session import session_scope
|
||||||
@@ -31,6 +30,7 @@ def session():
|
|||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
)
|
)
|
||||||
|
SQLModel.metadata.drop_all(engine)
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
with Session(engine) as sync_session:
|
with Session(engine) as sync_session:
|
||||||
yield sync_session
|
yield sync_session
|
||||||
@@ -41,8 +41,15 @@ async def default_settings():
|
|||||||
"""Provide default settings for tests."""
|
"""Provide default settings for tests."""
|
||||||
settings = get_settings(database_url="sqlite:///:memory:")
|
settings = get_settings(database_url="sqlite:///:memory:")
|
||||||
db_url = get_database_url(settings)
|
db_url = get_database_url(settings)
|
||||||
await create_all(engine=get_engine(database_url=db_url))
|
engine = get_engine(database_url=db_url)
|
||||||
return settings
|
|
||||||
|
# Cached in-memory engines persist across tests; reset schema per test for isolation.
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(SQLModel.metadata.drop_all)
|
||||||
|
await connection.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
yield settings
|
||||||
|
await dispose_session_factory(db_url)
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
@@ -52,8 +59,6 @@ async def async_session(default_settings: Settings):
|
|||||||
async with session_scope(database_url=db_url) as async_session:
|
async with session_scope(database_url=db_url) as async_session:
|
||||||
yield async_session
|
yield async_session
|
||||||
|
|
||||||
await dispose_session_factory(db_url)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def default_session_factory(default_settings: Settings):
|
def default_session_factory(default_settings: Settings):
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from transcription.config import Settings
|
|||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.providers.base import TranscriptionResult
|
from transcription.providers.base import TranscriptionResult
|
||||||
|
from transcription.services import ServiceBundle
|
||||||
from transcription.services.store import create_upload_job
|
from transcription.services.store import create_upload_job
|
||||||
from transcription.worker import process_next_queued_job
|
from transcription.services.workflows import advance_job
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -58,14 +59,18 @@ class TestPipelineSuccessFlow:
|
|||||||
_fake_transcribe_document_image,
|
_fake_transcribe_document_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
processed = await process_next_queued_job(session=async_session)
|
services = ServiceBundle()
|
||||||
|
queued_job = await services.jobs.read_next_queued_job(session=async_session)
|
||||||
|
processed = queued_job is not None
|
||||||
|
if queued_job is not None:
|
||||||
|
await advance_job(job=queued_job, services=services, session=async_session)
|
||||||
job = await async_session.get(Job, upload_result.job_id)
|
job = await async_session.get(Job, upload_result.job_id)
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
assert job.status == JobStatus.TRANSCRIBED
|
assert job.status == JobStatus.TRANSCRIBED
|
||||||
assert job.text == "Pipeline transcript"
|
assert any(job_source.raw_transcription == "Pipeline transcript" for job_source in job.job_sources)
|
||||||
assert job.error_detail is None
|
assert all(job_source.error_detail is None for job_source in job.job_sources)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -98,14 +103,19 @@ class TestPipelineFailureFlow:
|
|||||||
_fake_transcribe_document_image,
|
_fake_transcribe_document_image,
|
||||||
)
|
)
|
||||||
|
|
||||||
processed = await process_next_queued_job(session=async_session)
|
services = ServiceBundle()
|
||||||
|
queued_job = await services.jobs.read_next_queued_job(session=async_session)
|
||||||
|
processed = queued_job is not None
|
||||||
|
if queued_job is not None:
|
||||||
|
await advance_job(job=queued_job, services=services, session=async_session)
|
||||||
job = await async_session.get(Job, upload_result.job_id)
|
job = await async_session.get(Job, upload_result.job_id)
|
||||||
|
|
||||||
assert processed is True
|
assert processed is True
|
||||||
assert job is not None
|
assert job is not None
|
||||||
assert job.status == JobStatus.FAILED
|
assert job.status == JobStatus.FAILED
|
||||||
assert job.text is None
|
assert all(job_source.raw_transcription is None for job_source in job.job_sources)
|
||||||
assert job.error_detail is not None
|
assert any(job_source.error_detail is not None for job_source in job.job_sources)
|
||||||
assert "pipeline provider failure" in job.error_detail
|
error_detail = next(job_source.error_detail for job_source in job.job_sources if job_source.error_detail is not None)
|
||||||
assert "[internal_unexpected_error]" in job.error_detail
|
assert "pipeline provider failure" in error_detail
|
||||||
assert "error_id=" in job.error_detail
|
assert "[internal_unexpected_error]" in error_detail
|
||||||
|
assert "error_id=" in error_detail
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.db.models import Person
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentError
|
||||||
|
from transcription.services.documents import PersonDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_document_detail_allows_missing_sources(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
created = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="detail-doc",
|
||||||
|
document_type="letter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = await service.read_document_detail(created.id)
|
||||||
|
|
||||||
|
assert detail.id == created.id
|
||||||
|
assert detail.sources == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_document_refreshes_updated_timestamp(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
created = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="timestamp-doc",
|
||||||
|
document_type="letter",
|
||||||
|
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
original_updated_at = created.updated_at
|
||||||
|
created.notes = "updated"
|
||||||
|
|
||||||
|
updated = await service.update_document(created)
|
||||||
|
|
||||||
|
assert updated.notes == "updated"
|
||||||
|
assert updated.updated_at >= original_updated_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_document_blocks_when_dependencies_exist(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="blocked-delete",
|
||||||
|
document_type="record",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service._session_scope() as session:
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="001_page.png",
|
||||||
|
filename="001_page.png",
|
||||||
|
file_path="uploads/001_page.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(Job(document_id=document.id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(DocumentDeleteBlockedError):
|
||||||
|
await service.delete_document(document)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_document_succeeds_when_unlinked(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="free-delete",
|
||||||
|
document_type="memo",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.delete_document(document)
|
||||||
|
|
||||||
|
with pytest.raises(DocumentError):
|
||||||
|
await service.read_document_detail(document.id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_person_detail_loads_document_links(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="linked-doc",
|
||||||
|
document_type="letter",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
person = await service.create_person(Person(full_name="Linked Person"))
|
||||||
|
await service.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = await service.read_person_detail(person.id)
|
||||||
|
|
||||||
|
assert detail.id == person.id
|
||||||
|
assert len(detail.document_people) == 1
|
||||||
|
assert detail.document_people[0].document is not None
|
||||||
|
assert detail.document_people[0].document.name == "linked-doc"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_person_refreshes_updated_timestamp(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
created = await service.create_person(
|
||||||
|
Person(
|
||||||
|
full_name="timestamp-person",
|
||||||
|
updated_at=datetime(2000, 1, 1, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
original_updated_at = created.updated_at
|
||||||
|
created.display_name = "updated"
|
||||||
|
|
||||||
|
updated = await service.update_person(created)
|
||||||
|
|
||||||
|
assert updated.display_name == "updated"
|
||||||
|
assert updated.updated_at >= original_updated_at
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_person_blocks_when_linked_documents_exist(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await service.create_document(
|
||||||
|
Document(
|
||||||
|
id=uuid4(),
|
||||||
|
name="block-person-delete-doc",
|
||||||
|
document_type="record",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
person = await service.create_person(Person(full_name="Blocked Person"))
|
||||||
|
await service.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(PersonDeleteBlockedError):
|
||||||
|
await service.delete_person(person)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_person_succeeds_when_unlinked(default_session_factory):
|
||||||
|
service = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
person = await service.create_person(Person(full_name="Free Person"))
|
||||||
|
|
||||||
|
await service.delete_person(person)
|
||||||
|
|
||||||
|
with pytest.raises(DocumentError):
|
||||||
|
await service.read_person_detail(person.id)
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobDeleteBlockedError
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
|
||||||
|
|
||||||
@@ -66,14 +72,21 @@ class TestJobService:
|
|||||||
await job_service.create_job(job=job)
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
async with job_service._session_scope() as session:
|
async with job_service._session_scope() as session:
|
||||||
session.add(
|
source = Source(
|
||||||
Source(
|
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
job_id=job.id,
|
page_number=1,
|
||||||
upload_name="letter.jpg",
|
upload_name="letter.jpg",
|
||||||
filename="stored-letter.jpg",
|
filename="stored-letter.jpg",
|
||||||
file_path="/uploads/stored-letter.jpg",
|
file_path="/uploads/stored-letter.jpg",
|
||||||
)
|
)
|
||||||
|
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.commit()
|
||||||
|
|
||||||
@@ -90,11 +103,124 @@ class TestJobService:
|
|||||||
document = Document(id=uuid4(), name="ordered-doc")
|
document = Document(id=uuid4(), name="ordered-doc")
|
||||||
await document_service.create_document(document=document)
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
first = Job(document_id=document.id, status=JobStatus.QUEUED)
|
created_at = datetime.now(UTC)
|
||||||
second = Job(document_id=document.id, status=JobStatus.QUEUED)
|
first = Job(document_id=document.id, status=JobStatus.QUEUED, date_created=created_at)
|
||||||
|
second = Job(
|
||||||
|
document_id=document.id,
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
date_created=created_at + timedelta(microseconds=1),
|
||||||
|
)
|
||||||
await job_service.create_job(job=first)
|
await job_service.create_job(job=first)
|
||||||
await job_service.create_job(job=second)
|
await job_service.create_job(job=second)
|
||||||
|
|
||||||
next_job = await job_service.read_next_queued_job()
|
next_job = await job_service.read_next_queued_job()
|
||||||
assert next_job is not None
|
assert next_job is not None
|
||||||
assert next_job.id == first.id
|
assert next_job.id == first.id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_job_persists_provider_model_prompt(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="provider-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(
|
||||||
|
document_id=document.id,
|
||||||
|
provider="openrouter",
|
||||||
|
model="google/gemini-2.5-flash",
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
fetched = await job_service.read_job(job_id=job.id)
|
||||||
|
assert fetched.provider == "openrouter"
|
||||||
|
assert fetched.model == "google/gemini-2.5-flash"
|
||||||
|
assert fetched.prompt_name == "transcribe_document.md"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_job_resolves_filename_from_linked_source(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="filename-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
async with job_service._session_scope() as session:
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="page_001.png",
|
||||||
|
filename="stored_page_001.png",
|
||||||
|
file_path="/uploads/stored_page_001.png",
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
fetched = await job_service.read_job(job_id=job.id)
|
||||||
|
assert fetched.filename == "stored_page_001.png"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_job_with_guardrails_blocks_processing_jobs(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="processing-delete-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.PROCESSING)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
with pytest.raises(JobDeleteBlockedError):
|
||||||
|
await job_service.delete_job_with_guardrails(job_id=job.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_job_with_guardrails_removes_jobsource_links(
|
||||||
|
self,
|
||||||
|
job_service: JobService,
|
||||||
|
document_service: DocumentService,
|
||||||
|
):
|
||||||
|
document = Document(id=uuid4(), name="delete-job-doc")
|
||||||
|
await document_service.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
await job_service.create_job(job=job)
|
||||||
|
|
||||||
|
async with job_service._session_scope() as session:
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="delete-job-source.jpg",
|
||||||
|
filename="stored-delete-job-source.jpg",
|
||||||
|
file_path="/uploads/stored-delete-job-source.jpg",
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await job_service.delete_job_with_guardrails(job_id=job.id)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await job_service.read_job(job_id=job.id)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.store import UploadError
|
||||||
|
from transcription.services.store import create_job_for_document
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_job_for_document_requires_at_least_one_upload(async_session, tmp_path):
|
||||||
|
document = Document(id=uuid4(), name="needs-upload")
|
||||||
|
async_session.add(document)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(UploadError):
|
||||||
|
await create_job_for_document(
|
||||||
|
document_id=document.id,
|
||||||
|
uploads=[],
|
||||||
|
session=async_session,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_job_for_document_sorts_uploads_and_creates_links(async_session, tmp_path):
|
||||||
|
document = Document(id=uuid4(), name="ordered-upload-doc")
|
||||||
|
async_session.add(document)
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
||||||
|
|
||||||
|
result = await create_job_for_document(
|
||||||
|
document_id=document.id,
|
||||||
|
uploads=[
|
||||||
|
("folder/b_page.pdf", b"b"),
|
||||||
|
("folder/A_page.pdf", b"a"),
|
||||||
|
],
|
||||||
|
provider="openrouter",
|
||||||
|
model="test-model",
|
||||||
|
prompt_name="transcribe_document.md",
|
||||||
|
session=async_session,
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
created_job = await async_session.get(Job, result.job_id)
|
||||||
|
assert created_job is not None
|
||||||
|
assert created_job.provider == "openrouter"
|
||||||
|
assert created_job.model == "test-model"
|
||||||
|
assert created_job.prompt_name == "transcribe_document.md"
|
||||||
|
|
||||||
|
sources = (
|
||||||
|
await async_session.exec(
|
||||||
|
select(Source)
|
||||||
|
.where(Source.document_id == document.id)
|
||||||
|
.order_by(Source.page_number) # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
assert [source.upload_name for source in sources] == ["A_page.pdf", "b_page.pdf"]
|
||||||
|
assert all(source.filename.endswith(".pdf") for source in sources)
|
||||||
|
assert all("A_page" not in source.filename and "b_page" not in source.filename for source in sources)
|
||||||
|
|
||||||
|
job_sources = (await async_session.exec(select(JobSource).where(JobSource.job_id == result.job_id))).all()
|
||||||
|
assert len(job_sources) == 2
|
||||||
|
assert set(result.source_ids) == {job_source.source_id for job_source in job_sources}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for revision behavior in TranscriptionService."""
|
"""Tests for source revision behavior in TranscriptionService."""
|
||||||
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -6,16 +6,20 @@ import pytest
|
|||||||
|
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.services.documents import DocumentService
|
||||||
from transcription.services.jobs import JobService
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.transcription import SourceDeleteBlockedError
|
||||||
|
from transcription.services.transcription import TranscriptionNotFoundError
|
||||||
from transcription.services.transcription import TranscriptionService
|
from transcription.services.transcription import TranscriptionService
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
class TestTranscriptionServiceRevisionUpsert:
|
class TestTranscriptionServiceRevisionUpsert:
|
||||||
"""Verify optional single-revision create/update semantics."""
|
"""Verify page-level source revision semantics."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upsert_revision_creates_new_revision(self, default_session_factory):
|
async def test_upsert_revision_creates_new_revision(self, default_session_factory):
|
||||||
@@ -26,29 +30,31 @@ class TestTranscriptionServiceRevisionUpsert:
|
|||||||
document = Document(id=uuid4(), name="revision-create")
|
document = Document(id=uuid4(), name="revision-create")
|
||||||
await documents.create_document(document=document)
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text")
|
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
|
||||||
await jobs.create_job(job=job)
|
await jobs.create_job(job=job)
|
||||||
|
|
||||||
source = Source(
|
source = Source(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
job_id=job.id,
|
page_number=1,
|
||||||
upload_name="source.jpg",
|
upload_name="source.jpg",
|
||||||
filename="source.jpg",
|
filename="source.jpg",
|
||||||
file_path="uploads/source.jpg",
|
file_path="uploads/source.jpg",
|
||||||
)
|
)
|
||||||
async with transcriptions._session_scope() as session:
|
async with transcriptions._session_scope() as session:
|
||||||
session.add(source)
|
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.commit()
|
||||||
await session.refresh(source)
|
await session.refresh(source)
|
||||||
|
|
||||||
revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision")
|
revision = await transcriptions.upsert_revision_for_source(source_id=source.id, text="User revision")
|
||||||
fetched = await transcriptions.read_revision_by_source(source.id)
|
fetched = await transcriptions.read_revision_by_source(source.id)
|
||||||
|
|
||||||
assert revision.source_id == source.id
|
assert revision.id == source.id
|
||||||
assert revision.text == "User revision"
|
assert revision.revised_text == "User revision"
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.id == revision.id
|
assert fetched.id == source.id
|
||||||
assert fetched.text == "User revision"
|
assert fetched.revised_text == "User revision"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory):
|
async def test_upsert_revision_updates_existing_single_revision(self, default_session_factory):
|
||||||
@@ -59,18 +65,20 @@ class TestTranscriptionServiceRevisionUpsert:
|
|||||||
document = Document(id=uuid4(), name="revision-update")
|
document = Document(id=uuid4(), name="revision-update")
|
||||||
await documents.create_document(document=document)
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED, text="Original text")
|
job = Job(document_id=document.id, status=JobStatus.TRANSCRIBED)
|
||||||
await jobs.create_job(job=job)
|
await jobs.create_job(job=job)
|
||||||
|
|
||||||
source = Source(
|
source = Source(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
job_id=job.id,
|
page_number=1,
|
||||||
upload_name="source.jpg",
|
upload_name="source.jpg",
|
||||||
filename="source.jpg",
|
filename="source.jpg",
|
||||||
file_path="uploads/source.jpg",
|
file_path="uploads/source.jpg",
|
||||||
)
|
)
|
||||||
async with transcriptions._session_scope() as session:
|
async with transcriptions._session_scope() as session:
|
||||||
session.add(source)
|
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.commit()
|
||||||
await session.refresh(source)
|
await session.refresh(source)
|
||||||
|
|
||||||
@@ -79,7 +87,70 @@ class TestTranscriptionServiceRevisionUpsert:
|
|||||||
revisions = await transcriptions.list_revisions_by_job(job.id)
|
revisions = await transcriptions.list_revisions_by_job(job.id)
|
||||||
|
|
||||||
assert first.id == second.id
|
assert first.id == second.id
|
||||||
assert second.text == "Revision v2"
|
assert second.revised_text == "Revision v2"
|
||||||
assert len(revisions) == 1
|
assert len(revisions) == 1
|
||||||
assert revisions[0].id == first.id
|
assert revisions[0].id == first.id
|
||||||
assert revisions[0].text == "Revision v2"
|
assert revisions[0].revised_text == "Revision v2"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_source_from_job_context_removes_source_and_single_link(self, default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = Document(id=uuid4(), name="delete-source-success")
|
||||||
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
await jobs.create_job(job=job)
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="delete.jpg",
|
||||||
|
filename="delete.jpg",
|
||||||
|
file_path="uploads/delete.jpg",
|
||||||
|
)
|
||||||
|
async with transcriptions._session_scope() as session:
|
||||||
|
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(source)
|
||||||
|
|
||||||
|
await transcriptions.delete_source_from_job_context(job_id=job.id, source_id=source.id)
|
||||||
|
|
||||||
|
with pytest.raises(TranscriptionNotFoundError):
|
||||||
|
await transcriptions.read_source(source.id)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_source_from_job_context_blocks_when_other_job_links_exist(self, default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = Document(id=uuid4(), name="delete-source-blocked")
|
||||||
|
await documents.create_document(document=document)
|
||||||
|
|
||||||
|
job_one = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
job_two = Job(document_id=document.id, status=JobStatus.QUEUED)
|
||||||
|
await jobs.create_job(job=job_one)
|
||||||
|
await jobs.create_job(job=job_two)
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="shared.jpg",
|
||||||
|
filename="shared.jpg",
|
||||||
|
file_path="uploads/shared.jpg",
|
||||||
|
)
|
||||||
|
async with transcriptions._session_scope() as session:
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
session.add(JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING))
|
||||||
|
session.add(JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING))
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(source)
|
||||||
|
|
||||||
|
with pytest.raises(SourceDeleteBlockedError):
|
||||||
|
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
|
from transcription.db.models import Person
|
||||||
|
from transcription.db.models import Source
|
||||||
|
from transcription.services.documents import DocumentDeleteBlockedError
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobService
|
||||||
|
from transcription.services.transcription import SourceDeleteBlockedError
|
||||||
|
from transcription.services.transcription import TranscriptionService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_document_service_handles_person_and_document_person_crud(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="person-doc"))
|
||||||
|
person = await documents.create_person(Person(full_name="Ada Lovelace"))
|
||||||
|
|
||||||
|
link = await documents.create_document_person(
|
||||||
|
DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = await documents.read_document_person(link.id)
|
||||||
|
assert fetched.id == link.id
|
||||||
|
assert fetched.role == DocumentPersonRole.AUTHOR
|
||||||
|
|
||||||
|
updated_link = await documents.update_document_person(
|
||||||
|
DocumentPerson(id=link.id, document_id=document.id, person_id=person.id, role=DocumentPersonRole.RECIPIENT)
|
||||||
|
)
|
||||||
|
assert updated_link.role == DocumentPersonRole.RECIPIENT
|
||||||
|
|
||||||
|
listed = await documents.list_document_people(document_id=document.id)
|
||||||
|
assert len(listed) == 1
|
||||||
|
|
||||||
|
people = await documents.list_people()
|
||||||
|
assert len(people) == 1
|
||||||
|
|
||||||
|
await documents.delete_document_person(updated_link)
|
||||||
|
assert len(await documents.list_document_people(document_id=document.id)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcription_service_manages_source_crud(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="source-doc"))
|
||||||
|
source = await transcriptions.create_source(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="page-1.jpg",
|
||||||
|
filename="page-1.jpg",
|
||||||
|
file_path="uploads/page-1.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = await transcriptions.read_source(source.id)
|
||||||
|
assert fetched.id == source.id
|
||||||
|
|
||||||
|
source.page_number = 2
|
||||||
|
updated = await transcriptions.update_source(source)
|
||||||
|
assert updated.page_number == 2
|
||||||
|
|
||||||
|
listed = await transcriptions.list_sources(document_id=document.id)
|
||||||
|
assert len(listed) == 1
|
||||||
|
|
||||||
|
filtered = await transcriptions.query_sources(document_id=document.id, page_number=2)
|
||||||
|
assert len(filtered) == 1
|
||||||
|
|
||||||
|
await transcriptions.delete_source(updated)
|
||||||
|
assert len(await transcriptions.list_sources(document_id=document.id)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transcription_service_job_source_crud_uses_caller_session(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
async with transcriptions._session_scope() as session:
|
||||||
|
document = Document(id=uuid4(), name="job-source-doc")
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
job = Job(document_id=document.id)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
source = Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="job-source.jpg",
|
||||||
|
filename="job-source.jpg",
|
||||||
|
file_path="uploads/job-source.jpg",
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
job_source = await transcriptions.create_job_source(
|
||||||
|
JobSource(job_id=job.id, source_id=source.id, status=JobSourceStatus.PENDING),
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
assert job_source.status == JobSourceStatus.PENDING
|
||||||
|
|
||||||
|
job_source.status = JobSourceStatus.TRANSCRIBED
|
||||||
|
updated = await transcriptions.update_job_source(job_source, session=session)
|
||||||
|
assert updated.status == JobSourceStatus.TRANSCRIBED
|
||||||
|
|
||||||
|
fetched = await transcriptions.read_job_source(job_source.id, session=session)
|
||||||
|
assert fetched.id == job_source.id
|
||||||
|
|
||||||
|
listed = await transcriptions.list_job_sources(job_id=job.id, session=session)
|
||||||
|
assert len(listed) == 1
|
||||||
|
|
||||||
|
await transcriptions.delete_job_source(updated, session=session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert len(await transcriptions.list_job_sources(job_id=job.id)) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_document_detail_loads_linked_person_relationship(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="detail-person-doc"))
|
||||||
|
person = await documents.create_person(Person(full_name="Grace Hopper"))
|
||||||
|
await documents.create_document_person(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = await documents.read_document_detail(document.id)
|
||||||
|
|
||||||
|
assert len(detail.document_people) == 1
|
||||||
|
link = detail.document_people[0]
|
||||||
|
assert link.person is not None
|
||||||
|
assert link.person.full_name == "Grace Hopper"
|
||||||
|
assert link.role == DocumentPersonRole.AUTHOR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_document_delete_is_blocked_with_source_and_job_dependencies(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="blocked-by-deps"))
|
||||||
|
job = await jobs.create_job(Job(document_id=document.id))
|
||||||
|
source = await transcriptions.create_source(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="blocked.jpg",
|
||||||
|
filename="blocked.jpg",
|
||||||
|
file_path="uploads/blocked.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await transcriptions.create_job_source(
|
||||||
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
|
source_id=source.id,
|
||||||
|
status=JobSourceStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(DocumentDeleteBlockedError) as exc_info:
|
||||||
|
await documents.delete_document(document)
|
||||||
|
|
||||||
|
message = exc_info.value.message
|
||||||
|
assert "Sources" in message
|
||||||
|
assert "Jobs" in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_source_delete_blocks_when_linked_to_multiple_jobs(default_session_factory):
|
||||||
|
documents = DocumentService(session_factory=default_session_factory)
|
||||||
|
jobs = JobService(session_factory=default_session_factory)
|
||||||
|
transcriptions = TranscriptionService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
document = await documents.create_document(Document(id=uuid4(), name="multi-job-source-doc"))
|
||||||
|
job_one = await jobs.create_job(Job(document_id=document.id))
|
||||||
|
job_two = await jobs.create_job(Job(document_id=document.id))
|
||||||
|
|
||||||
|
source = await transcriptions.create_source(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="shared-page.jpg",
|
||||||
|
filename="shared-page.jpg",
|
||||||
|
file_path="uploads/shared-page.jpg",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await transcriptions.create_job_source(
|
||||||
|
JobSource(job_id=job_one.id, source_id=source.id, status=JobSourceStatus.PENDING)
|
||||||
|
)
|
||||||
|
await transcriptions.create_job_source(
|
||||||
|
JobSource(job_id=job_two.id, source_id=source.id, status=JobSourceStatus.PENDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SourceDeleteBlockedError):
|
||||||
|
await transcriptions.delete_source_from_job_context(job_id=job_one.id, source_id=source.id)
|
||||||
@@ -71,13 +71,18 @@ class TestProviderSettings:
|
|||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
_make_settings(provider="not-a-provider")
|
_make_settings(provider="not-a-provider")
|
||||||
|
|
||||||
def test_optional_fields_default_to_none(self):
|
def test_optional_provider_header_fields_default_to_none(self):
|
||||||
"""provider_model, openrouter_http_referer, and openrouter_app_title are None when unset."""
|
"""openrouter_http_referer and openrouter_app_title are None when unset."""
|
||||||
settings = _make_settings()
|
settings = _make_settings()
|
||||||
assert settings.provider_model is None
|
|
||||||
assert settings.openrouter_http_referer is None
|
assert settings.openrouter_http_referer is None
|
||||||
assert settings.openrouter_app_title is None
|
assert settings.openrouter_app_title is None
|
||||||
|
|
||||||
|
def test_provider_model_accepts_env_default(self, monkeypatch):
|
||||||
|
"""provider_model is sourced when provided through environment configuration."""
|
||||||
|
monkeypatch.setenv("PROVIDER_MODEL", "google/gemini-2.5-flash")
|
||||||
|
settings = Settings(openrouter_api_key="test-key-abc123")
|
||||||
|
assert settings.provider_model == "google/gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
class TestPathSettings:
|
class TestPathSettings:
|
||||||
"""Verify filesystem path field types."""
|
"""Verify filesystem path field types."""
|
||||||
|
|||||||
+5
-2
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for transcription.db runtime and schema bootstrap behavior."""
|
"""Tests for the database runtime and V2 schema bootstrap behavior."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
@@ -26,9 +26,12 @@ async def test_create_all_creates_expected_tables(tmp_path):
|
|||||||
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
|
table_names = set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
|
||||||
|
|
||||||
assert "document" in table_names
|
assert "document" in table_names
|
||||||
|
assert "person" in table_names
|
||||||
|
assert "document_person" in table_names
|
||||||
assert "job" in table_names
|
assert "job" in table_names
|
||||||
assert "source" in table_names
|
assert "source" in table_names
|
||||||
assert "revision" in table_names
|
assert "job_source" in table_names
|
||||||
|
assert "revision" not in table_names
|
||||||
finally:
|
finally:
|
||||||
await dispose_database_runtime()
|
await dispose_database_runtime()
|
||||||
|
|
||||||
|
|||||||
+4
-12
@@ -8,19 +8,11 @@ from transcription import __main__ as entrypoint
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
|
def test_main_uses_cli_factory_import_string(monkeypatch):
|
||||||
"""Non-reload execution keeps the parsed settings instance in the app."""
|
"""Startup uses an importable factory so Uvicorn owns app creation."""
|
||||||
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
|
settings = SimpleNamespace(host="127.0.0.1", port=8123, log_level="debug", reload=False)
|
||||||
application = object()
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def create_app(*, settings: object) -> object:
|
|
||||||
assert settings is expected_settings
|
|
||||||
return application
|
|
||||||
|
|
||||||
expected_settings = settings
|
|
||||||
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
|
monkeypatch.setattr(entrypoint, "parse_cli_settings", lambda: settings)
|
||||||
monkeypatch.setattr(entrypoint, "create_app", create_app)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
entrypoint.uvicorn,
|
entrypoint.uvicorn,
|
||||||
"run",
|
"run",
|
||||||
@@ -30,8 +22,8 @@ def test_main_passes_constructed_app_to_uvicorn(monkeypatch):
|
|||||||
entrypoint.main()
|
entrypoint.main()
|
||||||
|
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"application": application,
|
"application": "transcription.__main__:create_cli_app",
|
||||||
"factory": False,
|
"factory": True,
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 8123,
|
"port": 8123,
|
||||||
"log_level": "debug",
|
"log_level": "debug",
|
||||||
|
|||||||
+124
-43
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for transcription.models — Document, Source, Job, Revision persistence and relationships."""
|
"""Tests for the V2 SQLModel persistence layer and relationships."""
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -6,14 +6,22 @@ import pytest
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Revision
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
|
||||||
def _make_document(**overrides) -> Document:
|
def _make_document(**overrides) -> Document:
|
||||||
defaults = {"name": "letter bundle"}
|
defaults = {
|
||||||
|
"name": "letter bundle",
|
||||||
|
"document_type": "letter",
|
||||||
|
"notes": "Family correspondence",
|
||||||
|
}
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
return Document(**defaults)
|
return Document(**defaults)
|
||||||
|
|
||||||
@@ -26,6 +34,16 @@ def _persist_document(session) -> Document:
|
|||||||
return document
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_person(session, **overrides) -> Person:
|
||||||
|
defaults = {"full_name": "Ada Lovelace"}
|
||||||
|
defaults.update(overrides)
|
||||||
|
person = Person(**defaults)
|
||||||
|
session.add(person)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
def _persist_job(session, document: Document) -> Job:
|
def _persist_job(session, document: Document) -> Job:
|
||||||
job = Job(document_id=document.id)
|
job = Job(document_id=document.id)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
@@ -34,13 +52,14 @@ def _persist_job(session, document: Document) -> Job:
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
def _persist_source(session, document: Document, job: Job, **overrides) -> Source:
|
def _persist_source(session, document: Document, *, page_number: int = 1, **overrides) -> Source:
|
||||||
defaults = {
|
defaults = {
|
||||||
"document_id": document.id,
|
"document_id": document.id,
|
||||||
"job_id": job.id,
|
"page_number": page_number,
|
||||||
"upload_name": "letter.jpg",
|
"upload_name": "letter.jpg",
|
||||||
"filename": "stored-letter.jpg",
|
"filename": "stored-letter.jpg",
|
||||||
"file_path": "/uploads/stored-letter.jpg",
|
"file_path": "/uploads/stored-letter.jpg",
|
||||||
|
"raw_transcription": "Original machine text",
|
||||||
}
|
}
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
source = Source(**defaults)
|
source = Source(**defaults)
|
||||||
@@ -50,6 +69,20 @@ def _persist_source(session, document: Document, job: Job, **overrides) -> Sourc
|
|||||||
return source
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_job_source(session, job: Job, source: Source, **overrides) -> JobSource:
|
||||||
|
defaults = {
|
||||||
|
"job_id": job.id,
|
||||||
|
"source_id": source.id,
|
||||||
|
"status": JobSourceStatus.PENDING,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
job_source = JobSource(**defaults)
|
||||||
|
session.add(job_source)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(job_source)
|
||||||
|
return job_source
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentModel:
|
class TestDocumentModel:
|
||||||
def test_can_be_persisted(self, session):
|
def test_can_be_persisted(self, session):
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
@@ -60,6 +93,8 @@ class TestDocumentModel:
|
|||||||
def test_defaults_are_populated(self, session):
|
def test_defaults_are_populated(self, session):
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
assert isinstance(document.id, UUID)
|
assert isinstance(document.id, UUID)
|
||||||
|
assert document.created_at is not None
|
||||||
|
assert document.updated_at is not None
|
||||||
|
|
||||||
|
|
||||||
class TestJobModel:
|
class TestJobModel:
|
||||||
@@ -78,7 +113,7 @@ class TestJobModel:
|
|||||||
assert job.date_created is not None
|
assert job.date_created is not None
|
||||||
assert job.date_updated is not None
|
assert job.date_updated is not None
|
||||||
|
|
||||||
def test_transitions_to_transcribed(self, session):
|
def test_transitions_to_completed(self, session):
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
job = _persist_job(session, document)
|
||||||
|
|
||||||
@@ -87,79 +122,125 @@ class TestJobModel:
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(job)
|
session.refresh(job)
|
||||||
|
|
||||||
job.status = JobStatus.TRANSCRIBED
|
job.status = JobStatus.COMPLETED
|
||||||
session.add(job)
|
session.add(job)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(job)
|
session.refresh(job)
|
||||||
|
|
||||||
assert job.status == JobStatus.TRANSCRIBED
|
assert job.status == JobStatus.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
class TestSourceModel:
|
class TestSourceModel:
|
||||||
def test_can_be_created_for_document_and_job(self, session):
|
def test_can_be_created_for_document(self, session):
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
source = _persist_source(session, document)
|
||||||
source = _persist_source(session, document, job)
|
|
||||||
|
|
||||||
fetched = session.get(Source, source.id)
|
fetched = session.get(Source, source.id)
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.document_id == document.id
|
assert fetched.document_id == document.id
|
||||||
assert fetched.job_id == job.id
|
assert fetched.page_number == 1
|
||||||
assert fetched.date_uploaded is not None
|
assert fetched.date_uploaded is not None
|
||||||
|
|
||||||
|
def test_revised_text_is_supported(self, session):
|
||||||
class TestRevisionModel:
|
|
||||||
def test_revision_persists_for_source(self, session):
|
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
source = _persist_source(session, document, revised_text="Edited output")
|
||||||
source = _persist_source(session, document, job)
|
|
||||||
|
|
||||||
revision = Revision(source_id=source.id, text="Edited revision text")
|
fetched = session.get(Source, source.id)
|
||||||
session.add(revision)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(revision)
|
|
||||||
|
|
||||||
fetched = session.get(Revision, revision.id)
|
|
||||||
assert fetched is not None
|
assert fetched is not None
|
||||||
assert fetched.text == "Edited revision text"
|
assert fetched.revised_text == "Edited output"
|
||||||
assert fetched.date_created is not None
|
|
||||||
|
|
||||||
def test_source_id_is_unique(self, session):
|
|
||||||
|
class TestPersonAndDocumentPersonModel:
|
||||||
|
def test_document_person_role_is_unique_per_document_person(self, session):
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
person = _persist_person(session)
|
||||||
source = _persist_source(session, document, job)
|
|
||||||
|
|
||||||
first = Revision(source_id=source.id, text="First")
|
first = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
|
||||||
session.add(first)
|
session.add(first)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
duplicate = Revision(source_id=source.id, text="Duplicate")
|
duplicate = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
|
||||||
session.add(duplicate)
|
session.add(duplicate)
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
class TestRelationships:
|
class TestJobSourceModel:
|
||||||
def test_document_exposes_jobs_and_sources(self, session):
|
def test_job_source_persists_json_payloads(self, session):
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
job = _persist_job(session, document)
|
||||||
_persist_source(session, document, job)
|
source = _persist_source(session, document)
|
||||||
|
job_source = _persist_job_source(
|
||||||
|
session,
|
||||||
|
job,
|
||||||
|
source,
|
||||||
|
raw_transcription="Page transcript",
|
||||||
|
ai_metadata={"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]},
|
||||||
|
raw_api_response={"provider": "test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
fetched = session.get(JobSource, job_source.id)
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched.status == JobSourceStatus.PENDING
|
||||||
|
assert fetched.ai_metadata == {"confidence": 0.91, "boxes": [{"x": 1, "y": 2}]}
|
||||||
|
assert fetched.raw_api_response == {"provider": "test"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelationships:
|
||||||
|
def test_document_exposes_jobs_sources_and_people(self, session):
|
||||||
|
document = _persist_document(session)
|
||||||
|
_persist_job(session, document)
|
||||||
|
_persist_source(session, document)
|
||||||
|
person = _persist_person(session)
|
||||||
|
|
||||||
|
link = DocumentPerson(document_id=document.id, person_id=person.id, role=DocumentPersonRole.AUTHOR)
|
||||||
|
session.add(link)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
session.refresh(document)
|
session.refresh(document)
|
||||||
assert len(document.jobs) == 1
|
assert len(document.jobs) == 1
|
||||||
assert len(document.sources) == 1
|
assert len(document.sources) == 1
|
||||||
|
assert len(document.document_people) == 1
|
||||||
|
|
||||||
def test_source_exposes_optional_single_revision(self, session):
|
def test_document_exposes_author_via_role_filtered_relation(self, session):
|
||||||
document = _persist_document(session)
|
document = _persist_document(session)
|
||||||
job = _persist_job(session, document)
|
author = _persist_person(session, full_name="Author Person")
|
||||||
source = _persist_source(session, document, job)
|
recipient = _persist_person(session, full_name="Recipient Person")
|
||||||
|
|
||||||
assert source.revision is None
|
session.add(DocumentPerson(document_id=document.id, person_id=author.id, role=DocumentPersonRole.AUTHOR))
|
||||||
|
session.add(DocumentPerson(document_id=document.id, person_id=recipient.id, role=DocumentPersonRole.RECIPIENT))
|
||||||
revision = Revision(source_id=source.id, text="Edited")
|
|
||||||
session.add(revision)
|
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
session.refresh(source)
|
session.refresh(document)
|
||||||
assert source.revision is not None
|
assert [person.full_name for person in document.authors] == ["Author Person"]
|
||||||
assert source.revision.text == "Edited"
|
assert document.author is not None
|
||||||
|
assert document.author.full_name == "Author Person"
|
||||||
|
|
||||||
|
def test_person_exposes_authored_documents_via_role_filtered_relation(self, session):
|
||||||
|
authored_document = _make_document(name="Authored Doc")
|
||||||
|
recipient_only_document = _make_document(name="Recipient Doc")
|
||||||
|
session.add(authored_document)
|
||||||
|
session.add(recipient_only_document)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(authored_document)
|
||||||
|
session.refresh(recipient_only_document)
|
||||||
|
person = _persist_person(session, full_name="Dual Role Person")
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=authored_document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=recipient_only_document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.RECIPIENT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
session.refresh(person)
|
||||||
|
assert [document.name for document in person.authored_documents] == ["Authored Doc"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from transcription.ui import register_pages
|
from transcription.ui.pages import register_pages
|
||||||
from transcription.ui.resources import read_css
|
from transcription.ui.resources import read_css
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+20
-8
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -20,9 +22,12 @@ from transcription.db import create_all
|
|||||||
from transcription.db import initialize_database_runtime
|
from transcription.db import initialize_database_runtime
|
||||||
from transcription.db import session_scope
|
from transcription.db import session_scope
|
||||||
from transcription.db.models import Document
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
from transcription.db.models import Job
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import JobSource
|
||||||
|
from transcription.db.models import JobSourceStatus
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
from transcription.db.models import Revision
|
from transcription.db.models import Person
|
||||||
from transcription.db.models import Source
|
from transcription.db.models import Source
|
||||||
|
|
||||||
RevisionSeed = str
|
RevisionSeed = str
|
||||||
@@ -55,10 +60,12 @@ def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
|||||||
|
|
||||||
async def _clear() -> None:
|
async def _clear() -> None:
|
||||||
async with session_scope() as session:
|
async with session_scope() as session:
|
||||||
await session.exec(delete(Revision))
|
await session.exec(delete(JobSource))
|
||||||
|
await session.exec(delete(DocumentPerson))
|
||||||
await session.exec(delete(Source))
|
await session.exec(delete(Source))
|
||||||
await session.exec(delete(Job))
|
await session.exec(delete(Job))
|
||||||
await session.exec(delete(Document))
|
await session.exec(delete(Document))
|
||||||
|
await session.exec(delete(Person))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
asyncio.run(_clear())
|
asyncio.run(_clear())
|
||||||
@@ -94,8 +101,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
|||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
status=status,
|
status=status,
|
||||||
retry_count=0,
|
retry_count=0,
|
||||||
text=transcription_text,
|
|
||||||
error_detail=error_detail,
|
|
||||||
provider="openrouter",
|
provider="openrouter",
|
||||||
model="google/gemini-2.5-flash",
|
model="google/gemini-2.5-flash",
|
||||||
prompt_name="transcribe_document.md",
|
prompt_name="transcribe_document.md",
|
||||||
@@ -105,7 +110,6 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
|||||||
|
|
||||||
source = Source(
|
source = Source(
|
||||||
document_id=document.id,
|
document_id=document.id,
|
||||||
job_id=job.id,
|
|
||||||
upload_name=filename,
|
upload_name=filename,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
file_path=str(stored_path),
|
file_path=str(stored_path),
|
||||||
@@ -113,14 +117,22 @@ def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
|||||||
session.add(source)
|
session.add(source)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
if revision_text is not None:
|
if transcription_text is not None or error_detail is not None:
|
||||||
session.add(
|
session.add(
|
||||||
Revision(
|
JobSource(
|
||||||
|
job_id=job.id,
|
||||||
source_id=source.id,
|
source_id=source.id,
|
||||||
text=revision_text,
|
status=JobSourceStatus.TRANSCRIBED if transcription_text is not None else JobSourceStatus.FAILED,
|
||||||
|
raw_transcription=transcription_text,
|
||||||
|
error_detail=error_detail,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if revision_text is not None:
|
||||||
|
source.revised_text = revision_text
|
||||||
|
source.date_revised = datetime.now(UTC)
|
||||||
|
session.add(source)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return job.id
|
return job.id
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"""Tests for the documents page routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import Person
|
||||||
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestDocumentsPageRendering:
|
||||||
|
"""Verify document list/detail routes render expected read states."""
|
||||||
|
|
||||||
|
def test_documents_page_renders_empty_state(self, app_client):
|
||||||
|
"""GET /ui/documents renders empty-state text when no records exist."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/documents")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Documents" in response.text
|
||||||
|
assert "Create new document" in response.text
|
||||||
|
assert "No documents in repository yet." in response.text
|
||||||
|
|
||||||
|
def test_document_create_page_renders_fields(self, app_client):
|
||||||
|
"""GET /ui/documents/new renders document-create form fields."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/documents/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Create Document" in response.text
|
||||||
|
assert "Document name is required." in response.text
|
||||||
|
assert "Document name" in response.text
|
||||||
|
assert "Document type" in response.text
|
||||||
|
assert "Author (Person)" in response.text
|
||||||
|
assert "Exact date (YYYY-MM-DD)" in response.text
|
||||||
|
assert "Approximate date" in response.text
|
||||||
|
assert "Document location" in response.text
|
||||||
|
assert "Archive identifier" in response.text
|
||||||
|
assert "Notes" in response.text
|
||||||
|
assert "Create new item" in response.text
|
||||||
|
assert "Create new person" in response.text
|
||||||
|
assert "Save document" in response.text
|
||||||
|
|
||||||
|
def test_documents_page_lists_seeded_documents(self, app_client):
|
||||||
|
"""GET /ui/documents lists seeded document cards."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
session.add(Document(name="Seeded Document", document_type="letter"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get("/ui/documents")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Seeded Document" in response.text
|
||||||
|
assert "letter" in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_renders_metadata_and_empty_related_sections(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows metadata and related empty states."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(
|
||||||
|
name="Zenna Letter",
|
||||||
|
document_type="letter",
|
||||||
|
document_date=date(1885, 7, 13),
|
||||||
|
document_date_raw="c. 1885",
|
||||||
|
location_created="Ohio",
|
||||||
|
notes="Family archive",
|
||||||
|
archive_identifier="BOX-1-FOLDER-2",
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Zenna Letter" in response.text
|
||||||
|
assert "Type: letter" in response.text
|
||||||
|
assert "Author:" in response.text
|
||||||
|
assert "Not set" in response.text
|
||||||
|
assert "Exact Date:" in response.text
|
||||||
|
assert "1885-07-13" in response.text
|
||||||
|
assert "Approx. Date:" in response.text
|
||||||
|
assert "c. 1885" in response.text
|
||||||
|
assert "Location Created:" in response.text
|
||||||
|
assert "Ohio" in response.text
|
||||||
|
assert "Archive Identifier:" in response.text
|
||||||
|
assert "BOX-1-FOLDER-2" in response.text
|
||||||
|
assert "Archival Notes:" in response.text
|
||||||
|
assert "Family archive" in response.text
|
||||||
|
assert "Created:" in response.text
|
||||||
|
assert "Updated:" in response.text
|
||||||
|
assert "No linked people yet." in response.text
|
||||||
|
assert "0 Source(s) Linked" in response.text
|
||||||
|
assert "0 Active Jobs" in response.text
|
||||||
|
assert "+ Add Source" in response.text
|
||||||
|
assert "+ Add Job" in response.text
|
||||||
|
assert "Sources" in response.text
|
||||||
|
assert "Jobs" in response.text
|
||||||
|
assert "Edit Document" in response.text
|
||||||
|
assert "Delete" in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_renders_related_people_sources_and_jobs(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows related records when present."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_related() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(name="Roster", document_type="record")
|
||||||
|
person = Person(full_name="Jane Doe")
|
||||||
|
session.add(document)
|
||||||
|
session.add(person)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="001_page.png",
|
||||||
|
filename="stored_001_page.png",
|
||||||
|
file_path="/tmp/stored_001_page.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Job(
|
||||||
|
document_id=document.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_related())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Jane Doe" in response.text
|
||||||
|
assert "author" in response.text
|
||||||
|
assert "Author:" in response.text
|
||||||
|
assert "1 Source(s) Linked" in response.text
|
||||||
|
assert "1 Active Jobs" in response.text
|
||||||
|
|
||||||
|
def test_document_jobs_page_filters_to_document_context(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
target = Document(name="Target", document_type="letter")
|
||||||
|
other = Document(name="Other", document_type="record")
|
||||||
|
session.add(target)
|
||||||
|
session.add(other)
|
||||||
|
await session.flush()
|
||||||
|
session.add(Job(document_id=target.id))
|
||||||
|
session.add(Job(document_id=other.id))
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(target)
|
||||||
|
return str(target.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed())
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/jobs")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Jobs for Target" in response.text
|
||||||
|
assert "Jobs for Other" not in response.text
|
||||||
|
|
||||||
|
def test_document_sources_page_filters_to_document_context(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
target = Document(name="Target", document_type="letter")
|
||||||
|
other = Document(name="Other", document_type="record")
|
||||||
|
session.add(target)
|
||||||
|
session.add(other)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=target.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="target_page.png",
|
||||||
|
filename="target_stored.png",
|
||||||
|
file_path="/tmp/target_stored.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=other.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="other_page.png",
|
||||||
|
filename="other_stored.png",
|
||||||
|
file_path="/tmp/other_stored.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(target)
|
||||||
|
return str(target.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed())
|
||||||
|
response = client.get(f"/ui/sources?document_id={document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sources: Target" in response.text
|
||||||
|
assert "Back to Document" in response.text
|
||||||
|
assert "target_page.png" in response.text
|
||||||
|
assert "other_page.png" not in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_rejects_invalid_id(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows validation feedback for malformed IDs."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/documents/not-a-uuid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invalid document id" in response.text
|
||||||
|
|
||||||
|
def test_document_detail_page_handles_missing_document(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id} shows not-found state for unknown IDs."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{uuid4()}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Document not found" in response.text
|
||||||
|
|
||||||
|
def test_document_edit_page_renders_expected_fields(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id}/edit renders editable fields and save controls."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(
|
||||||
|
name="Editable Document",
|
||||||
|
document_type="memo",
|
||||||
|
document_date_raw="c. 1900",
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/edit")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Edit Document Record" in response.text
|
||||||
|
assert "Document name and document type are required." in response.text
|
||||||
|
assert "Document name" in response.text
|
||||||
|
assert "Document type" in response.text
|
||||||
|
assert "Author (Person)" in response.text
|
||||||
|
assert "Exact date (YYYY-MM-DD)" in response.text
|
||||||
|
assert "Approximate date" in response.text
|
||||||
|
assert "Document location" in response.text
|
||||||
|
assert "Archive identifier" in response.text
|
||||||
|
assert "Notes" in response.text
|
||||||
|
assert "Create new item" in response.text
|
||||||
|
assert "Create new person" in response.text
|
||||||
|
assert "Save changes" in response.text
|
||||||
|
|
||||||
|
def test_document_delete_page_shows_confirmation_when_unlinked(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id}/delete renders permanent-action confirmation if unlinked."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(name="Safe Delete", document_type="letter")
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete Document" in response.text
|
||||||
|
assert "This action permanently deletes the document." in response.text
|
||||||
|
assert "Delete document permanently" in response.text
|
||||||
|
|
||||||
|
def test_document_delete_page_shows_blocked_state_when_dependencies_exist(self, app_client):
|
||||||
|
"""GET /ui/documents/{document_id}/delete explains blocked deletion with dependency categories."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_related() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(name="Blocked Delete", document_type="record")
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="001_page.png",
|
||||||
|
filename="stored_001_page.png",
|
||||||
|
file_path="/tmp/stored_001_page.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(Job(document_id=document.id))
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_related())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/documents/{document_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete is blocked because related records exist." in response.text
|
||||||
|
assert "Dependencies present: Sources, Jobs" in response.text
|
||||||
|
assert "Go to Jobs" in response.text
|
||||||
|
|
||||||
|
def test_job_create_page_preselects_document_query_param(self, app_client):
|
||||||
|
"""GET /ui/jobs/new?document_id=... includes the selected document in rendered state."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(
|
||||||
|
name="Preselected Document",
|
||||||
|
document_type="letter",
|
||||||
|
created_at=datetime.now(UTC),
|
||||||
|
updated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
return str(document.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/new?document_id={document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Preselected Document" in response.text
|
||||||
+67
-33
@@ -1,10 +1,12 @@
|
|||||||
"""Tests for the jobs page route."""
|
"""Tests for the jobs page route."""
|
||||||
|
|
||||||
from pathlib import Path
|
import asyncio
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
from transcription.db.models import JobStatus
|
from transcription.db.models import JobStatus
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +20,39 @@ class TestPageRendering:
|
|||||||
response = client.get("/ui/jobs")
|
response = client.get("/ui/jobs")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "No jobs yet." in response.text
|
assert "Create job" in response.text
|
||||||
|
assert "No active or historical processing jobs found." in response.text
|
||||||
|
|
||||||
|
def test_job_create_page_requires_existing_documents(self, app_client):
|
||||||
|
"""GET /ui/jobs/new shows guidance when no Documents exist."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/jobs/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Create Processing Job" in response.text
|
||||||
|
assert "No documents available. Create a Document before creating a Job." in response.text
|
||||||
|
assert "Create document" in response.text
|
||||||
|
|
||||||
|
def test_job_create_page_lists_available_documents(self, app_client):
|
||||||
|
"""GET /ui/jobs/new renders document choices when Documents exist."""
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_document() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
session.add(Document(name="Seeded Document"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed_document())
|
||||||
|
|
||||||
|
response = client.get("/ui/jobs/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Create Processing Job" in response.text
|
||||||
|
assert "Seeded Document" in response.text
|
||||||
|
assert "Files are processed alphabetically by original filename." in response.text
|
||||||
|
assert "No files uploaded yet." in response.text
|
||||||
|
assert "Select source files or a folder" in response.text
|
||||||
|
|
||||||
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||||
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
||||||
@@ -31,29 +65,28 @@ class TestPageRendering:
|
|||||||
assert "sample.pdf" in response.text
|
assert "sample.pdf" in response.text
|
||||||
assert "transcribed" in response.text
|
assert "transcribed" in response.text
|
||||||
|
|
||||||
def test_job_detail_page_renders_seeded_job(self, app_client, seed_job):
|
def test_job_detail_page_renders_document_links(self, app_client, seed_job):
|
||||||
"""GET /ui/jobs/{job_id} renders detail content for a real seeded job."""
|
"""GET /ui/jobs/{job_id} renders document-scoped navigation links."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "single_page_pdf.pdf"
|
|
||||||
job_id = seed_job(
|
job_id = seed_job(
|
||||||
filename="detail.pdf",
|
filename="detail.pdf",
|
||||||
status=JobStatus.TRANSCRIBED,
|
status=JobStatus.TRANSCRIBED,
|
||||||
transcription_text="original text",
|
transcription_text="original text",
|
||||||
revision_text="hello",
|
|
||||||
source_file=fixture_path,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(f"/ui/jobs/{job_id}")
|
response = client.get(f"/ui/jobs/{job_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Original Transcription" in response.text
|
assert "Job" in response.text
|
||||||
assert "detail.pdf" in response.text
|
assert "Provider:" in response.text
|
||||||
assert "Revision" in response.text
|
assert "Model:" in response.text
|
||||||
assert "Revision" in response.text
|
assert "Prompt:" in response.text
|
||||||
assert "hello" in response.text
|
assert "Retry Count:" in response.text
|
||||||
assert "original text" in response.text
|
assert "Last Updated:" in response.text
|
||||||
assert "Document preview" in response.text
|
assert "document links" in response.text.lower()
|
||||||
assert "/uploads/detail.pdf" in response.text
|
assert "Sources" in response.text
|
||||||
|
assert "Jobs" in response.text
|
||||||
|
assert "Delete job" not in response.text
|
||||||
|
|
||||||
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
||||||
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
||||||
@@ -72,36 +105,37 @@ class TestPageRendering:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Job not found" in response.text
|
assert "Job not found" in response.text
|
||||||
|
|
||||||
def test_job_detail_page_shows_revision_editor_when_none_exists(self, app_client, seed_job):
|
def test_job_detail_page_hides_delete_action(self, app_client, seed_job):
|
||||||
"""GET /ui/jobs/{job_id} renders revision editor and create action for sources with no revision."""
|
"""GET /ui/jobs/{job_id} does not expose job deletion controls in this revision."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
job_id = seed_job(
|
job_id = seed_job(
|
||||||
filename="no-revision.pdf",
|
filename="no-revision.pdf",
|
||||||
status=JobStatus.TRANSCRIBED,
|
status=JobStatus.TRANSCRIBED,
|
||||||
transcription_text="original text",
|
transcription_text="original text",
|
||||||
revision_text=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(f"/ui/jobs/{job_id}")
|
response = client.get(f"/ui/jobs/{job_id}")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Revision Editor" in response.text
|
assert "Delete job" not in response.text
|
||||||
assert "Create revision" in response.text
|
|
||||||
assert "No revision exists for this source." in response.text
|
|
||||||
|
|
||||||
def test_job_detail_page_shows_update_action_for_existing_revision(self, app_client, seed_job):
|
def test_job_delete_page_shows_confirmation_when_not_processing(self, app_client, seed_job):
|
||||||
"""GET /ui/jobs/{job_id} renders revision editor with update action when revision exists."""
|
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
job_id = seed_job(
|
job_id = seed_job(filename="delete-ready.pdf", status=JobStatus.TRANSCRIBED)
|
||||||
filename="with-revision.pdf",
|
|
||||||
status=JobStatus.TRANSCRIBED,
|
|
||||||
transcription_text="original text",
|
|
||||||
revision_text="hello",
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.get(f"/ui/jobs/{job_id}")
|
response = client.get(f"/ui/jobs/{job_id}/delete")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "Revision Editor" in response.text
|
assert "Delete job" in response.text
|
||||||
assert "Update revision" in response.text
|
assert "This action permanently deletes the job." in response.text
|
||||||
assert "hello" in response.text
|
assert "Delete job permanently" in response.text
|
||||||
|
|
||||||
|
def test_job_delete_page_shows_blocked_state_when_processing(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(filename="delete-blocked.pdf", status=JobStatus.PROCESSING)
|
||||||
|
|
||||||
|
response = client.get(f"/ui/jobs/{job_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete is blocked while the job is processing." in response.text
|
||||||
|
assert "Wait for processing to complete, then retry delete." in response.text
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ class TestPageRegistration:
|
|||||||
"""Mounted UI routes respond successfully when the full app is created."""
|
"""Mounted UI routes respond successfully when the full app is created."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
|
|
||||||
upload_response = client.get("/ui/upload")
|
upload_response = client.get("/ui/upload", follow_redirects=False)
|
||||||
|
documents_response = client.get("/ui/documents")
|
||||||
|
people_response = client.get("/ui/people")
|
||||||
|
sources_response = client.get("/ui/sources")
|
||||||
jobs_response = client.get("/ui/jobs")
|
jobs_response = client.get("/ui/jobs")
|
||||||
|
|
||||||
assert upload_response.status_code == 200
|
assert upload_response.status_code == 307
|
||||||
|
assert documents_response.status_code == 200
|
||||||
|
assert people_response.status_code == 200
|
||||||
|
assert sources_response.status_code == 200
|
||||||
assert jobs_response.status_code == 200
|
assert jobs_response.status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Tests for the people page routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import date
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import DocumentPerson
|
||||||
|
from transcription.db.models import DocumentPersonRole
|
||||||
|
from transcription.db.models import Person
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestPeoplePageRendering:
|
||||||
|
"""Verify people routes render expected CRUD read states."""
|
||||||
|
|
||||||
|
def test_people_page_renders_empty_state(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/people")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "People" in response.text
|
||||||
|
assert "Create new person" in response.text
|
||||||
|
assert "No person records found in repository." in response.text
|
||||||
|
|
||||||
|
def test_people_page_lists_seeded_people(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
session.add(Person(full_name="Ada Lovelace", display_name="Ada"))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get("/ui/people")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Ada Lovelace" in response.text
|
||||||
|
assert "Ada" in response.text
|
||||||
|
|
||||||
|
def test_person_create_page_renders_fields(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/people/new")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Create Person Record" in response.text
|
||||||
|
assert "Full name is required." in response.text
|
||||||
|
assert "Birth date (YYYY-MM-DD)" in response.text
|
||||||
|
assert "Death date (YYYY-MM-DD)" in response.text
|
||||||
|
assert "Biography" in response.text
|
||||||
|
assert "Save person" in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_renders_metadata_and_empty_links(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(
|
||||||
|
full_name="Grace Hopper",
|
||||||
|
display_name="Grace",
|
||||||
|
maiden_name="Murray",
|
||||||
|
birth_date=date(1906, 12, 9),
|
||||||
|
birth_date_raw="1906",
|
||||||
|
birth_place="New York",
|
||||||
|
death_date=date(1992, 1, 1),
|
||||||
|
death_date_raw="1992",
|
||||||
|
death_place="Arlington",
|
||||||
|
biography="Computer pioneer",
|
||||||
|
portrait_path="/images/grace.jpg",
|
||||||
|
)
|
||||||
|
session.add(person)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Grace Hopper" in response.text
|
||||||
|
assert "Full Name:" in response.text
|
||||||
|
assert "Display Name:" in response.text
|
||||||
|
assert "Maiden Name:" in response.text
|
||||||
|
assert "Birth Date:" in response.text
|
||||||
|
assert "1906-12-09" in response.text
|
||||||
|
assert "Death Date:" in response.text
|
||||||
|
assert "1992-01-01" in response.text
|
||||||
|
assert "biography" in response.text.lower()
|
||||||
|
assert "Computer pioneer" in response.text
|
||||||
|
assert "Created:" in response.text
|
||||||
|
assert "Updated:" in response.text
|
||||||
|
assert "No linked documents yet." in response.text
|
||||||
|
assert "Link this person from a Document workflow." in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_resolves_relative_portrait_path_to_uploads_mount(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(
|
||||||
|
full_name="Portrait Person",
|
||||||
|
portrait_path="portraits/person/seeded.png",
|
||||||
|
)
|
||||||
|
session.add(person)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_person())
|
||||||
|
response = client.get(f"/ui/people/{person_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "/uploads/portraits/person/seeded.png" in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_renders_linked_documents(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_links() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Linked Person")
|
||||||
|
document = Document(name="Linked Document", document_type="letter")
|
||||||
|
session.add(person)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_links())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Linked Document" in response.text
|
||||||
|
assert "Role: author" in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_handles_invalid_id(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/people/not-a-uuid")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Invalid person id" in response.text
|
||||||
|
|
||||||
|
def test_person_detail_page_handles_missing_person(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{uuid4()}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Person not found" in response.text
|
||||||
|
|
||||||
|
def test_person_edit_page_renders_expected_fields(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Editable Person", display_name="EP")
|
||||||
|
session.add(person)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}/edit")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Edit Person Record" in response.text
|
||||||
|
assert "Full name is required." in response.text
|
||||||
|
assert "Full name" in response.text
|
||||||
|
assert "Save changes" in response.text
|
||||||
|
|
||||||
|
def test_person_delete_page_shows_confirmation_when_unlinked(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_person() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Safe Delete")
|
||||||
|
session.add(person)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_person())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete Person Record" in response.text
|
||||||
|
assert "This action permanently deletes the person record." in response.text
|
||||||
|
assert "Delete person permanently" in response.text
|
||||||
|
|
||||||
|
def test_person_delete_page_shows_blocked_state_when_linked_documents_exist(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed_links() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
person = Person(full_name="Blocked Delete")
|
||||||
|
document = Document(name="Linked Document", document_type="record")
|
||||||
|
session.add(person)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
DocumentPerson(
|
||||||
|
document_id=document.id,
|
||||||
|
person_id=person.id,
|
||||||
|
role=DocumentPersonRole.AUTHOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
return str(person.id)
|
||||||
|
|
||||||
|
person_id = asyncio.run(_seed_links())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/people/{person_id}/delete")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Delete is blocked because linked documents exist." in response.text
|
||||||
|
assert "Linked documents: 1" in response.text
|
||||||
|
assert "Go to Documents" in response.text
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Tests for the sources page routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from transcription.db import session_scope
|
||||||
|
from transcription.db.models import Document
|
||||||
|
from transcription.db.models import Job
|
||||||
|
from transcription.db.models import Source
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestSourcesPageRendering:
|
||||||
|
"""Verify source list and detail routes render expected states."""
|
||||||
|
|
||||||
|
def test_sources_page_renders_empty_state(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
response = client.get("/ui/sources")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sources" in response.text
|
||||||
|
assert "No source file records found." in response.text
|
||||||
|
|
||||||
|
def test_sources_page_lists_seeded_sources(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed() -> None:
|
||||||
|
async with session_scope() as session:
|
||||||
|
document = Document(name="Source Document", document_type="letter")
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=document.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="page_one.png",
|
||||||
|
filename="stored_page_one.png",
|
||||||
|
file_path="/tmp/stored_page_one.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_seed())
|
||||||
|
|
||||||
|
response = client.get("/ui/sources")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "page_one.png" in response.text
|
||||||
|
assert "stored_page_one.png" in response.text
|
||||||
|
|
||||||
|
def test_sources_page_filters_to_document_context(self, app_client):
|
||||||
|
_, client = app_client
|
||||||
|
|
||||||
|
async def _seed() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
target = Document(name="Target", document_type="letter")
|
||||||
|
other = Document(name="Other", document_type="record")
|
||||||
|
session.add(target)
|
||||||
|
session.add(other)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=target.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="target_page.png",
|
||||||
|
filename="target_stored.png",
|
||||||
|
file_path="/tmp/target_stored.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Source(
|
||||||
|
document_id=other.id,
|
||||||
|
page_number=1,
|
||||||
|
upload_name="other_page.png",
|
||||||
|
filename="other_stored.png",
|
||||||
|
file_path="/tmp/other_stored.png",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return str(target.id)
|
||||||
|
|
||||||
|
document_id = asyncio.run(_seed())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/sources?document_id={document_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sources: Target" in response.text
|
||||||
|
assert "Back to Document" in response.text
|
||||||
|
assert "target_page.png" in response.text
|
||||||
|
assert "other_page.png" not in response.text
|
||||||
|
|
||||||
|
def test_sources_page_filters_to_job_context(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
job_id = seed_job(filename="job-page.png", transcription_text="job text")
|
||||||
|
|
||||||
|
response = client.get(f"/ui/sources?job_id={job_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Sources for Job" in response.text
|
||||||
|
assert "Back to Job" in response.text
|
||||||
|
assert "job-page.png" in response.text
|
||||||
|
|
||||||
|
def test_source_detail_page_renders_preview_and_revision_box(self, app_client, seed_job):
|
||||||
|
_, client = app_client
|
||||||
|
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "small_png.png"
|
||||||
|
job_id = seed_job(
|
||||||
|
filename="detail-source.png",
|
||||||
|
transcription_text="original transcription text",
|
||||||
|
revision_text="human revision text",
|
||||||
|
source_file=fixture_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_source_id() -> str:
|
||||||
|
async with session_scope() as session:
|
||||||
|
job = await session.get(Job, job_id)
|
||||||
|
assert job is not None
|
||||||
|
source = (
|
||||||
|
await session.exec(select(Source).where(Source.document_id == job.document_id))
|
||||||
|
).first()
|
||||||
|
assert source is not None
|
||||||
|
return str(source.id)
|
||||||
|
|
||||||
|
source_id = asyncio.run(_get_source_id())
|
||||||
|
|
||||||
|
response = client.get(f"/ui/sources/{source_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Source Page 1: detail-source.png" in response.text
|
||||||
|
assert "Back to Sources" in response.text
|
||||||
|
assert "automated raw transcription" in response.text.lower()
|
||||||
|
assert "original transcription text" in response.text
|
||||||
|
assert "curated human transcription" in response.text.lower()
|
||||||
|
assert "human revision text" in response.text
|
||||||
|
assert "Page Number:" in response.text
|
||||||
|
assert "Stored Filename:" in response.text
|
||||||
@@ -15,22 +15,18 @@ class TestPageRendering:
|
|||||||
assert response.status_code == 307
|
assert response.status_code == 307
|
||||||
assert response.headers["location"] == "/ui"
|
assert response.headers["location"] == "/ui"
|
||||||
|
|
||||||
def test_ui_redirects_to_upload(self, app_client):
|
def test_ui_redirects_to_documents(self, app_client):
|
||||||
"""GET /ui redirects to the upload page."""
|
"""GET /ui redirects to the documents page."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
response = client.get("/ui", follow_redirects=False)
|
response = client.get("/ui", follow_redirects=False)
|
||||||
|
|
||||||
assert response.status_code == 307
|
assert response.status_code == 307
|
||||||
assert response.headers["location"] == "/ui/upload"
|
assert response.headers["location"] == "/ui/documents"
|
||||||
|
|
||||||
def test_upload_page_renders_expected_controls(self, app_client):
|
def test_upload_page_renders_expected_controls(self, app_client):
|
||||||
"""GET /ui/upload returns the page shell and upload controls."""
|
"""GET /ui/upload redirects to the job-create flow."""
|
||||||
_, client = app_client
|
_, client = app_client
|
||||||
response = client.get("/ui/upload")
|
response = client.get("/ui/upload", follow_redirects=False)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 307
|
||||||
assert "VibeScribe" in response.text
|
assert response.headers["location"] == "/ui/jobs/new"
|
||||||
assert "Upload Document" in response.text
|
|
||||||
assert "Select document file" in response.text
|
|
||||||
assert "Upload" in response.text
|
|
||||||
assert "Jobs" in response.text
|
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user