Compare commits

...

5 Commits

16 changed files with 807 additions and 104 deletions

20
CHANGELOG.md Normal file
View 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
View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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."""

View File

@@ -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()

View 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()

View File

@@ -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

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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:

View File

@@ -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);
}
});
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('');
} }
const nextUntyped = timeline.find((entry) => !knownIds.has(entry.id));
if (!nextUntyped) { if (!isBootstrapCompleteRef.current) {
if (timeline.length === 0) {
return;
}
isBootstrapCompleteRef.current = true;
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; 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);
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) { if (!typingAnimationEnabled) {
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;
}); });
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 =

View File

@@ -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 })
} }
/> />
Processing log typing animation enabled <span className="settings-checkbox-copy">
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 })} />
Dominant neighbor path override enabled <span className="settings-checkbox-copy">
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>

View File

@@ -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));

View File

@@ -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[];