Compare commits
5 Commits
992f897878
...
3b72919015
| Author | SHA1 | Date | |
|---|---|---|---|
|
3b72919015
|
|||
|
859acc133c
|
|||
|
df401b9e55
|
|||
|
a18545fb18
|
|||
|
4beab4bc09
|
20
CHANGELOG.md
Normal file
20
CHANGELOG.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Changelog
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initialized `CHANGELOG.md` with Keep a Changelog structure for ongoing release-note tracking.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Refreshed `README.md` with current stack details, runtime services, setup commands, configuration notes, and manual validation guidance.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
### Security
|
||||||
145
README.md
145
README.md
@@ -1,121 +1,154 @@
|
|||||||
# DMS
|
# DMS
|
||||||
|
|
||||||
DMS is a single-user document management system for ingesting, processing, organizing, and searching files.
|
DMS is a self-hosted document management system for ingesting, processing, organizing, and searching files.
|
||||||
|
|
||||||
Core capabilities:
|
## Core Capabilities
|
||||||
- drag-and-drop upload from anywhere in the UI
|
|
||||||
- file and folder upload with path preservation
|
|
||||||
- asynchronous processing with OCR and extraction for PDF, images, DOCX, XLSX, TXT, and ZIP
|
|
||||||
- metadata and full-text search
|
|
||||||
- routing suggestions based on learned decisions
|
|
||||||
- original file download and extracted markdown export
|
|
||||||
|
|
||||||
## Stack
|
- Drag and drop upload from anywhere in the UI
|
||||||
|
- File and folder upload with path preservation
|
||||||
|
- Asynchronous extraction and OCR for PDF, images, DOCX, XLSX, TXT, and ZIP
|
||||||
|
- Metadata and full-text search
|
||||||
|
- Routing suggestions based on previous decisions
|
||||||
|
- Original file download and extracted markdown export
|
||||||
|
|
||||||
- Backend: FastAPI + SQLAlchemy + RQ worker (`backend/`)
|
## Technology Stack
|
||||||
- Frontend: React + Vite + TypeScript (`frontend/`)
|
|
||||||
- Infrastructure: Postgres, Redis, Typesense (`docker-compose.yml`)
|
- Backend: FastAPI, SQLAlchemy, RQ worker (`backend/`)
|
||||||
|
- Frontend: React, Vite, TypeScript (`frontend/`)
|
||||||
|
- Infrastructure: PostgreSQL, Redis, Typesense (`docker-compose.yml`)
|
||||||
|
|
||||||
|
## Runtime Services
|
||||||
|
|
||||||
|
The default `docker compose` stack includes:
|
||||||
|
|
||||||
|
- `frontend` - React UI (`http://localhost:5173`)
|
||||||
|
- `api` - FastAPI backend (`http://localhost:8000`, docs at `/docs`)
|
||||||
|
- `worker` - background processing jobs
|
||||||
|
- `db` - PostgreSQL (`localhost:5432`)
|
||||||
|
- `redis` - queue backend (`localhost:6379`)
|
||||||
|
- `typesense` - search index (`localhost:8108`)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Docker Engine
|
- Docker Engine
|
||||||
- Docker Compose plugin
|
- Docker Compose plugin
|
||||||
- Internet access for the first image build
|
- Internet access for first-time image build
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. Start the full stack from repository root:
|
From repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Open services:
|
Open:
|
||||||
|
|
||||||
- Frontend: `http://localhost:5173`
|
- Frontend: `http://localhost:5173`
|
||||||
- Backend OpenAPI docs: `http://localhost:8000/docs`
|
- API docs: `http://localhost:8000/docs`
|
||||||
- Health endpoint: `http://localhost:8000/api/v1/health`
|
- Health: `http://localhost:8000/api/v1/health`
|
||||||
|
|
||||||
3. Stop when done:
|
Stop the stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Commands
|
## Common Operations
|
||||||
|
|
||||||
Start services:
|
Start or rebuild:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Stop services:
|
Stop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Stream logs:
|
Tail logs:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
Rebuild services:
|
Tail API and worker logs only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose logs -f api worker
|
||||||
docker compose up --build -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Reset runtime data (destructive, removes named volumes):
|
Reset all runtime data (destructive):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Frontend-Only Local Workflow
|
||||||
|
|
||||||
|
If backend services are already running, you can run frontend tooling locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run dev
|
||||||
|
cd frontend && npm run build
|
||||||
|
cd frontend && npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run preview` serves the built app on port `4173`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Main runtime variables are defined in `docker-compose.yml`:
|
||||||
|
|
||||||
|
- API and worker: `DATABASE_URL`, `REDIS_URL`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `TYPESENSE_*`
|
||||||
|
- Frontend: `VITE_API_BASE`
|
||||||
|
|
||||||
|
Application settings saved from the UI persist at:
|
||||||
|
|
||||||
|
- `<STORAGE_ROOT>/settings.json` (inside the storage volume)
|
||||||
|
|
||||||
|
Settings endpoints:
|
||||||
|
|
||||||
|
- `GET/PUT /api/v1/settings`
|
||||||
|
- `POST /api/v1/settings/reset`
|
||||||
|
- `POST /api/v1/settings/handwriting`
|
||||||
|
- `POST /api/v1/processing/logs/trim`
|
||||||
|
|
||||||
|
Note: the compose file currently includes host-specific URL values (for example `PUBLIC_BASE_URL` and `VITE_API_BASE`). Adjust these for your environment when needed.
|
||||||
|
|
||||||
## Data Persistence
|
## Data Persistence
|
||||||
|
|
||||||
Runtime state is persisted in Docker named volumes declared in `docker-compose.yml`:
|
Docker named volumes used by the stack:
|
||||||
|
|
||||||
- `db-data`
|
- `db-data`
|
||||||
- `redis-data`
|
- `redis-data`
|
||||||
- `dcm-storage`
|
- `dcm-storage`
|
||||||
- `typesense-data`
|
- `typesense-data`
|
||||||
|
|
||||||
The application settings file is stored under the storage volume at `/data/storage/settings.json` inside containers.
|
## Validation Checklist
|
||||||
|
|
||||||
## Configuration Notes
|
After setup or config changes, verify:
|
||||||
|
|
||||||
- API and worker runtime environment values are configured in `docker-compose.yml` (`DATABASE_URL`, `REDIS_URL`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `TYPESENSE_*`).
|
|
||||||
- Frontend API target is controlled by `VITE_API_BASE` in the `frontend` service.
|
|
||||||
- Handwriting, provider, routing, summary, display, and upload defaults are managed through the settings UI and persisted by the backend settings service.
|
|
||||||
|
|
||||||
## Manual Validation Checklist
|
|
||||||
|
|
||||||
After changes, verify:
|
|
||||||
- `GET /api/v1/health` returns `{"status":"ok"}`
|
- `GET /api/v1/health` returns `{"status":"ok"}`
|
||||||
- upload and processing complete successfully
|
- Upload and processing complete successfully
|
||||||
- search returns expected results
|
- Search returns expected results
|
||||||
- preview or download works for uploaded documents
|
- Preview and download work for uploaded documents
|
||||||
- `docker compose logs -f` shows no API or worker failures
|
- `docker compose logs -f api worker` has no failures
|
||||||
|
|
||||||
## API Surface Summary
|
## Repository Layout
|
||||||
|
|
||||||
Base prefix: `/api/v1`
|
- `backend/` - FastAPI API, services, models, worker
|
||||||
|
- `frontend/` - React application
|
||||||
|
- `doc/` - technical documentation for architecture, API, data model, and operations
|
||||||
|
- `docker-compose.yml` - local runtime topology
|
||||||
|
|
||||||
- Health: `/health`
|
## Documentation Index
|
||||||
- Documents: `/documents` (listing, upload, metadata update, lifecycle actions, download and preview, markdown export)
|
|
||||||
- Search: `/search`
|
|
||||||
- Processing logs: `/processing/logs`
|
|
||||||
- Settings: `/settings` and `/settings/handwriting`
|
|
||||||
|
|
||||||
See `doc/api-contract.md` for the complete endpoint contract.
|
- `doc/README.md` - technical documentation entrypoint
|
||||||
|
|
||||||
## Technical Documentation
|
|
||||||
|
|
||||||
- `doc/README.md` - technical documentation index
|
|
||||||
- `doc/architecture-overview.md` - service and runtime architecture
|
- `doc/architecture-overview.md` - service and runtime architecture
|
||||||
- `doc/api-contract.md` - HTTP endpoint contract and payload model map
|
- `doc/api-contract.md` - endpoint and payload contract
|
||||||
- `doc/data-model-reference.md` - database model reference
|
- `doc/data-model-reference.md` - persistence model reference
|
||||||
- `doc/operations-and-configuration.md` - operations runbook and configuration reference
|
- `doc/operations-and-configuration.md` - runtime operations and configuration
|
||||||
- `doc/frontend-design-foundation.md` - frontend design system and UI rules
|
- `doc/frontend-design-foundation.md` - frontend design rules
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.schemas.processing_logs import ProcessingLogEntryResponse, ProcessingLogListResponse
|
from app.schemas.processing_logs import ProcessingLogEntryResponse, ProcessingLogListResponse
|
||||||
|
from app.services.app_settings import read_processing_log_retention_settings
|
||||||
from app.services.processing_logs import (
|
from app.services.processing_logs import (
|
||||||
cleanup_processing_logs,
|
cleanup_processing_logs,
|
||||||
clear_processing_logs,
|
clear_processing_logs,
|
||||||
@@ -42,16 +43,28 @@ def get_processing_logs(
|
|||||||
|
|
||||||
@router.post("/trim")
|
@router.post("/trim")
|
||||||
def trim_processing_logs(
|
def trim_processing_logs(
|
||||||
keep_document_sessions: int = Query(default=2, ge=0, le=20),
|
keep_document_sessions: int | None = Query(default=None, ge=0, le=20),
|
||||||
keep_unbound_entries: int = Query(default=80, ge=0, le=400),
|
keep_unbound_entries: int | None = Query(default=None, ge=0, le=400),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
"""Deletes old processing logs while keeping recent document sessions and unbound events."""
|
"""Deletes old processing logs using query values or persisted retention defaults."""
|
||||||
|
|
||||||
|
retention_defaults = read_processing_log_retention_settings()
|
||||||
|
resolved_keep_document_sessions = (
|
||||||
|
keep_document_sessions
|
||||||
|
if keep_document_sessions is not None
|
||||||
|
else int(retention_defaults.get("keep_document_sessions", 2))
|
||||||
|
)
|
||||||
|
resolved_keep_unbound_entries = (
|
||||||
|
keep_unbound_entries
|
||||||
|
if keep_unbound_entries is not None
|
||||||
|
else int(retention_defaults.get("keep_unbound_entries", 80))
|
||||||
|
)
|
||||||
|
|
||||||
result = cleanup_processing_logs(
|
result = cleanup_processing_logs(
|
||||||
session=session,
|
session=session,
|
||||||
keep_document_sessions=keep_document_sessions,
|
keep_document_sessions=resolved_keep_document_sessions,
|
||||||
keep_unbound_entries=keep_unbound_entries,
|
keep_unbound_entries=resolved_keep_unbound_entries,
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.schemas.settings import (
|
|||||||
HandwritingStyleSettingsResponse,
|
HandwritingStyleSettingsResponse,
|
||||||
HandwritingSettingsUpdateRequest,
|
HandwritingSettingsUpdateRequest,
|
||||||
OcrTaskSettingsResponse,
|
OcrTaskSettingsResponse,
|
||||||
|
ProcessingLogRetentionSettingsResponse,
|
||||||
ProviderSettingsResponse,
|
ProviderSettingsResponse,
|
||||||
RoutingTaskSettingsResponse,
|
RoutingTaskSettingsResponse,
|
||||||
SummaryTaskSettingsResponse,
|
SummaryTaskSettingsResponse,
|
||||||
@@ -35,6 +36,7 @@ def _build_response(payload: dict) -> AppSettingsResponse:
|
|||||||
|
|
||||||
upload_defaults_payload = payload.get("upload_defaults", {})
|
upload_defaults_payload = payload.get("upload_defaults", {})
|
||||||
display_payload = payload.get("display", {})
|
display_payload = payload.get("display", {})
|
||||||
|
processing_log_retention_payload = payload.get("processing_log_retention", {})
|
||||||
providers_payload = payload.get("providers", [])
|
providers_payload = payload.get("providers", [])
|
||||||
tasks_payload = payload.get("tasks", {})
|
tasks_payload = payload.get("tasks", {})
|
||||||
handwriting_style_payload = payload.get("handwriting_style_clustering", {})
|
handwriting_style_payload = payload.get("handwriting_style_clustering", {})
|
||||||
@@ -55,6 +57,10 @@ def _build_response(payload: dict) -> AppSettingsResponse:
|
|||||||
cards_per_page=int(display_payload.get("cards_per_page", 12)),
|
cards_per_page=int(display_payload.get("cards_per_page", 12)),
|
||||||
log_typing_animation_enabled=bool(display_payload.get("log_typing_animation_enabled", True)),
|
log_typing_animation_enabled=bool(display_payload.get("log_typing_animation_enabled", True)),
|
||||||
),
|
),
|
||||||
|
processing_log_retention=ProcessingLogRetentionSettingsResponse(
|
||||||
|
keep_document_sessions=int(processing_log_retention_payload.get("keep_document_sessions", 2)),
|
||||||
|
keep_unbound_entries=int(processing_log_retention_payload.get("keep_unbound_entries", 80)),
|
||||||
|
),
|
||||||
handwriting_style_clustering=HandwritingStyleSettingsResponse(
|
handwriting_style_clustering=HandwritingStyleSettingsResponse(
|
||||||
enabled=bool(handwriting_style_payload.get("enabled", True)),
|
enabled=bool(handwriting_style_payload.get("enabled", True)),
|
||||||
embed_model=str(handwriting_style_payload.get("embed_model", "ts/clip-vit-b-p32")),
|
embed_model=str(handwriting_style_payload.get("embed_model", "ts/clip-vit-b-p32")),
|
||||||
@@ -159,6 +165,10 @@ def set_app_settings(payload: AppSettingsUpdateRequest) -> AppSettingsResponse:
|
|||||||
if payload.display is not None:
|
if payload.display is not None:
|
||||||
display_payload = payload.display.model_dump(exclude_none=True)
|
display_payload = payload.display.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
processing_log_retention_payload = None
|
||||||
|
if payload.processing_log_retention is not None:
|
||||||
|
processing_log_retention_payload = payload.processing_log_retention.model_dump(exclude_none=True)
|
||||||
|
|
||||||
handwriting_style_payload = None
|
handwriting_style_payload = None
|
||||||
if payload.handwriting_style_clustering is not None:
|
if payload.handwriting_style_clustering is not None:
|
||||||
handwriting_style_payload = payload.handwriting_style_clustering.model_dump(exclude_none=True)
|
handwriting_style_payload = payload.handwriting_style_clustering.model_dump(exclude_none=True)
|
||||||
@@ -174,6 +184,7 @@ def set_app_settings(payload: AppSettingsUpdateRequest) -> AppSettingsResponse:
|
|||||||
tasks=tasks_payload,
|
tasks=tasks_payload,
|
||||||
upload_defaults=upload_defaults_payload,
|
upload_defaults=upload_defaults_payload,
|
||||||
display=display_payload,
|
display=display_payload,
|
||||||
|
processing_log_retention=processing_log_retention_payload,
|
||||||
handwriting_style=handwriting_style_payload,
|
handwriting_style=handwriting_style_payload,
|
||||||
predefined_paths=predefined_paths_payload,
|
predefined_paths=predefined_paths_payload,
|
||||||
predefined_tags=predefined_tags_payload,
|
predefined_tags=predefined_tags_payload,
|
||||||
|
|||||||
@@ -127,6 +127,20 @@ class DisplaySettingsUpdateRequest(BaseModel):
|
|||||||
log_typing_animation_enabled: bool | None = None
|
log_typing_animation_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingLogRetentionSettingsResponse(BaseModel):
|
||||||
|
"""Represents retention limits used when pruning processing pipeline logs."""
|
||||||
|
|
||||||
|
keep_document_sessions: int = Field(default=2, ge=0, le=20)
|
||||||
|
keep_unbound_entries: int = Field(default=80, ge=0, le=400)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingLogRetentionSettingsUpdateRequest(BaseModel):
|
||||||
|
"""Represents partial updates for processing log retention limits."""
|
||||||
|
|
||||||
|
keep_document_sessions: int | None = Field(default=None, ge=0, le=20)
|
||||||
|
keep_unbound_entries: int | None = Field(default=None, ge=0, le=400)
|
||||||
|
|
||||||
|
|
||||||
class PredefinedPathEntryResponse(BaseModel):
|
class PredefinedPathEntryResponse(BaseModel):
|
||||||
"""Represents one predefined logical path with global discoverability scope."""
|
"""Represents one predefined logical path with global discoverability scope."""
|
||||||
|
|
||||||
@@ -200,6 +214,7 @@ class AppSettingsResponse(BaseModel):
|
|||||||
|
|
||||||
upload_defaults: UploadDefaultsResponse
|
upload_defaults: UploadDefaultsResponse
|
||||||
display: DisplaySettingsResponse
|
display: DisplaySettingsResponse
|
||||||
|
processing_log_retention: ProcessingLogRetentionSettingsResponse
|
||||||
handwriting_style_clustering: HandwritingStyleSettingsResponse
|
handwriting_style_clustering: HandwritingStyleSettingsResponse
|
||||||
predefined_paths: list[PredefinedPathEntryResponse] = Field(default_factory=list)
|
predefined_paths: list[PredefinedPathEntryResponse] = Field(default_factory=list)
|
||||||
predefined_tags: list[PredefinedTagEntryResponse] = Field(default_factory=list)
|
predefined_tags: list[PredefinedTagEntryResponse] = Field(default_factory=list)
|
||||||
@@ -212,6 +227,7 @@ class AppSettingsUpdateRequest(BaseModel):
|
|||||||
|
|
||||||
upload_defaults: UploadDefaultsUpdateRequest | None = None
|
upload_defaults: UploadDefaultsUpdateRequest | None = None
|
||||||
display: DisplaySettingsUpdateRequest | None = None
|
display: DisplaySettingsUpdateRequest | None = None
|
||||||
|
processing_log_retention: ProcessingLogRetentionSettingsUpdateRequest | None = None
|
||||||
handwriting_style_clustering: HandwritingStyleSettingsUpdateRequest | None = None
|
handwriting_style_clustering: HandwritingStyleSettingsUpdateRequest | None = None
|
||||||
predefined_paths: list[PredefinedPathEntryUpdateRequest] | None = None
|
predefined_paths: list[PredefinedPathEntryUpdateRequest] | None = None
|
||||||
predefined_tags: list[PredefinedTagEntryUpdateRequest] | None = None
|
predefined_tags: list[PredefinedTagEntryUpdateRequest] | None = None
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ TASK_OCR_HANDWRITING = "ocr_handwriting"
|
|||||||
TASK_SUMMARY_GENERATION = "summary_generation"
|
TASK_SUMMARY_GENERATION = "summary_generation"
|
||||||
TASK_ROUTING_CLASSIFICATION = "routing_classification"
|
TASK_ROUTING_CLASSIFICATION = "routing_classification"
|
||||||
HANDWRITING_STYLE_SETTINGS_KEY = "handwriting_style_clustering"
|
HANDWRITING_STYLE_SETTINGS_KEY = "handwriting_style_clustering"
|
||||||
|
PROCESSING_LOG_RETENTION_SETTINGS_KEY = "processing_log_retention"
|
||||||
PREDEFINED_PATHS_SETTINGS_KEY = "predefined_paths"
|
PREDEFINED_PATHS_SETTINGS_KEY = "predefined_paths"
|
||||||
PREDEFINED_TAGS_SETTINGS_KEY = "predefined_tags"
|
PREDEFINED_TAGS_SETTINGS_KEY = "predefined_tags"
|
||||||
DEFAULT_HANDWRITING_STYLE_EMBED_MODEL = "ts/clip-vit-b-p32"
|
DEFAULT_HANDWRITING_STYLE_EMBED_MODEL = "ts/clip-vit-b-p32"
|
||||||
@@ -65,6 +66,10 @@ def _default_settings() -> dict[str, Any]:
|
|||||||
"cards_per_page": 12,
|
"cards_per_page": 12,
|
||||||
"log_typing_animation_enabled": True,
|
"log_typing_animation_enabled": True,
|
||||||
},
|
},
|
||||||
|
PROCESSING_LOG_RETENTION_SETTINGS_KEY: {
|
||||||
|
"keep_document_sessions": 2,
|
||||||
|
"keep_unbound_entries": 80,
|
||||||
|
},
|
||||||
PREDEFINED_PATHS_SETTINGS_KEY: [],
|
PREDEFINED_PATHS_SETTINGS_KEY: [],
|
||||||
PREDEFINED_TAGS_SETTINGS_KEY: [],
|
PREDEFINED_TAGS_SETTINGS_KEY: [],
|
||||||
HANDWRITING_STYLE_SETTINGS_KEY: {
|
HANDWRITING_STYLE_SETTINGS_KEY: {
|
||||||
@@ -148,6 +153,18 @@ def _clamp_cards_per_page(value: int) -> int:
|
|||||||
return max(1, min(200, value))
|
return max(1, min(200, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_processing_log_document_sessions(value: int) -> int:
|
||||||
|
"""Clamps the number of recent document log sessions kept during cleanup."""
|
||||||
|
|
||||||
|
return max(0, min(20, value))
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp_processing_log_unbound_entries(value: int) -> int:
|
||||||
|
"""Clamps retained unbound processing log events kept during cleanup."""
|
||||||
|
|
||||||
|
return max(0, min(400, value))
|
||||||
|
|
||||||
|
|
||||||
def _clamp_predefined_entries_limit(value: int) -> int:
|
def _clamp_predefined_entries_limit(value: int) -> int:
|
||||||
"""Clamps maximum count for predefined tag/path catalog entries."""
|
"""Clamps maximum count for predefined tag/path catalog entries."""
|
||||||
|
|
||||||
@@ -401,6 +418,28 @@ def _normalize_display_settings(payload: dict[str, Any], defaults: dict[str, Any
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_processing_log_retention(payload: dict[str, Any], defaults: dict[str, Any]) -> dict[str, int]:
|
||||||
|
"""Normalizes processing log retention settings used by API and worker cleanup defaults."""
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
default_keep_document_sessions = _clamp_processing_log_document_sessions(
|
||||||
|
_safe_int(defaults.get("keep_document_sessions", 2), 2)
|
||||||
|
)
|
||||||
|
default_keep_unbound_entries = _clamp_processing_log_unbound_entries(
|
||||||
|
_safe_int(defaults.get("keep_unbound_entries", 80), 80)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"keep_document_sessions": _clamp_processing_log_document_sessions(
|
||||||
|
_safe_int(payload.get("keep_document_sessions", default_keep_document_sessions), default_keep_document_sessions)
|
||||||
|
),
|
||||||
|
"keep_unbound_entries": _clamp_processing_log_unbound_entries(
|
||||||
|
_safe_int(payload.get("keep_unbound_entries", default_keep_unbound_entries), default_keep_unbound_entries)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_predefined_paths(
|
def _normalize_predefined_paths(
|
||||||
payload: Any,
|
payload: Any,
|
||||||
existing_items: list[dict[str, Any]] | None = None,
|
existing_items: list[dict[str, Any]] | None = None,
|
||||||
@@ -567,6 +606,10 @@ def _sanitize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
normalized_tasks = _normalize_tasks(tasks_payload, provider_ids)
|
normalized_tasks = _normalize_tasks(tasks_payload, provider_ids)
|
||||||
upload_defaults = _normalize_upload_defaults(payload.get("upload_defaults", {}), defaults["upload_defaults"])
|
upload_defaults = _normalize_upload_defaults(payload.get("upload_defaults", {}), defaults["upload_defaults"])
|
||||||
display_settings = _normalize_display_settings(payload.get("display", {}), defaults["display"])
|
display_settings = _normalize_display_settings(payload.get("display", {}), defaults["display"])
|
||||||
|
processing_log_retention = _normalize_processing_log_retention(
|
||||||
|
payload.get(PROCESSING_LOG_RETENTION_SETTINGS_KEY, {}),
|
||||||
|
defaults[PROCESSING_LOG_RETENTION_SETTINGS_KEY],
|
||||||
|
)
|
||||||
predefined_paths = _normalize_predefined_paths(
|
predefined_paths = _normalize_predefined_paths(
|
||||||
payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
||||||
existing_items=payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
existing_items=payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
||||||
@@ -583,6 +626,7 @@ def _sanitize_settings(payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"upload_defaults": upload_defaults,
|
"upload_defaults": upload_defaults,
|
||||||
"display": display_settings,
|
"display": display_settings,
|
||||||
|
PROCESSING_LOG_RETENTION_SETTINGS_KEY: processing_log_retention,
|
||||||
PREDEFINED_PATHS_SETTINGS_KEY: predefined_paths,
|
PREDEFINED_PATHS_SETTINGS_KEY: predefined_paths,
|
||||||
PREDEFINED_TAGS_SETTINGS_KEY: predefined_tags,
|
PREDEFINED_TAGS_SETTINGS_KEY: predefined_tags,
|
||||||
HANDWRITING_STYLE_SETTINGS_KEY: handwriting_style_settings,
|
HANDWRITING_STYLE_SETTINGS_KEY: handwriting_style_settings,
|
||||||
@@ -645,6 +689,10 @@ def read_app_settings() -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"upload_defaults": payload.get("upload_defaults", {"logical_path": "Inbox", "tags": []}),
|
"upload_defaults": payload.get("upload_defaults", {"logical_path": "Inbox", "tags": []}),
|
||||||
"display": payload.get("display", {"cards_per_page": 12, "log_typing_animation_enabled": True}),
|
"display": payload.get("display", {"cards_per_page": 12, "log_typing_animation_enabled": True}),
|
||||||
|
PROCESSING_LOG_RETENTION_SETTINGS_KEY: payload.get(
|
||||||
|
PROCESSING_LOG_RETENTION_SETTINGS_KEY,
|
||||||
|
_default_settings()[PROCESSING_LOG_RETENTION_SETTINGS_KEY],
|
||||||
|
),
|
||||||
PREDEFINED_PATHS_SETTINGS_KEY: payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
PREDEFINED_PATHS_SETTINGS_KEY: payload.get(PREDEFINED_PATHS_SETTINGS_KEY, []),
|
||||||
PREDEFINED_TAGS_SETTINGS_KEY: payload.get(PREDEFINED_TAGS_SETTINGS_KEY, []),
|
PREDEFINED_TAGS_SETTINGS_KEY: payload.get(PREDEFINED_TAGS_SETTINGS_KEY, []),
|
||||||
HANDWRITING_STYLE_SETTINGS_KEY: payload.get(HANDWRITING_STYLE_SETTINGS_KEY, {}),
|
HANDWRITING_STYLE_SETTINGS_KEY: payload.get(HANDWRITING_STYLE_SETTINGS_KEY, {}),
|
||||||
@@ -687,16 +735,23 @@ def update_app_settings(
|
|||||||
tasks: dict[str, dict[str, Any]] | None = None,
|
tasks: dict[str, dict[str, Any]] | None = None,
|
||||||
upload_defaults: dict[str, Any] | None = None,
|
upload_defaults: dict[str, Any] | None = None,
|
||||||
display: dict[str, Any] | None = None,
|
display: dict[str, Any] | None = None,
|
||||||
|
processing_log_retention: dict[str, Any] | None = None,
|
||||||
handwriting_style: dict[str, Any] | None = None,
|
handwriting_style: dict[str, Any] | None = None,
|
||||||
predefined_paths: list[dict[str, Any]] | None = None,
|
predefined_paths: list[dict[str, Any]] | None = None,
|
||||||
predefined_tags: list[dict[str, Any]] | None = None,
|
predefined_tags: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Updates app settings, persists them, and returns API-safe values."""
|
"""Updates app settings blocks, persists them, and returns API-safe values."""
|
||||||
|
|
||||||
current_payload = _read_raw_settings()
|
current_payload = _read_raw_settings()
|
||||||
next_payload: dict[str, Any] = {
|
next_payload: dict[str, Any] = {
|
||||||
"upload_defaults": dict(current_payload.get("upload_defaults", {"logical_path": "Inbox", "tags": []})),
|
"upload_defaults": dict(current_payload.get("upload_defaults", {"logical_path": "Inbox", "tags": []})),
|
||||||
"display": dict(current_payload.get("display", {"cards_per_page": 12, "log_typing_animation_enabled": True})),
|
"display": dict(current_payload.get("display", {"cards_per_page": 12, "log_typing_animation_enabled": True})),
|
||||||
|
PROCESSING_LOG_RETENTION_SETTINGS_KEY: dict(
|
||||||
|
current_payload.get(
|
||||||
|
PROCESSING_LOG_RETENTION_SETTINGS_KEY,
|
||||||
|
_default_settings()[PROCESSING_LOG_RETENTION_SETTINGS_KEY],
|
||||||
|
)
|
||||||
|
),
|
||||||
PREDEFINED_PATHS_SETTINGS_KEY: list(current_payload.get(PREDEFINED_PATHS_SETTINGS_KEY, [])),
|
PREDEFINED_PATHS_SETTINGS_KEY: list(current_payload.get(PREDEFINED_PATHS_SETTINGS_KEY, [])),
|
||||||
PREDEFINED_TAGS_SETTINGS_KEY: list(current_payload.get(PREDEFINED_TAGS_SETTINGS_KEY, [])),
|
PREDEFINED_TAGS_SETTINGS_KEY: list(current_payload.get(PREDEFINED_TAGS_SETTINGS_KEY, [])),
|
||||||
HANDWRITING_STYLE_SETTINGS_KEY: dict(
|
HANDWRITING_STYLE_SETTINGS_KEY: dict(
|
||||||
@@ -766,6 +821,13 @@ def update_app_settings(
|
|||||||
next_display["log_typing_animation_enabled"] = bool(display["log_typing_animation_enabled"])
|
next_display["log_typing_animation_enabled"] = bool(display["log_typing_animation_enabled"])
|
||||||
next_payload["display"] = next_display
|
next_payload["display"] = next_display
|
||||||
|
|
||||||
|
if processing_log_retention is not None and isinstance(processing_log_retention, dict):
|
||||||
|
next_retention = dict(next_payload.get(PROCESSING_LOG_RETENTION_SETTINGS_KEY, {}))
|
||||||
|
for key in ("keep_document_sessions", "keep_unbound_entries"):
|
||||||
|
if key in processing_log_retention:
|
||||||
|
next_retention[key] = processing_log_retention[key]
|
||||||
|
next_payload[PROCESSING_LOG_RETENTION_SETTINGS_KEY] = next_retention
|
||||||
|
|
||||||
if handwriting_style is not None and isinstance(handwriting_style, dict):
|
if handwriting_style is not None and isinstance(handwriting_style, dict):
|
||||||
next_handwriting_style = dict(next_payload.get(HANDWRITING_STYLE_SETTINGS_KEY, {}))
|
next_handwriting_style = dict(next_payload.get(HANDWRITING_STYLE_SETTINGS_KEY, {}))
|
||||||
for key in (
|
for key in (
|
||||||
@@ -828,6 +890,17 @@ def read_handwriting_style_settings() -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_processing_log_retention_settings() -> dict[str, int]:
|
||||||
|
"""Returns normalized processing log retention defaults used by worker and trim APIs."""
|
||||||
|
|
||||||
|
payload = _read_raw_settings()
|
||||||
|
defaults = _default_settings()[PROCESSING_LOG_RETENTION_SETTINGS_KEY]
|
||||||
|
return _normalize_processing_log_retention(
|
||||||
|
payload.get(PROCESSING_LOG_RETENTION_SETTINGS_KEY, {}),
|
||||||
|
defaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def read_predefined_paths_settings() -> list[dict[str, Any]]:
|
def read_predefined_paths_settings() -> list[dict[str, Any]]:
|
||||||
"""Returns normalized predefined logical path catalog entries."""
|
"""Returns normalized predefined logical path catalog entries."""
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ from datetime import UTC, datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.db.base import SessionLocal
|
from app.db.base import SessionLocal
|
||||||
from app.models.document import Document, DocumentStatus
|
from app.models.document import Document, DocumentStatus
|
||||||
from app.services.app_settings import read_handwriting_provider_settings, read_handwriting_style_settings
|
from app.services.app_settings import (
|
||||||
|
read_handwriting_provider_settings,
|
||||||
|
read_handwriting_style_settings,
|
||||||
|
read_processing_log_retention_settings,
|
||||||
|
)
|
||||||
from app.services.extractor import (
|
from app.services.extractor import (
|
||||||
IMAGE_EXTENSIONS,
|
IMAGE_EXTENSIONS,
|
||||||
extract_archive_members,
|
extract_archive_members,
|
||||||
@@ -32,6 +37,17 @@ from app.services.storage import absolute_path, compute_sha256, store_bytes, wri
|
|||||||
from app.worker.queue import get_processing_queue
|
from app.worker.queue import get_processing_queue
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_processing_logs_with_settings(session: Session) -> None:
|
||||||
|
"""Applies configured processing log retention while trimming old log entries."""
|
||||||
|
|
||||||
|
retention = read_processing_log_retention_settings()
|
||||||
|
cleanup_processing_logs(
|
||||||
|
session=session,
|
||||||
|
keep_document_sessions=int(retention.get("keep_document_sessions", 2)),
|
||||||
|
keep_unbound_entries=int(retention.get("keep_unbound_entries", 80)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_archive_member_document(
|
def _create_archive_member_document(
|
||||||
parent: Document,
|
parent: Document,
|
||||||
member_name: str,
|
member_name: str,
|
||||||
@@ -204,7 +220,7 @@ def process_document_task(document_id: str) -> None:
|
|||||||
document=document,
|
document=document,
|
||||||
payload_json={"status": document.status.value},
|
payload_json={"status": document.status.value},
|
||||||
)
|
)
|
||||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
_cleanup_processing_logs_with_settings(session=session)
|
||||||
session.commit()
|
session.commit()
|
||||||
for child_id in child_ids:
|
for child_id in child_ids:
|
||||||
queue.enqueue("app.worker.tasks.process_document_task", child_id)
|
queue.enqueue("app.worker.tasks.process_document_task", child_id)
|
||||||
@@ -239,7 +255,7 @@ def process_document_task(document_id: str) -> None:
|
|||||||
document=document,
|
document=document,
|
||||||
payload_json={"status": document.status.value},
|
payload_json={"status": document.status.value},
|
||||||
)
|
)
|
||||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
_cleanup_processing_logs_with_settings(session=session)
|
||||||
session.commit()
|
session.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -330,7 +346,7 @@ def process_document_task(document_id: str) -> None:
|
|||||||
document=document,
|
document=document,
|
||||||
payload_json={"status": document.status.value},
|
payload_json={"status": document.status.value},
|
||||||
)
|
)
|
||||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
_cleanup_processing_logs_with_settings(session=session)
|
||||||
session.commit()
|
session.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -362,7 +378,7 @@ def process_document_task(document_id: str) -> None:
|
|||||||
document=document,
|
document=document,
|
||||||
payload_json={"status": document.status.value},
|
payload_json={"status": document.status.value},
|
||||||
)
|
)
|
||||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
_cleanup_processing_logs_with_settings(session=session)
|
||||||
session.commit()
|
session.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -540,5 +556,5 @@ def process_document_task(document_id: str) -> None:
|
|||||||
document=document,
|
document=document,
|
||||||
payload_json={"status": document.status.value},
|
payload_json={"status": document.status.value},
|
||||||
)
|
)
|
||||||
cleanup_processing_logs(session=session, keep_document_sessions=2, keep_unbound_entries=80)
|
_cleanup_processing_logs_with_settings(session=session)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
135
backend/tests/test_processing_log_retention_settings.py
Normal file
135
backend/tests/test_processing_log_retention_settings.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""Unit coverage for persisted processing log retention settings behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(BACKEND_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
if "pydantic_settings" not in sys.modules:
|
||||||
|
pydantic_settings_stub = ModuleType("pydantic_settings")
|
||||||
|
|
||||||
|
class _BaseSettings:
|
||||||
|
"""Minimal BaseSettings replacement for dependency-light unit test execution."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: object) -> None:
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
def _settings_config_dict(**kwargs: object) -> dict[str, object]:
|
||||||
|
"""Returns configuration values using dict semantics expected by settings module."""
|
||||||
|
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
pydantic_settings_stub.BaseSettings = _BaseSettings
|
||||||
|
pydantic_settings_stub.SettingsConfigDict = _settings_config_dict
|
||||||
|
sys.modules["pydantic_settings"] = pydantic_settings_stub
|
||||||
|
|
||||||
|
from app.schemas.settings import AppSettingsUpdateRequest, ProcessingLogRetentionSettingsUpdateRequest
|
||||||
|
from app.services import app_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_current_payload() -> dict:
|
||||||
|
"""Builds a sanitized payload used as in-memory persistence fixture for update tests."""
|
||||||
|
|
||||||
|
return app_settings._sanitize_settings(app_settings._default_settings())
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingLogRetentionSettingsTests(unittest.TestCase):
|
||||||
|
"""Verifies defaulting, sanitization, schema mapping, and update merge behavior."""
|
||||||
|
|
||||||
|
def test_sanitize_settings_uses_default_retention_values(self) -> None:
|
||||||
|
"""Defaults are restored when persisted payload omits retention settings."""
|
||||||
|
|
||||||
|
sanitized = app_settings._sanitize_settings({})
|
||||||
|
self.assertEqual(
|
||||||
|
sanitized["processing_log_retention"],
|
||||||
|
{
|
||||||
|
"keep_document_sessions": 2,
|
||||||
|
"keep_unbound_entries": 80,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sanitize_settings_clamps_retention_values(self) -> None:
|
||||||
|
"""Retention values are clamped to same bounds enforced by trim endpoint query rules."""
|
||||||
|
|
||||||
|
sanitized = app_settings._sanitize_settings(
|
||||||
|
{
|
||||||
|
"processing_log_retention": {
|
||||||
|
"keep_document_sessions": 99,
|
||||||
|
"keep_unbound_entries": -5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
sanitized["processing_log_retention"],
|
||||||
|
{
|
||||||
|
"keep_document_sessions": 20,
|
||||||
|
"keep_unbound_entries": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_request_schema_accepts_processing_log_retention_payload(self) -> None:
|
||||||
|
"""Settings PATCH schema keeps retention fields in serialized payloads."""
|
||||||
|
|
||||||
|
request_payload = AppSettingsUpdateRequest(
|
||||||
|
processing_log_retention=ProcessingLogRetentionSettingsUpdateRequest(
|
||||||
|
keep_document_sessions=7,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
request_payload.model_dump(exclude_none=True)["processing_log_retention"],
|
||||||
|
{"keep_document_sessions": 7},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_app_settings_merges_retention_block_and_sanitizes_values(self) -> None:
|
||||||
|
"""Settings updates merge partial retention values and persist sanitized results."""
|
||||||
|
|
||||||
|
current_payload = _sample_current_payload()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(app_settings, "_read_raw_settings", return_value=current_payload),
|
||||||
|
patch.object(app_settings, "read_app_settings", return_value={"processing_log_retention": {}}),
|
||||||
|
patch.object(app_settings, "_write_settings") as write_settings_mock,
|
||||||
|
):
|
||||||
|
app_settings.update_app_settings(
|
||||||
|
processing_log_retention={
|
||||||
|
"keep_document_sessions": 9,
|
||||||
|
"keep_unbound_entries": 999,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
written_payload = write_settings_mock.call_args.args[0]
|
||||||
|
self.assertEqual(
|
||||||
|
written_payload["processing_log_retention"],
|
||||||
|
{
|
||||||
|
"keep_document_sessions": 9,
|
||||||
|
"keep_unbound_entries": 400,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_read_processing_log_retention_settings_returns_defaults_when_key_missing(self) -> None:
|
||||||
|
"""Reader falls back to defaults when persisted payload omits retention key."""
|
||||||
|
|
||||||
|
payload_without_retention = _sample_current_payload()
|
||||||
|
payload_without_retention.pop("processing_log_retention", None)
|
||||||
|
with patch.object(app_settings, "_read_raw_settings", return_value=payload_without_retention):
|
||||||
|
retention = app_settings.read_processing_log_retention_settings()
|
||||||
|
self.assertEqual(
|
||||||
|
retention,
|
||||||
|
{
|
||||||
|
"keep_document_sessions": 2,
|
||||||
|
"keep_unbound_entries": 80,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,7 +6,7 @@ This directory contains technical documentation for DMS.
|
|||||||
|
|
||||||
- `../README.md` - project overview, setup, and quick operations
|
- `../README.md` - project overview, setup, and quick operations
|
||||||
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
|
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
|
||||||
- `api-contract.md` - API endpoint contract grouped by route module
|
- `api-contract.md` - API endpoint contract grouped by route module, including settings and processing-log trim defaults
|
||||||
- `data-model-reference.md` - database entity definitions and lifecycle states
|
- `data-model-reference.md` - database entity definitions and lifecycle states
|
||||||
- `operations-and-configuration.md` - runtime operations, ports, volumes, and configuration values
|
- `operations-and-configuration.md` - runtime operations, ports, volumes, and persisted settings configuration
|
||||||
- `frontend-design-foundation.md` - frontend visual system, tokens, and UI implementation rules
|
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, processing-log timeline behavior, and settings helper-copy guidance
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ Primary implementation modules:
|
|||||||
- Query: `offset`, `limit`, `document_id`
|
- Query: `offset`, `limit`, `document_id`
|
||||||
- Response model: `ProcessingLogListResponse`
|
- Response model: `ProcessingLogListResponse`
|
||||||
- `POST /processing/logs/trim`
|
- `POST /processing/logs/trim`
|
||||||
- Query: `keep_document_sessions`, `keep_unbound_entries`
|
- Query: optional `keep_document_sessions`, `keep_unbound_entries`
|
||||||
|
- Behavior: omitted query values fall back to persisted `/settings.processing_log_retention`
|
||||||
- Response: trim counters
|
- Response: trim counters
|
||||||
- `POST /processing/logs/clear`
|
- `POST /processing/logs/clear`
|
||||||
- Response: clear counters
|
- Response: clear counters
|
||||||
@@ -127,4 +128,4 @@ Processing log schemas in `backend/app/schemas/processing_logs.py`:
|
|||||||
- `ProcessingLogListResponse`
|
- `ProcessingLogListResponse`
|
||||||
|
|
||||||
Settings schemas in `backend/app/schemas/settings.py`:
|
Settings schemas in `backend/app/schemas/settings.py`:
|
||||||
- Provider, task, upload-default, display, predefined paths or tags, handwriting-style, and legacy handwriting models grouped under `AppSettingsResponse` and `AppSettingsUpdateRequest`.
|
- Provider, task, upload-default, display, processing-log retention, predefined paths or tags, handwriting-style, and legacy handwriting models grouped under `AppSettingsResponse` and `AppSettingsUpdateRequest`.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Do not hardcode new palette or spacing values in component styles when a token a
|
|||||||
## Control Standards
|
## Control Standards
|
||||||
|
|
||||||
- Global input, select, textarea, and button styles are defined once in `frontend/src/styles.css`.
|
- Global input, select, textarea, and button styles are defined once in `frontend/src/styles.css`.
|
||||||
|
- Checkbox and radio controls must be styled through explicit `input[type='checkbox']` and `input[type='radio']` rules, not generic text-input selectors.
|
||||||
- Variant button classes (`secondary-action`, `active-view-button`, `warning-action`, `danger-action`) are the only approved button color routes.
|
- Variant button classes (`secondary-action`, `active-view-button`, `warning-action`, `danger-action`) are the only approved button color routes.
|
||||||
- Tag chips, routing pills, card chips, and icon buttons must stay within the compact radius and spacing scale.
|
- Tag chips, routing pills, card chips, and icon buttons must stay within the compact radius and spacing scale.
|
||||||
- Focus states use `:focus-visible` and tokenized focus color to preserve keyboard discoverability.
|
- Focus states use `:focus-visible` and tokenized focus color to preserve keyboard discoverability.
|
||||||
@@ -41,6 +42,13 @@ Do not hardcode new palette or spacing values in component styles when a token a
|
|||||||
- Keep transitions brief and functional.
|
- Keep transitions brief and functional.
|
||||||
- Avoid decorative animation loops outside explicit status indicators like terminal caret blink.
|
- Avoid decorative animation loops outside explicit status indicators like terminal caret blink.
|
||||||
|
|
||||||
|
## Processing Log Timeline Behavior
|
||||||
|
|
||||||
|
- Keep processing log headers in strict arrival order when typing animation is enabled.
|
||||||
|
- Buffer newly discovered entries and reveal only one active line at a time while it types.
|
||||||
|
- Do not render queued headers before their animation starts, even when polling returns batched updates.
|
||||||
|
- Preserve existing header content format and fold/unfold detail behavior as lines are revealed.
|
||||||
|
|
||||||
## Extension Checklist
|
## Extension Checklist
|
||||||
|
|
||||||
When adding or redesigning a UI area:
|
When adding or redesigning a UI area:
|
||||||
@@ -49,3 +57,10 @@ When adding or redesigning a UI area:
|
|||||||
3. Implement component styles in `frontend/src/styles.css` using existing layout and variant conventions.
|
3. Implement component styles in `frontend/src/styles.css` using existing layout and variant conventions.
|
||||||
4. Validate responsive behavior at `1240px`, `1040px`, `760px`, and `560px` breakpoints.
|
4. Validate responsive behavior at `1240px`, `1040px`, `760px`, and `560px` breakpoints.
|
||||||
5. Verify keyboard focus visibility and text contrast before merging.
|
5. Verify keyboard focus visibility and text contrast before merging.
|
||||||
|
|
||||||
|
## Settings UX Copy
|
||||||
|
|
||||||
|
- Keep helper copy in settings short and plain language, especially on advanced model and threshold controls.
|
||||||
|
- Prefer one concise hint per advanced control that explains practical impact rather than internals.
|
||||||
|
- In the Workspace settings block, keep processing-log controls visually separated from default path and tag behavior.
|
||||||
|
- Processing-log hints must explicitly state they affect logs and retention behavior, not document metadata values.
|
||||||
|
|||||||
@@ -97,11 +97,14 @@ Application-level settings managed from the UI are persisted by backend settings
|
|||||||
Settings include:
|
Settings include:
|
||||||
- upload defaults
|
- upload defaults
|
||||||
- display options
|
- display options
|
||||||
|
- processing-log retention options (`keep_document_sessions`, `keep_unbound_entries`)
|
||||||
- provider configuration
|
- provider configuration
|
||||||
- OCR, summary, and routing task settings
|
- OCR, summary, and routing task settings
|
||||||
- predefined paths and tags
|
- predefined paths and tags
|
||||||
- handwriting-style clustering settings
|
- handwriting-style clustering settings
|
||||||
|
|
||||||
|
Retention settings are used by worker cleanup and by `POST /api/v1/processing/logs/trim` when trim query values are not provided.
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
|
|
||||||
After operational or configuration changes, verify:
|
After operational or configuration changes, verify:
|
||||||
|
|||||||
@@ -6,18 +6,45 @@ import type { JSX } from 'react';
|
|||||||
|
|
||||||
import type { ProcessingLogEntry } from '../types';
|
import type { ProcessingLogEntry } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input contract for the processing timeline panel.
|
||||||
|
*/
|
||||||
interface ProcessingLogPanelProps {
|
interface ProcessingLogPanelProps {
|
||||||
|
/**
|
||||||
|
* Raw timeline entries returned by the API.
|
||||||
|
*/
|
||||||
entries: ProcessingLogEntry[];
|
entries: ProcessingLogEntry[];
|
||||||
|
/**
|
||||||
|
* Indicates that the parent screen is currently refreshing timeline data.
|
||||||
|
*/
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
/**
|
||||||
|
* Indicates that the timeline clear action is currently pending.
|
||||||
|
*/
|
||||||
isClearing: boolean;
|
isClearing: boolean;
|
||||||
|
/**
|
||||||
|
* Currently selected document ID used for header highlighting.
|
||||||
|
*/
|
||||||
selectedDocumentId: string | null;
|
selectedDocumentId: string | null;
|
||||||
|
/**
|
||||||
|
* Indicates whether processing work is currently active for the workspace.
|
||||||
|
*/
|
||||||
isProcessingActive: boolean;
|
isProcessingActive: boolean;
|
||||||
|
/**
|
||||||
|
* Enables typed header rendering for newly queued entries.
|
||||||
|
*/
|
||||||
typingAnimationEnabled: boolean;
|
typingAnimationEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Clears persisted processing logs.
|
||||||
|
*/
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders processing events in a terminal-style stream with optional typed headers.
|
* Renders processing events in a terminal-style stream with buffered sequential typing.
|
||||||
|
*
|
||||||
|
* New entries are queued and revealed one by one so batched timeline updates never render
|
||||||
|
* full headers before the animation for that entry has completed.
|
||||||
*/
|
*/
|
||||||
export default function ProcessingLogPanel({
|
export default function ProcessingLogPanel({
|
||||||
entries,
|
entries,
|
||||||
@@ -29,11 +56,15 @@ export default function ProcessingLogPanel({
|
|||||||
onClear,
|
onClear,
|
||||||
}: ProcessingLogPanelProps): JSX.Element {
|
}: ProcessingLogPanelProps): JSX.Element {
|
||||||
const timeline = useMemo(() => [...entries].reverse(), [entries]);
|
const timeline = useMemo(() => [...entries].reverse(), [entries]);
|
||||||
const [typedEntryIds, setTypedEntryIds] = useState<Set<number>>(() => new Set());
|
const timelineById = useMemo(() => new Map(timeline.map((entry) => [entry.id, entry])), [timeline]);
|
||||||
|
const [revealedEntryIds, setRevealedEntryIds] = useState<Set<number>>(() => new Set());
|
||||||
|
const [pendingEntryIds, setPendingEntryIds] = useState<number[]>([]);
|
||||||
const [typingEntryId, setTypingEntryId] = useState<number | null>(null);
|
const [typingEntryId, setTypingEntryId] = useState<number | null>(null);
|
||||||
const [typingHeader, setTypingHeader] = useState<string>('');
|
const [typingHeader, setTypingHeader] = useState<string>('');
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
|
const isBootstrapCompleteRef = useRef<boolean>(false);
|
||||||
|
const knownEntryIdsRef = useRef<Set<number>>(new Set());
|
||||||
|
|
||||||
const formatTimestamp = (value: string): string => {
|
const formatTimestamp = (value: string): string => {
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
@@ -63,28 +94,120 @@ export default function ProcessingLogPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const knownIds = new Set(typedEntryIds);
|
const timelineIdSet = new Set(timeline.map((entry) => entry.id));
|
||||||
if (typingEntryId !== null) {
|
|
||||||
knownIds.add(typingEntryId);
|
knownEntryIdsRef.current.forEach((entryId) => {
|
||||||
|
if (!timelineIdSet.has(entryId)) {
|
||||||
|
knownEntryIdsRef.current.delete(entryId);
|
||||||
}
|
}
|
||||||
const nextUntyped = timeline.find((entry) => !knownIds.has(entry.id));
|
});
|
||||||
if (!nextUntyped) {
|
|
||||||
|
setRevealedEntryIds((current) => {
|
||||||
|
const next = new Set<number>();
|
||||||
|
current.forEach((entryId) => {
|
||||||
|
if (timelineIdSet.has(entryId)) {
|
||||||
|
next.add(entryId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPendingEntryIds((current) => current.filter((entryId) => timelineIdSet.has(entryId)));
|
||||||
|
setExpandedIds((current) => {
|
||||||
|
const next = new Set<number>();
|
||||||
|
current.forEach((entryId) => {
|
||||||
|
if (timelineIdSet.has(entryId)) {
|
||||||
|
next.add(entryId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typingEntryId !== null && !timelineIdSet.has(typingEntryId)) {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setTypingEntryId(null);
|
||||||
|
setTypingHeader('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBootstrapCompleteRef.current) {
|
||||||
|
if (timeline.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!typingAnimationEnabled) {
|
isBootstrapCompleteRef.current = true;
|
||||||
setTypedEntryIds((current) => {
|
if (timeline.length > 1) {
|
||||||
|
timeline.forEach((entry) => {
|
||||||
|
knownEntryIdsRef.current.add(entry.id);
|
||||||
|
});
|
||||||
|
setRevealedEntryIds(new Set(timeline.map((entry) => entry.id)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveredEntryIds: number[] = [];
|
||||||
|
timeline.forEach((entry) => {
|
||||||
|
if (!knownEntryIdsRef.current.has(entry.id)) {
|
||||||
|
knownEntryIdsRef.current.add(entry.id);
|
||||||
|
discoveredEntryIds.push(entry.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (discoveredEntryIds.length > 0) {
|
||||||
|
setPendingEntryIds((current) => [...current, ...discoveredEntryIds]);
|
||||||
|
}
|
||||||
|
}, [timeline, typingEntryId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typingAnimationEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typingEntryId === null && pendingEntryIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setRevealedEntryIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
next.add(nextUntyped.id);
|
if (typingEntryId !== null) {
|
||||||
|
next.add(typingEntryId);
|
||||||
|
}
|
||||||
|
pendingEntryIds.forEach((entryId) => {
|
||||||
|
next.add(entryId);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setTypingEntryId(null);
|
||||||
|
setTypingHeader('');
|
||||||
|
setPendingEntryIds([]);
|
||||||
|
}, [pendingEntryIds, typingAnimationEnabled, typingEntryId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typingEntryId !== null || pendingEntryIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEntryId = pendingEntryIds[0];
|
||||||
|
setPendingEntryIds((current) => current.slice(1));
|
||||||
|
|
||||||
|
if (!typingAnimationEnabled) {
|
||||||
|
setRevealedEntryIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.add(nextEntryId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typingEntryId !== null) {
|
|
||||||
|
const nextEntry = timelineById.get(nextEntryId);
|
||||||
|
if (!nextEntry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullHeader = renderHeader(nextUntyped);
|
const fullHeader = renderHeader(nextEntry);
|
||||||
setTypingEntryId(nextUntyped.id);
|
setTypingEntryId(nextEntryId);
|
||||||
setTypingHeader('');
|
setTypingHeader('');
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
timerRef.current = window.setInterval(() => {
|
timerRef.current = window.setInterval(() => {
|
||||||
@@ -95,15 +218,16 @@ export default function ProcessingLogPanel({
|
|||||||
window.clearInterval(timerRef.current);
|
window.clearInterval(timerRef.current);
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
setTypedEntryIds((current) => {
|
setRevealedEntryIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
next.add(nextUntyped.id);
|
next.add(nextEntryId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setTypingEntryId(null);
|
setTypingEntryId(null);
|
||||||
|
setTypingHeader('');
|
||||||
}
|
}
|
||||||
}, 10);
|
}, 10);
|
||||||
}, [timeline, typedEntryIds, typingAnimationEnabled, typingEntryId]);
|
}, [pendingEntryIds, renderHeader, timelineById, typingAnimationEnabled, typingEntryId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -113,6 +237,14 @@ export default function ProcessingLogPanel({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const visibleTimeline = useMemo(() => {
|
||||||
|
const visibleEntryIds = new Set(revealedEntryIds);
|
||||||
|
if (typingEntryId !== null) {
|
||||||
|
visibleEntryIds.add(typingEntryId);
|
||||||
|
}
|
||||||
|
return timeline.filter((entry) => visibleEntryIds.has(entry.id));
|
||||||
|
}, [revealedEntryIds, timeline, typingEntryId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="processing-log-panel">
|
<section className="processing-log-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
@@ -127,12 +259,12 @@ export default function ProcessingLogPanel({
|
|||||||
<div className="processing-log-terminal-wrap">
|
<div className="processing-log-terminal-wrap">
|
||||||
<div className="processing-log-terminal">
|
<div className="processing-log-terminal">
|
||||||
{timeline.length === 0 && <p className="terminal-empty">No processing events yet.</p>}
|
{timeline.length === 0 && <p className="terminal-empty">No processing events yet.</p>}
|
||||||
{timeline.map((entry, index) => {
|
{visibleTimeline.map((entry, index) => {
|
||||||
const groupKey = `${entry.document_id ?? 'unbound'}:${entry.stage}`;
|
const groupKey = `${entry.document_id ?? 'unbound'}:${entry.stage}`;
|
||||||
const previousGroupKey = index > 0 ? `${timeline[index - 1].document_id ?? 'unbound'}:${timeline[index - 1].stage}` : null;
|
const previousGroupKey = index > 0 ? `${visibleTimeline[index - 1].document_id ?? 'unbound'}:${visibleTimeline[index - 1].stage}` : null;
|
||||||
const showSeparator = index > 0 && groupKey !== previousGroupKey;
|
const showSeparator = index > 0 && groupKey !== previousGroupKey;
|
||||||
const isTyping = entry.id === typingEntryId;
|
const isTyping = entry.id === typingEntryId;
|
||||||
const isTyped = typedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping);
|
const isTyped = revealedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping);
|
||||||
const isExpanded = expandedIds.has(entry.id);
|
const isExpanded = expandedIds.has(entry.id);
|
||||||
const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / ');
|
const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / ');
|
||||||
const hasDetails =
|
const hasDetails =
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Dedicated settings screen for providers, task model bindings, and catalog controls.
|
* Dedicated settings screen for providers, task model bindings, and catalog controls.
|
||||||
|
* Uses concise helper hints for advanced runtime and provider settings.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { JSX } from 'react';
|
import type { JSX } from 'react';
|
||||||
@@ -12,6 +13,7 @@ import type {
|
|||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
HandwritingStyleClusteringSettings,
|
HandwritingStyleClusteringSettings,
|
||||||
OcrTaskSettings,
|
OcrTaskSettings,
|
||||||
|
ProcessingLogRetentionSettings,
|
||||||
PredefinedPathEntry,
|
PredefinedPathEntry,
|
||||||
PredefinedTagEntry,
|
PredefinedTagEntry,
|
||||||
ProviderSettings,
|
ProviderSettings,
|
||||||
@@ -47,8 +49,26 @@ function parseCardsPerPageInput(input: string, fallback: number): number {
|
|||||||
return clampCardsPerPage(parsed);
|
return clampCardsPerPage(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROCESSING_LOG_RETENTION: ProcessingLogRetentionSettings = {
|
||||||
|
keep_document_sessions: 2,
|
||||||
|
keep_unbound_entries: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROCESSING_LOG_SESSION_MIN = 0;
|
||||||
|
const PROCESSING_LOG_SESSION_MAX = 20;
|
||||||
|
const PROCESSING_LOG_UNBOUND_MIN = 0;
|
||||||
|
const PROCESSING_LOG_UNBOUND_MAX = 400;
|
||||||
|
|
||||||
|
function clampProcessingLogDocumentSessions(value: number): number {
|
||||||
|
return Math.max(PROCESSING_LOG_SESSION_MIN, Math.min(PROCESSING_LOG_SESSION_MAX, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampProcessingLogUnboundEntries(value: number): number {
|
||||||
|
return Math.max(PROCESSING_LOG_UNBOUND_MIN, Math.min(PROCESSING_LOG_UNBOUND_MAX, value));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders compact human-oriented settings controls.
|
* Renders compact human-oriented settings controls with plain-language hints.
|
||||||
*/
|
*/
|
||||||
export default function SettingsScreen({
|
export default function SettingsScreen({
|
||||||
settings,
|
settings,
|
||||||
@@ -69,6 +89,7 @@ export default function SettingsScreen({
|
|||||||
const [newPredefinedTag, setNewPredefinedTag] = useState<string>('');
|
const [newPredefinedTag, setNewPredefinedTag] = useState<string>('');
|
||||||
const [uploadDefaults, setUploadDefaults] = useState<UploadDefaultsSettings | null>(null);
|
const [uploadDefaults, setUploadDefaults] = useState<UploadDefaultsSettings | null>(null);
|
||||||
const [displaySettings, setDisplaySettings] = useState<DisplaySettings | null>(null);
|
const [displaySettings, setDisplaySettings] = useState<DisplaySettings | null>(null);
|
||||||
|
const [processingLogRetention, setProcessingLogRetention] = useState<ProcessingLogRetentionSettings | null>(null);
|
||||||
const [cardsPerPageInput, setCardsPerPageInput] = useState<string>('12');
|
const [cardsPerPageInput, setCardsPerPageInput] = useState<string>('12');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -92,6 +113,15 @@ export default function SettingsScreen({
|
|||||||
setPredefinedTags(settings.predefined_tags);
|
setPredefinedTags(settings.predefined_tags);
|
||||||
setUploadDefaults(settings.upload_defaults);
|
setUploadDefaults(settings.upload_defaults);
|
||||||
setDisplaySettings(settings.display);
|
setDisplaySettings(settings.display);
|
||||||
|
setProcessingLogRetention({
|
||||||
|
keep_document_sessions: clampProcessingLogDocumentSessions(
|
||||||
|
settings.processing_log_retention?.keep_document_sessions ??
|
||||||
|
DEFAULT_PROCESSING_LOG_RETENTION.keep_document_sessions,
|
||||||
|
),
|
||||||
|
keep_unbound_entries: clampProcessingLogUnboundEntries(
|
||||||
|
settings.processing_log_retention?.keep_unbound_entries ?? DEFAULT_PROCESSING_LOG_RETENTION.keep_unbound_entries,
|
||||||
|
),
|
||||||
|
});
|
||||||
setCardsPerPageInput(String(settings.display.cards_per_page));
|
setCardsPerPageInput(String(settings.display.cards_per_page));
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
@@ -163,7 +193,15 @@ export default function SettingsScreen({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = useCallback(async (): Promise<void> => {
|
const handleSave = useCallback(async (): Promise<void> => {
|
||||||
if (!ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
if (
|
||||||
|
!ocrTask ||
|
||||||
|
!summaryTask ||
|
||||||
|
!routingTask ||
|
||||||
|
!handwritingStyle ||
|
||||||
|
!uploadDefaults ||
|
||||||
|
!displaySettings ||
|
||||||
|
!processingLogRetention
|
||||||
|
) {
|
||||||
setError('Settings are not fully loaded yet');
|
setError('Settings are not fully loaded yet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,7 +213,12 @@ export default function SettingsScreen({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const resolvedCardsPerPage = parseCardsPerPageInput(cardsPerPageInput, displaySettings.cards_per_page);
|
const resolvedCardsPerPage = parseCardsPerPageInput(cardsPerPageInput, displaySettings.cards_per_page);
|
||||||
|
const resolvedProcessingLogRetention: ProcessingLogRetentionSettings = {
|
||||||
|
keep_document_sessions: clampProcessingLogDocumentSessions(processingLogRetention.keep_document_sessions),
|
||||||
|
keep_unbound_entries: clampProcessingLogUnboundEntries(processingLogRetention.keep_unbound_entries),
|
||||||
|
};
|
||||||
setDisplaySettings({ ...displaySettings, cards_per_page: resolvedCardsPerPage });
|
setDisplaySettings({ ...displaySettings, cards_per_page: resolvedCardsPerPage });
|
||||||
|
setProcessingLogRetention(resolvedProcessingLogRetention);
|
||||||
setCardsPerPageInput(String(resolvedCardsPerPage));
|
setCardsPerPageInput(String(resolvedCardsPerPage));
|
||||||
|
|
||||||
await onSave({
|
await onSave({
|
||||||
@@ -187,6 +230,7 @@ export default function SettingsScreen({
|
|||||||
cards_per_page: resolvedCardsPerPage,
|
cards_per_page: resolvedCardsPerPage,
|
||||||
log_typing_animation_enabled: displaySettings.log_typing_animation_enabled,
|
log_typing_animation_enabled: displaySettings.log_typing_animation_enabled,
|
||||||
},
|
},
|
||||||
|
processing_log_retention: resolvedProcessingLogRetention,
|
||||||
predefined_paths: predefinedPaths,
|
predefined_paths: predefinedPaths,
|
||||||
predefined_tags: predefinedTags,
|
predefined_tags: predefinedTags,
|
||||||
handwriting_style_clustering: {
|
handwriting_style_clustering: {
|
||||||
@@ -252,21 +296,51 @@ export default function SettingsScreen({
|
|||||||
routingTask,
|
routingTask,
|
||||||
summaryTask,
|
summaryTask,
|
||||||
uploadDefaults,
|
uploadDefaults,
|
||||||
|
processingLogRetention,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onRegisterSaveAction) {
|
if (!onRegisterSaveAction) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
if (
|
||||||
|
!settings ||
|
||||||
|
!ocrTask ||
|
||||||
|
!summaryTask ||
|
||||||
|
!routingTask ||
|
||||||
|
!handwritingStyle ||
|
||||||
|
!uploadDefaults ||
|
||||||
|
!displaySettings ||
|
||||||
|
!processingLogRetention
|
||||||
|
) {
|
||||||
onRegisterSaveAction(null);
|
onRegisterSaveAction(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onRegisterSaveAction(() => handleSave());
|
onRegisterSaveAction(() => handleSave());
|
||||||
return () => onRegisterSaveAction(null);
|
return () => onRegisterSaveAction(null);
|
||||||
}, [displaySettings, handleSave, handwritingStyle, ocrTask, onRegisterSaveAction, routingTask, settings, summaryTask, uploadDefaults]);
|
}, [
|
||||||
|
displaySettings,
|
||||||
|
handleSave,
|
||||||
|
handwritingStyle,
|
||||||
|
ocrTask,
|
||||||
|
onRegisterSaveAction,
|
||||||
|
processingLogRetention,
|
||||||
|
routingTask,
|
||||||
|
settings,
|
||||||
|
summaryTask,
|
||||||
|
uploadDefaults,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
if (
|
||||||
|
!settings ||
|
||||||
|
!ocrTask ||
|
||||||
|
!summaryTask ||
|
||||||
|
!routingTask ||
|
||||||
|
!handwritingStyle ||
|
||||||
|
!uploadDefaults ||
|
||||||
|
!displaySettings ||
|
||||||
|
!processingLogRetention
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<section className="settings-layout">
|
<section className="settings-layout">
|
||||||
<div className="settings-card">
|
<div className="settings-card">
|
||||||
@@ -294,6 +368,7 @@ export default function SettingsScreen({
|
|||||||
onChange={(nextPath) => setUploadDefaults({ ...uploadDefaults, logical_path: nextPath })}
|
onChange={(nextPath) => setUploadDefaults({ ...uploadDefaults, logical_path: nextPath })}
|
||||||
suggestions={knownPaths}
|
suggestions={knownPaths}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">Used when you upload without choosing a path.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field settings-field-wide">
|
<label className="settings-field settings-field-wide">
|
||||||
Default Tags
|
Default Tags
|
||||||
@@ -302,6 +377,7 @@ export default function SettingsScreen({
|
|||||||
onChange={(nextTags) => setUploadDefaults({ ...uploadDefaults, tags: nextTags })}
|
onChange={(nextTags) => setUploadDefaults({ ...uploadDefaults, tags: nextTags })}
|
||||||
suggestions={knownTags}
|
suggestions={knownTags}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">Added automatically when no tags are selected.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Cards Per Page
|
Cards Per Page
|
||||||
@@ -312,8 +388,53 @@ export default function SettingsScreen({
|
|||||||
value={cardsPerPageInput}
|
value={cardsPerPageInput}
|
||||||
onChange={(event) => setCardsPerPageInput(event.target.value)}
|
onChange={(event) => setCardsPerPageInput(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">Controls how many documents you see at once.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-checkbox settings-checkbox-field">
|
<div className="settings-subsection-divider">
|
||||||
|
<h4>Processing Log Controls</h4>
|
||||||
|
<p className="small">
|
||||||
|
These settings affect processing logs only. They do not change default path, tags, or document cards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="settings-field">
|
||||||
|
Keep document sessions
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={PROCESSING_LOG_SESSION_MIN}
|
||||||
|
max={PROCESSING_LOG_SESSION_MAX}
|
||||||
|
value={processingLogRetention.keep_document_sessions}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = Number.parseInt(event.target.value, 10);
|
||||||
|
if (!Number.isNaN(nextValue)) {
|
||||||
|
setProcessingLogRetention({
|
||||||
|
...processingLogRetention,
|
||||||
|
keep_document_sessions: clampProcessingLogDocumentSessions(nextValue),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="settings-field-hint">How many recent log sessions to keep for each document.</span>
|
||||||
|
</label>
|
||||||
|
<label className="settings-field">
|
||||||
|
Keep unbound entries
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={PROCESSING_LOG_UNBOUND_MIN}
|
||||||
|
max={PROCESSING_LOG_UNBOUND_MAX}
|
||||||
|
value={processingLogRetention.keep_unbound_entries}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = Number.parseInt(event.target.value, 10);
|
||||||
|
if (!Number.isNaN(nextValue)) {
|
||||||
|
setProcessingLogRetention({
|
||||||
|
...processingLogRetention,
|
||||||
|
keep_unbound_entries: clampProcessingLogUnboundEntries(nextValue),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="settings-field-hint">How many standalone log entries to keep.</span>
|
||||||
|
</label>
|
||||||
|
<label className="inline-checkbox settings-checkbox-field settings-checkbox-with-hint">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={displaySettings.log_typing_animation_enabled}
|
checked={displaySettings.log_typing_animation_enabled}
|
||||||
@@ -321,8 +442,14 @@ export default function SettingsScreen({
|
|||||||
setDisplaySettings({ ...displaySettings, log_typing_animation_enabled: event.target.checked })
|
setDisplaySettings({ ...displaySettings, log_typing_animation_enabled: event.target.checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-checkbox-copy">
|
||||||
Processing log typing animation enabled
|
Processing log typing animation enabled
|
||||||
|
<span className="settings-field-hint">Shows new log text as a type-in animation.</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<p className="small settings-helper-text">
|
||||||
|
Processing-log retention values are used by backend trim routines when pruning historical entries.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -453,6 +580,7 @@ export default function SettingsScreen({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">Task settings use this ID to select the provider.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Label
|
Label
|
||||||
@@ -484,6 +612,7 @@ export default function SettingsScreen({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">Stop waiting after this many seconds if a call hangs.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field settings-field-wide">
|
<label className="settings-field settings-field-wide">
|
||||||
Base URL
|
Base URL
|
||||||
@@ -497,6 +626,7 @@ export default function SettingsScreen({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">API endpoint root for this provider.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field settings-field-wide">
|
<label className="settings-field settings-field-wide">
|
||||||
API Key
|
API Key
|
||||||
@@ -512,6 +642,7 @@ export default function SettingsScreen({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">Leave blank to keep the current stored key.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-checkbox settings-checkbox-field">
|
<label className="inline-checkbox settings-checkbox-field">
|
||||||
<input
|
<input
|
||||||
@@ -566,10 +697,12 @@ export default function SettingsScreen({
|
|||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Model
|
Model
|
||||||
<input value={ocrTask.model} onChange={(event) => setOcrTask({ ...ocrTask, model: event.target.value })} />
|
<input value={ocrTask.model} onChange={(event) => setOcrTask({ ...ocrTask, model: event.target.value })} />
|
||||||
|
<span className="settings-field-hint">Model name sent to the selected provider.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field settings-field-wide">
|
<label className="settings-field settings-field-wide">
|
||||||
OCR Prompt
|
OCR Prompt
|
||||||
<textarea value={ocrTask.prompt} onChange={(event) => setOcrTask({ ...ocrTask, prompt: event.target.value })} />
|
<textarea value={ocrTask.prompt} onChange={(event) => setOcrTask({ ...ocrTask, prompt: event.target.value })} />
|
||||||
|
<span className="settings-field-hint">Instructions used when reading handwriting text.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -596,6 +729,7 @@ export default function SettingsScreen({
|
|||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Model
|
Model
|
||||||
<input value={summaryTask.model} onChange={(event) => setSummaryTask({ ...summaryTask, model: event.target.value })} />
|
<input value={summaryTask.model} onChange={(event) => setSummaryTask({ ...summaryTask, model: event.target.value })} />
|
||||||
|
<span className="settings-field-hint">Model name sent to the selected provider.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Max Input Tokens
|
Max Input Tokens
|
||||||
@@ -611,10 +745,12 @@ export default function SettingsScreen({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<span className="settings-field-hint">Long inputs are trimmed to this size before summarizing.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field settings-field-wide">
|
<label className="settings-field settings-field-wide">
|
||||||
Summary Prompt
|
Summary Prompt
|
||||||
<textarea value={summaryTask.prompt} onChange={(event) => setSummaryTask({ ...summaryTask, prompt: event.target.value })} />
|
<textarea value={summaryTask.prompt} onChange={(event) => setSummaryTask({ ...summaryTask, prompt: event.target.value })} />
|
||||||
|
<span className="settings-field-hint">Instructions that shape the generated summary.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,42 +777,56 @@ export default function SettingsScreen({
|
|||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Model
|
Model
|
||||||
<input value={routingTask.model} onChange={(event) => setRoutingTask({ ...routingTask, model: event.target.value })} />
|
<input value={routingTask.model} onChange={(event) => setRoutingTask({ ...routingTask, model: event.target.value })} />
|
||||||
|
<span className="settings-field-hint">Model name sent to the selected provider.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Neighbor Count
|
Neighbor Count
|
||||||
<input type="number" value={routingTask.neighbor_count} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_count: Number.parseInt(event.target.value, 10) || routingTask.neighbor_count })} />
|
<input type="number" value={routingTask.neighbor_count} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_count: Number.parseInt(event.target.value, 10) || routingTask.neighbor_count })} />
|
||||||
|
<span className="settings-field-hint">How many close matches to compare before routing.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Min Neighbor Similarity
|
Min Neighbor Similarity
|
||||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_min_similarity })} />
|
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_min_similarity })} />
|
||||||
|
<span className="settings-field-hint">Ignore neighbors below this match score.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Auto Apply Confidence
|
Auto Apply Confidence
|
||||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_confidence_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_confidence_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_confidence_threshold })} />
|
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_confidence_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_confidence_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_confidence_threshold })} />
|
||||||
|
<span className="settings-field-hint">Minimum model confidence for automatic changes.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Auto Apply Neighbor Similarity
|
Auto Apply Neighbor Similarity
|
||||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_neighbor_similarity_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_neighbor_similarity_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_neighbor_similarity_threshold })} />
|
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_neighbor_similarity_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_neighbor_similarity_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_neighbor_similarity_threshold })} />
|
||||||
|
<span className="settings-field-hint">Minimum neighbor score for automatic changes.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-checkbox settings-checkbox-field">
|
<label className="inline-checkbox settings-checkbox-field settings-checkbox-with-hint">
|
||||||
<input type="checkbox" checked={routingTask.neighbor_path_override_enabled} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_enabled: event.target.checked })} />
|
<input type="checkbox" checked={routingTask.neighbor_path_override_enabled} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_enabled: event.target.checked })} />
|
||||||
|
<span className="settings-checkbox-copy">
|
||||||
Dominant neighbor path override enabled
|
Dominant neighbor path override enabled
|
||||||
|
<span className="settings-field-hint">
|
||||||
|
If a strong top match disagrees with the model, use the top match path instead.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Override Min Similarity
|
Override Min Similarity
|
||||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_similarity })} />
|
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_similarity })} />
|
||||||
|
<span className="settings-field-hint">Top neighbor must reach this score to override.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Override Min Gap
|
Override Min Gap
|
||||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_gap} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_gap: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_gap })} />
|
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_gap} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_gap: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_gap })} />
|
||||||
|
<span className="settings-field-hint">Top match must beat the second match by at least this gap.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Override Max LLM Confidence
|
Override Max LLM Confidence
|
||||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_max_confidence} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_max_confidence: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_max_confidence })} />
|
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_max_confidence} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_max_confidence: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_max_confidence })} />
|
||||||
|
<span className="settings-field-hint">Override only runs when model confidence is at or below this level.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field settings-field-wide">
|
<label className="settings-field settings-field-wide">
|
||||||
Routing Prompt
|
Routing Prompt
|
||||||
<textarea value={routingTask.prompt} onChange={(event) => setRoutingTask({ ...routingTask, prompt: event.target.value })} />
|
<textarea value={routingTask.prompt} onChange={(event) => setRoutingTask({ ...routingTask, prompt: event.target.value })} />
|
||||||
|
<span className="settings-field-hint">Instructions used when deciding document path and tags.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -693,26 +843,32 @@ export default function SettingsScreen({
|
|||||||
<label className="settings-field settings-field-wide">
|
<label className="settings-field settings-field-wide">
|
||||||
Typesense Embedding Model Slug
|
Typesense Embedding Model Slug
|
||||||
<input value={handwritingStyle.embed_model} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, embed_model: event.target.value })} />
|
<input value={handwritingStyle.embed_model} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, embed_model: event.target.value })} />
|
||||||
|
<span className="settings-field-hint">Embedding model used to compare handwriting style similarity.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Neighbor Limit
|
Neighbor Limit
|
||||||
<input type="number" min={1} max={32} value={handwritingStyle.neighbor_limit} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, neighbor_limit: Number.parseInt(event.target.value, 10) || handwritingStyle.neighbor_limit })} />
|
<input type="number" min={1} max={32} value={handwritingStyle.neighbor_limit} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, neighbor_limit: Number.parseInt(event.target.value, 10) || handwritingStyle.neighbor_limit })} />
|
||||||
|
<span className="settings-field-hint">How many nearby samples to check during matching.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Match Min Similarity
|
Match Min Similarity
|
||||||
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.match_min_similarity })} />
|
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.match_min_similarity })} />
|
||||||
|
<span className="settings-field-hint">Minimum similarity needed to treat two styles as a match.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Bootstrap Match Min Similarity
|
Bootstrap Match Min Similarity
|
||||||
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.bootstrap_match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.bootstrap_match_min_similarity })} />
|
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.bootstrap_match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.bootstrap_match_min_similarity })} />
|
||||||
|
<span className="settings-field-hint">Stricter match score used only while bootstrapping new clusters.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Bootstrap Sample Size
|
Bootstrap Sample Size
|
||||||
<input type="number" min={1} max={30} value={handwritingStyle.bootstrap_sample_size} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_sample_size: Number.parseInt(event.target.value, 10) || handwritingStyle.bootstrap_sample_size })} />
|
<input type="number" min={1} max={30} value={handwritingStyle.bootstrap_sample_size} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_sample_size: Number.parseInt(event.target.value, 10) || handwritingStyle.bootstrap_sample_size })} />
|
||||||
|
<span className="settings-field-hint">Number of samples used to start each new style cluster.</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
Max Image Side (px)
|
Max Image Side (px)
|
||||||
<input type="number" min={256} max={4096} value={handwritingStyle.image_max_side} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, image_max_side: Number.parseInt(event.target.value, 10) || handwritingStyle.image_max_side })} />
|
<input type="number" min={256} max={4096} value={handwritingStyle.image_max_side} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, image_max_side: Number.parseInt(event.target.value, 10) || handwritingStyle.image_max_side })} />
|
||||||
|
<span className="settings-field-hint">Resizes large images to this limit before analysis.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input:not([type='checkbox']):not([type='radio']),
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -86,24 +86,39 @@ textarea {
|
|||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder,
|
input:not([type='checkbox']):not([type='radio'])::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
color: #72819e;
|
color: #72819e;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:hover,
|
input:not([type='checkbox']):not([type='radio']):hover,
|
||||||
select:hover,
|
select:hover,
|
||||||
textarea:hover {
|
textarea:hover {
|
||||||
border-color: var(--color-border-strong);
|
border-color: var(--color-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:not([type='checkbox']):not([type='radio']):focus,
|
||||||
select:focus,
|
select:focus,
|
||||||
textarea:focus {
|
textarea:focus {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 2px rgba(63, 141, 255, 0.2);
|
box-shadow: 0 0 0 2px rgba(63, 141, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'],
|
||||||
|
input[type='radio'] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:focus-visible,
|
||||||
|
input[type='radio']:focus-visible {
|
||||||
|
outline: 2px solid rgba(63, 141, 255, 0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
padding-right: 1.9rem;
|
padding-right: 1.9rem;
|
||||||
@@ -961,23 +976,69 @@ button:disabled {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-field-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #95a6c4;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-subsection-divider {
|
||||||
|
grid-column: span 12;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding-top: 0.2rem;
|
||||||
|
border-top: 1px solid rgba(70, 89, 122, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-subsection-divider h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-subsection-divider p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-checkbox {
|
.inline-checkbox {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
|
min-height: 1.95rem;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
border: 1px solid rgba(70, 89, 122, 0.55);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: rgba(18, 27, 41, 0.62);
|
||||||
font-size: 0.79rem;
|
font-size: 0.79rem;
|
||||||
color: #cad7ed;
|
color: #cad7ed;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-checkbox input {
|
.inline-checkbox input {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-checkbox input:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-toggle {
|
.settings-toggle {
|
||||||
color: #dbe8ff;
|
color: #dbe8ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-checkbox-with-hint {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-checkbox-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-catalog-grid {
|
.settings-catalog-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -176,6 +176,14 @@ export interface DisplaySettings {
|
|||||||
log_typing_animation_enabled: boolean;
|
log_typing_animation_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents retention targets used when trimming persisted processing logs.
|
||||||
|
*/
|
||||||
|
export interface ProcessingLogRetentionSettings {
|
||||||
|
keep_document_sessions: number;
|
||||||
|
keep_unbound_entries: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents one predefined logical path and discoverability scope.
|
* Represents one predefined logical path and discoverability scope.
|
||||||
*/
|
*/
|
||||||
@@ -220,6 +228,7 @@ export interface TaskSettings {
|
|||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
upload_defaults: UploadDefaultsSettings;
|
upload_defaults: UploadDefaultsSettings;
|
||||||
display: DisplaySettings;
|
display: DisplaySettings;
|
||||||
|
processing_log_retention: ProcessingLogRetentionSettings;
|
||||||
handwriting_style_clustering: HandwritingStyleClusteringSettings;
|
handwriting_style_clustering: HandwritingStyleClusteringSettings;
|
||||||
predefined_paths: PredefinedPathEntry[];
|
predefined_paths: PredefinedPathEntry[];
|
||||||
predefined_tags: PredefinedTagEntry[];
|
predefined_tags: PredefinedTagEntry[];
|
||||||
@@ -265,6 +274,14 @@ export interface DisplaySettingsUpdate {
|
|||||||
log_typing_animation_enabled?: boolean;
|
log_typing_animation_enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents processing-log retention update payload.
|
||||||
|
*/
|
||||||
|
export interface ProcessingLogRetentionSettingsUpdate {
|
||||||
|
keep_document_sessions?: number;
|
||||||
|
keep_unbound_entries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents handwriting-style clustering settings update payload.
|
* Represents handwriting-style clustering settings update payload.
|
||||||
*/
|
*/
|
||||||
@@ -284,6 +301,7 @@ export interface HandwritingStyleClusteringSettingsUpdate {
|
|||||||
export interface AppSettingsUpdate {
|
export interface AppSettingsUpdate {
|
||||||
upload_defaults?: UploadDefaultsSettingsUpdate;
|
upload_defaults?: UploadDefaultsSettingsUpdate;
|
||||||
display?: DisplaySettingsUpdate;
|
display?: DisplaySettingsUpdate;
|
||||||
|
processing_log_retention?: ProcessingLogRetentionSettingsUpdate;
|
||||||
handwriting_style_clustering?: HandwritingStyleClusteringSettingsUpdate;
|
handwriting_style_clustering?: HandwritingStyleClusteringSettingsUpdate;
|
||||||
predefined_paths?: PredefinedPathEntry[];
|
predefined_paths?: PredefinedPathEntry[];
|
||||||
predefined_tags?: PredefinedTagEntry[];
|
predefined_tags?: PredefinedTagEntry[];
|
||||||
|
|||||||
Reference in New Issue
Block a user