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 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:
- 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
## Core Capabilities
## 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/`)
- Frontend: React + Vite + TypeScript (`frontend/`)
- Infrastructure: Postgres, Redis, Typesense (`docker-compose.yml`)
## Technology Stack
- 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
- Docker Engine
- Docker Compose plugin
- Internet access for the first image build
- Internet access for first-time image build
## Quick Start
1. Start the full stack from repository root:
From repository root:
```bash
docker compose up --build -d
```
2. Open services:
Open:
- Frontend: `http://localhost:5173`
- Backend OpenAPI docs: `http://localhost:8000/docs`
- Health endpoint: `http://localhost:8000/api/v1/health`
- API docs: `http://localhost:8000/docs`
- Health: `http://localhost:8000/api/v1/health`
3. Stop when done:
Stop the stack:
```bash
docker compose down
```
## Common Commands
## Common Operations
Start services:
Start or rebuild:
```bash
docker compose up --build -d
```
Stop services:
Stop:
```bash
docker compose down
```
Stream logs:
Tail logs:
```bash
docker compose logs -f
```
Rebuild services:
Tail API and worker logs only:
```bash
docker compose down
docker compose up --build -d
docker compose logs -f api worker
```
Reset runtime data (destructive, removes named volumes):
Reset all runtime data (destructive):
```bash
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
Runtime state is persisted in Docker named volumes declared in `docker-compose.yml`:
Docker named volumes used by the stack:
- `db-data`
- `redis-data`
- `dcm-storage`
- `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"}`
- upload and processing complete successfully
- search returns expected results
- preview or download works for uploaded documents
- `docker compose logs -f` shows no API or worker failures
- Upload and processing complete successfully
- Search returns expected results
- Preview and download work for uploaded documents
- `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`
- Documents: `/documents` (listing, upload, metadata update, lifecycle actions, download and preview, markdown export)
- Search: `/search`
- Processing logs: `/processing/logs`
- Settings: `/settings` and `/settings/handwriting`
## Documentation Index
See `doc/api-contract.md` for the complete endpoint contract.
## Technical Documentation
- `doc/README.md` - technical documentation index
- `doc/README.md` - technical documentation entrypoint
- `doc/architecture-overview.md` - service and runtime architecture
- `doc/api-contract.md` - HTTP endpoint contract and payload model map
- `doc/data-model-reference.md` - database model reference
- `doc/operations-and-configuration.md` - operations runbook and configuration reference
- `doc/frontend-design-foundation.md` - frontend design system and UI rules
- `doc/api-contract.md` - endpoint and payload contract
- `doc/data-model-reference.md` - persistence model reference
- `doc/operations-and-configuration.md` - runtime operations and configuration
- `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.schemas.processing_logs import ProcessingLogEntryResponse, ProcessingLogListResponse
from app.services.app_settings import read_processing_log_retention_settings
from app.services.processing_logs import (
cleanup_processing_logs,
clear_processing_logs,
@@ -42,16 +43,28 @@ def get_processing_logs(
@router.post("/trim")
def trim_processing_logs(
keep_document_sessions: int = Query(default=2, ge=0, le=20),
keep_unbound_entries: int = Query(default=80, ge=0, le=400),
keep_document_sessions: int | None = Query(default=None, ge=0, le=20),
keep_unbound_entries: int | None = Query(default=None, ge=0, le=400),
session: Session = Depends(get_session),
) -> 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(
session=session,
keep_document_sessions=keep_document_sessions,
keep_unbound_entries=keep_unbound_entries,
keep_document_sessions=resolved_keep_document_sessions,
keep_unbound_entries=resolved_keep_unbound_entries,
)
session.commit()
return result

View File

@@ -10,6 +10,7 @@ from app.schemas.settings import (
HandwritingStyleSettingsResponse,
HandwritingSettingsUpdateRequest,
OcrTaskSettingsResponse,
ProcessingLogRetentionSettingsResponse,
ProviderSettingsResponse,
RoutingTaskSettingsResponse,
SummaryTaskSettingsResponse,
@@ -35,6 +36,7 @@ def _build_response(payload: dict) -> AppSettingsResponse:
upload_defaults_payload = payload.get("upload_defaults", {})
display_payload = payload.get("display", {})
processing_log_retention_payload = payload.get("processing_log_retention", {})
providers_payload = payload.get("providers", [])
tasks_payload = payload.get("tasks", {})
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)),
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(
enabled=bool(handwriting_style_payload.get("enabled", True)),
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:
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
if payload.handwriting_style_clustering is not None:
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,
upload_defaults=upload_defaults_payload,
display=display_payload,
processing_log_retention=processing_log_retention_payload,
handwriting_style=handwriting_style_payload,
predefined_paths=predefined_paths_payload,
predefined_tags=predefined_tags_payload,

View File

@@ -127,6 +127,20 @@ class DisplaySettingsUpdateRequest(BaseModel):
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):
"""Represents one predefined logical path with global discoverability scope."""
@@ -200,6 +214,7 @@ class AppSettingsResponse(BaseModel):
upload_defaults: UploadDefaultsResponse
display: DisplaySettingsResponse
processing_log_retention: ProcessingLogRetentionSettingsResponse
handwriting_style_clustering: HandwritingStyleSettingsResponse
predefined_paths: list[PredefinedPathEntryResponse] = Field(default_factory=list)
predefined_tags: list[PredefinedTagEntryResponse] = Field(default_factory=list)
@@ -212,6 +227,7 @@ class AppSettingsUpdateRequest(BaseModel):
upload_defaults: UploadDefaultsUpdateRequest | None = None
display: DisplaySettingsUpdateRequest | None = None
processing_log_retention: ProcessingLogRetentionSettingsUpdateRequest | None = None
handwriting_style_clustering: HandwritingStyleSettingsUpdateRequest | None = None
predefined_paths: list[PredefinedPathEntryUpdateRequest] | 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_ROUTING_CLASSIFICATION = "routing_classification"
HANDWRITING_STYLE_SETTINGS_KEY = "handwriting_style_clustering"
PROCESSING_LOG_RETENTION_SETTINGS_KEY = "processing_log_retention"
PREDEFINED_PATHS_SETTINGS_KEY = "predefined_paths"
PREDEFINED_TAGS_SETTINGS_KEY = "predefined_tags"
DEFAULT_HANDWRITING_STYLE_EMBED_MODEL = "ts/clip-vit-b-p32"
@@ -65,6 +66,10 @@ def _default_settings() -> dict[str, Any]:
"cards_per_page": 12,
"log_typing_animation_enabled": True,
},
PROCESSING_LOG_RETENTION_SETTINGS_KEY: {
"keep_document_sessions": 2,
"keep_unbound_entries": 80,
},
PREDEFINED_PATHS_SETTINGS_KEY: [],
PREDEFINED_TAGS_SETTINGS_KEY: [],
HANDWRITING_STYLE_SETTINGS_KEY: {
@@ -148,6 +153,18 @@ def _clamp_cards_per_page(value: int) -> int:
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:
"""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(
payload: Any,
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)
upload_defaults = _normalize_upload_defaults(payload.get("upload_defaults", {}), defaults["upload_defaults"])
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(
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 {
"upload_defaults": upload_defaults,
"display": display_settings,
PROCESSING_LOG_RETENTION_SETTINGS_KEY: processing_log_retention,
PREDEFINED_PATHS_SETTINGS_KEY: predefined_paths,
PREDEFINED_TAGS_SETTINGS_KEY: predefined_tags,
HANDWRITING_STYLE_SETTINGS_KEY: handwriting_style_settings,
@@ -645,6 +689,10 @@ def read_app_settings() -> dict[str, Any]:
return {
"upload_defaults": payload.get("upload_defaults", {"logical_path": "Inbox", "tags": []}),
"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_TAGS_SETTINGS_KEY: payload.get(PREDEFINED_TAGS_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,
upload_defaults: 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,
predefined_paths: list[dict[str, Any]] | None = None,
predefined_tags: list[dict[str, Any]] | None = None,
) -> 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()
next_payload: dict[str, Any] = {
"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})),
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_TAGS_SETTINGS_KEY: list(current_payload.get(PREDEFINED_TAGS_SETTINGS_KEY, [])),
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_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):
next_handwriting_style = dict(next_payload.get(HANDWRITING_STYLE_SETTINGS_KEY, {}))
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]]:
"""Returns normalized predefined logical path catalog entries."""

View File

@@ -5,10 +5,15 @@ from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.base import SessionLocal
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 (
IMAGE_EXTENSIONS,
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
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(
parent: Document,
member_name: str,
@@ -204,7 +220,7 @@ def process_document_task(document_id: str) -> None:
document=document,
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()
for child_id in child_ids:
queue.enqueue("app.worker.tasks.process_document_task", child_id)
@@ -239,7 +255,7 @@ def process_document_task(document_id: str) -> None:
document=document,
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()
return
@@ -330,7 +346,7 @@ def process_document_task(document_id: str) -> None:
document=document,
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()
return
@@ -362,7 +378,7 @@ def process_document_task(document_id: str) -> None:
document=document,
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()
return
@@ -540,5 +556,5 @@ def process_document_task(document_id: str) -> None:
document=document,
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()

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
- `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
- `operations-and-configuration.md` - runtime operations, ports, volumes, and configuration values
- `frontend-design-foundation.md` - frontend visual system, tokens, and UI implementation rules
- `operations-and-configuration.md` - runtime operations, ports, volumes, and persisted settings configuration
- `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`
- Response model: `ProcessingLogListResponse`
- `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
- `POST /processing/logs/clear`
- Response: clear counters
@@ -127,4 +128,4 @@ Processing log schemas in `backend/app/schemas/processing_logs.py`:
- `ProcessingLogListResponse`
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
- 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.
- 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.
@@ -41,6 +42,13 @@ Do not hardcode new palette or spacing values in component styles when a token a
- Keep transitions brief and functional.
- 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
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.
4. Validate responsive behavior at `1240px`, `1040px`, `760px`, and `560px` breakpoints.
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:
- upload defaults
- display options
- processing-log retention options (`keep_document_sessions`, `keep_unbound_entries`)
- provider configuration
- OCR, summary, and routing task settings
- predefined paths and tags
- 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
After operational or configuration changes, verify:

View File

@@ -6,18 +6,45 @@ import type { JSX } from 'react';
import type { ProcessingLogEntry } from '../types';
/**
* Input contract for the processing timeline panel.
*/
interface ProcessingLogPanelProps {
/**
* Raw timeline entries returned by the API.
*/
entries: ProcessingLogEntry[];
/**
* Indicates that the parent screen is currently refreshing timeline data.
*/
isLoading: boolean;
/**
* Indicates that the timeline clear action is currently pending.
*/
isClearing: boolean;
/**
* Currently selected document ID used for header highlighting.
*/
selectedDocumentId: string | null;
/**
* Indicates whether processing work is currently active for the workspace.
*/
isProcessingActive: boolean;
/**
* Enables typed header rendering for newly queued entries.
*/
typingAnimationEnabled: boolean;
/**
* Clears persisted processing logs.
*/
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({
entries,
@@ -29,11 +56,15 @@ export default function ProcessingLogPanel({
onClear,
}: ProcessingLogPanelProps): JSX.Element {
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 [typingHeader, setTypingHeader] = useState<string>('');
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
const timerRef = useRef<number | null>(null);
const isBootstrapCompleteRef = useRef<boolean>(false);
const knownEntryIdsRef = useRef<Set<number>>(new Set());
const formatTimestamp = (value: string): string => {
const parsed = new Date(value);
@@ -63,28 +94,120 @@ export default function ProcessingLogPanel({
};
useEffect(() => {
const knownIds = new Set(typedEntryIds);
if (typingEntryId !== null) {
knownIds.add(typingEntryId);
const timelineIdSet = new Set(timeline.map((entry) => entry.id));
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;
}
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) {
setTypedEntryIds((current) => {
setRevealedEntryIds((current) => {
const next = new Set(current);
next.add(nextUntyped.id);
next.add(nextEntryId);
return next;
});
return;
}
if (typingEntryId !== null) {
const nextEntry = timelineById.get(nextEntryId);
if (!nextEntry) {
return;
}
const fullHeader = renderHeader(nextUntyped);
setTypingEntryId(nextUntyped.id);
const fullHeader = renderHeader(nextEntry);
setTypingEntryId(nextEntryId);
setTypingHeader('');
let cursor = 0;
timerRef.current = window.setInterval(() => {
@@ -95,15 +218,16 @@ export default function ProcessingLogPanel({
window.clearInterval(timerRef.current);
timerRef.current = null;
}
setTypedEntryIds((current) => {
setRevealedEntryIds((current) => {
const next = new Set(current);
next.add(nextUntyped.id);
next.add(nextEntryId);
return next;
});
setTypingEntryId(null);
setTypingHeader('');
}
}, 10);
}, [timeline, typedEntryIds, typingAnimationEnabled, typingEntryId]);
}, [pendingEntryIds, renderHeader, timelineById, typingAnimationEnabled, typingEntryId]);
useEffect(() => {
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 (
<section className="processing-log-panel">
<div className="panel-header">
@@ -127,12 +259,12 @@ export default function ProcessingLogPanel({
<div className="processing-log-terminal-wrap">
<div className="processing-log-terminal">
{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 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 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 providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / ');
const hasDetails =

View File

@@ -1,5 +1,6 @@
/**
* 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 type { JSX } from 'react';
@@ -12,6 +13,7 @@ import type {
DisplaySettings,
HandwritingStyleClusteringSettings,
OcrTaskSettings,
ProcessingLogRetentionSettings,
PredefinedPathEntry,
PredefinedTagEntry,
ProviderSettings,
@@ -47,8 +49,26 @@ function parseCardsPerPageInput(input: string, fallback: number): number {
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({
settings,
@@ -69,6 +89,7 @@ export default function SettingsScreen({
const [newPredefinedTag, setNewPredefinedTag] = useState<string>('');
const [uploadDefaults, setUploadDefaults] = useState<UploadDefaultsSettings | null>(null);
const [displaySettings, setDisplaySettings] = useState<DisplaySettings | null>(null);
const [processingLogRetention, setProcessingLogRetention] = useState<ProcessingLogRetentionSettings | null>(null);
const [cardsPerPageInput, setCardsPerPageInput] = useState<string>('12');
const [error, setError] = useState<string | null>(null);
@@ -92,6 +113,15 @@ export default function SettingsScreen({
setPredefinedTags(settings.predefined_tags);
setUploadDefaults(settings.upload_defaults);
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));
setError(null);
}, [settings]);
@@ -163,7 +193,15 @@ export default function SettingsScreen({
};
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');
return;
}
@@ -175,7 +213,12 @@ export default function SettingsScreen({
setError(null);
try {
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 });
setProcessingLogRetention(resolvedProcessingLogRetention);
setCardsPerPageInput(String(resolvedCardsPerPage));
await onSave({
@@ -187,6 +230,7 @@ export default function SettingsScreen({
cards_per_page: resolvedCardsPerPage,
log_typing_animation_enabled: displaySettings.log_typing_animation_enabled,
},
processing_log_retention: resolvedProcessingLogRetention,
predefined_paths: predefinedPaths,
predefined_tags: predefinedTags,
handwriting_style_clustering: {
@@ -252,21 +296,51 @@ export default function SettingsScreen({
routingTask,
summaryTask,
uploadDefaults,
processingLogRetention,
]);
useEffect(() => {
if (!onRegisterSaveAction) {
return;
}
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
if (
!settings ||
!ocrTask ||
!summaryTask ||
!routingTask ||
!handwritingStyle ||
!uploadDefaults ||
!displaySettings ||
!processingLogRetention
) {
onRegisterSaveAction(null);
return;
}
onRegisterSaveAction(() => handleSave());
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 (
<section className="settings-layout">
<div className="settings-card">
@@ -294,6 +368,7 @@ export default function SettingsScreen({
onChange={(nextPath) => setUploadDefaults({ ...uploadDefaults, logical_path: nextPath })}
suggestions={knownPaths}
/>
<span className="settings-field-hint">Used when you upload without choosing a path.</span>
</label>
<label className="settings-field settings-field-wide">
Default Tags
@@ -302,6 +377,7 @@ export default function SettingsScreen({
onChange={(nextTags) => setUploadDefaults({ ...uploadDefaults, tags: nextTags })}
suggestions={knownTags}
/>
<span className="settings-field-hint">Added automatically when no tags are selected.</span>
</label>
<label className="settings-field">
Cards Per Page
@@ -312,8 +388,53 @@ export default function SettingsScreen({
value={cardsPerPageInput}
onChange={(event) => setCardsPerPageInput(event.target.value)}
/>
<span className="settings-field-hint">Controls how many documents you see at once.</span>
</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
type="checkbox"
checked={displaySettings.log_typing_animation_enabled}
@@ -321,8 +442,14 @@ export default function SettingsScreen({
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>
<p className="small settings-helper-text">
Processing-log retention values are used by backend trim routines when pruning historical entries.
</p>
</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 className="settings-field">
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 className="settings-field settings-field-wide">
Base URL
@@ -497,6 +626,7 @@ export default function SettingsScreen({
)
}
/>
<span className="settings-field-hint">API endpoint root for this provider.</span>
</label>
<label className="settings-field settings-field-wide">
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 className="inline-checkbox settings-checkbox-field">
<input
@@ -566,10 +697,12 @@ export default function SettingsScreen({
<label className="settings-field">
Model
<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 className="settings-field settings-field-wide">
OCR Prompt
<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>
</div>
</div>
@@ -596,6 +729,7 @@ export default function SettingsScreen({
<label className="settings-field">
Model
<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 className="settings-field">
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 className="settings-field settings-field-wide">
Summary Prompt
<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>
</div>
</div>
@@ -641,42 +777,56 @@ export default function SettingsScreen({
<label className="settings-field">
Model
<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 className="settings-field">
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 className="settings-field">
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 })} />
<span className="settings-field-hint">Ignore neighbors below this match score.</span>
</label>
<label className="settings-field">
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 })} />
<span className="settings-field-hint">Minimum model confidence for automatic changes.</span>
</label>
<label className="settings-field">
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 })} />
<span className="settings-field-hint">Minimum neighbor score for automatic changes.</span>
</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 })} />
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 className="settings-field">
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 className="settings-field">
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 className="settings-field">
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 })} />
<span className="settings-field-hint">Override only runs when model confidence is at or below this level.</span>
</label>
<label className="settings-field settings-field-wide">
Routing Prompt
<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>
</div>
</div>
@@ -693,26 +843,32 @@ export default function SettingsScreen({
<label className="settings-field settings-field-wide">
Typesense Embedding Model Slug
<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 className="settings-field">
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 className="settings-field">
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 className="settings-field">
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 className="settings-field">
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 className="settings-field">
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 })} />
<span className="settings-field-hint">Resizes large images to this limit before analysis.</span>
</label>
</div>
</div>

View File

@@ -74,7 +74,7 @@
min-height: 2rem;
}
input,
input:not([type='checkbox']):not([type='radio']),
select,
textarea {
width: 100%;
@@ -86,24 +86,39 @@ textarea {
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 {
color: #72819e;
}
input:hover,
input:not([type='checkbox']):not([type='radio']):hover,
select:hover,
textarea:hover {
border-color: var(--color-border-strong);
}
input:focus,
input:not([type='checkbox']):not([type='radio']):focus,
select:focus,
textarea:focus {
border-color: var(--color-accent);
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 {
appearance: none;
padding-right: 1.9rem;
@@ -961,23 +976,69 @@ button:disabled {
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 {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
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;
color: #cad7ed;
cursor: pointer;
}
.inline-checkbox input {
margin: 0;
flex-shrink: 0;
}
.inline-checkbox input:disabled {
cursor: not-allowed;
}
.settings-toggle {
color: #dbe8ff;
}
.settings-checkbox-with-hint {
align-items: start;
}
.settings-checkbox-copy {
display: grid;
gap: 0.15rem;
}
.settings-catalog-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -176,6 +176,14 @@ export interface DisplaySettings {
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.
*/
@@ -220,6 +228,7 @@ export interface TaskSettings {
export interface AppSettings {
upload_defaults: UploadDefaultsSettings;
display: DisplaySettings;
processing_log_retention: ProcessingLogRetentionSettings;
handwriting_style_clustering: HandwritingStyleClusteringSettings;
predefined_paths: PredefinedPathEntry[];
predefined_tags: PredefinedTagEntry[];
@@ -265,6 +274,14 @@ export interface DisplaySettingsUpdate {
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.
*/
@@ -284,6 +301,7 @@ export interface HandwritingStyleClusteringSettingsUpdate {
export interface AppSettingsUpdate {
upload_defaults?: UploadDefaultsSettingsUpdate;
display?: DisplaySettingsUpdate;
processing_log_retention?: ProcessingLogRetentionSettingsUpdate;
handwriting_style_clustering?: HandwritingStyleClusteringSettingsUpdate;
predefined_paths?: PredefinedPathEntry[];
predefined_tags?: PredefinedTagEntry[];