Add ability to edit safe .env.example options to Settings
Quality Gate / gate (push) Failing after 47s

This commit is contained in:
Jim Lancaster
2026-08-24 16:14:49 -05:00
parent 96af7b2dc0
commit c0112c2714
6 changed files with 630 additions and 4 deletions
+75 -1
View File
@@ -21,6 +21,8 @@ from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.registry import render_registry_table
from transcription.ui.homepage_store import read_homepage_markdown
from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.runtime_settings_store import read_runtime_settings_snapshot
from transcription.ui.runtime_settings_store import save_runtime_settings
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -497,14 +499,78 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
with ui.row().classes("items-center gap-2"):
ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary")
@ui.refreshable
async def render_runtime_settings() -> None:
with archival_card("Runtime Settings"):
ui.label("Edit non-secret .env settings. Secret fields are intentionally excluded.").classes(
"text-xs ui-text-muted"
)
ui.label("Changes are persisted to .env and apply after restart.").classes(
"text-xs ui-text-muted mb-3"
)
snapshot_outcome = await run_ui_action(
operation="settings.runtime.read",
title="Runtime settings unavailable",
action=lambda: _read_runtime_settings_snapshot(settings),
)
if not snapshot_outcome.ok or snapshot_outcome.value is None:
return
snapshot = snapshot_outcome.value
field_controls: dict[str, Any] = {}
for field in snapshot.fields:
with ui.column().classes("w-full gap-1 py-2 ui-header-divider"):
ui.label(field.label).classes("text-sm font-semibold")
ui.label(field.description).classes("text-xs ui-text-muted")
if field.control == "bool":
control = ui.checkbox(field.env_key, value=bool(field.value))
elif field.control == "select":
control = (
ui.select(
list(field.options),
label=field.env_key,
value=str(field.value),
)
.props("outlined")
.classes("w-full")
)
else:
control = (
ui.input(field.env_key, value=str(field.value)).props("outlined").classes("w-full")
)
field_controls[field.field_name] = control
async def save_runtime() -> None:
updates: dict[str, str | bool] = {}
for field in snapshot.fields:
control = field_controls[field.field_name]
if field.control == "bool":
updates[field.field_name] = bool(control.value)
else:
updates[field.field_name] = str(control.value or "")
save_outcome = await run_ui_action(
operation="settings.runtime.write",
title="Runtime settings save failed",
action=lambda: _write_runtime_settings(settings=settings, updates=updates),
)
if not save_outcome.ok:
return
ui.notify("Runtime settings saved to .env", type="positive")
render_runtime_settings.refresh()
with ui.row().classes("items-center gap-2 mt-3"):
ui.button("Save runtime settings", icon="save", on_click=save_runtime).classes("ui-btn-primary")
with ui.tabs().classes("w-full") as tabs:
runtime_settings_tab = ui.tab("Runtime Settings")
document_types_tab = ui.tab("Document Types")
person_roles_tab = ui.tab("Person Roles")
tags_tab = ui.tab("Tags")
prompts_tab = ui.tab("Prompts")
home_page_text_tab = ui.tab("Home Page Text")
with ui.tab_panels(tabs, value=document_types_tab).classes("w-full"):
with ui.tab_panels(tabs, value=runtime_settings_tab).classes("w-full"):
with ui.tab_panel(runtime_settings_tab):
await render_runtime_settings()
with ui.tab_panel(document_types_tab):
await render_document_types()
with ui.tab_panel(person_roles_tab):
@@ -546,3 +612,11 @@ async def _read_home_page_text(settings: Settings) -> str:
async def _write_home_page_text(settings: Settings, markdown_text: str) -> None:
await run_blocking(save_homepage_markdown, markdown_text, settings=settings)
async def _read_runtime_settings_snapshot(settings: Settings):
return await run_blocking(read_runtime_settings_snapshot, settings=settings)
async def _write_runtime_settings(*, settings: Settings, updates: dict[str, str | bool]):
return await run_blocking(save_runtime_settings, settings=settings, updates=updates)
@@ -0,0 +1,465 @@
"""Safe runtime settings catalog and .env persistence helpers."""
from __future__ import annotations
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from pydantic import ValidationError
from transcription.config import Provider
from transcription.config import Settings
from transcription.errors import AppError
from transcription.errors import ErrorCategory
FieldControl = Literal["text", "bool", "select"]
@dataclass(frozen=True, slots=True)
class RuntimeSettingField:
"""UI metadata and value for a safe, editable runtime setting."""
field_name: str
env_key: str
label: str
description: str
control: FieldControl
value: str | bool
options: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class RuntimeSettingsSnapshot:
"""Typed snapshot of runtime settings editable in UI."""
env_file_path: Path
fields: tuple[RuntimeSettingField, ...]
@dataclass(frozen=True, slots=True)
class RuntimeSettingDescriptor:
field_name: str
env_key: str
label: str
description: str
control: FieldControl
options: tuple[str, ...] = ()
SETTINGS_UI_EXCLUDED_FIELDS = frozenset(
{
"openrouter_api_key",
"database",
}
)
RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = (
RuntimeSettingDescriptor("host", "HOST", "Host", "Server bind host.", "text"),
RuntimeSettingDescriptor("port", "PORT", "Port", "Server bind port.", "text"),
RuntimeSettingDescriptor(
"log_level",
"LOG_LEVEL",
"Log level",
"Application log verbosity.",
"select",
options=("critical", "error", "warning", "info", "debug", "trace"),
),
RuntimeSettingDescriptor("reload", "RELOAD", "Reload", "Auto-reload on code changes.", "bool"),
RuntimeSettingDescriptor("log_dir", "LOG_DIR", "Log directory", "Directory for rotating log files.", "text"),
RuntimeSettingDescriptor(
"log_file_name",
"LOG_FILE_NAME",
"Log file name",
"Active rotating log filename.",
"text",
),
RuntimeSettingDescriptor(
"log_file_max_bytes",
"LOG_FILE_MAX_BYTES",
"Log file max bytes",
"Maximum bytes per log file before rotation.",
"text",
),
RuntimeSettingDescriptor(
"log_file_backup_count",
"LOG_FILE_BACKUP_COUNT",
"Log file backup count",
"Number of rotated log files to keep.",
"text",
),
RuntimeSettingDescriptor(
"provider",
"PROVIDER",
"Provider",
"Transcription provider key.",
"select",
options=tuple(member.value for member in Provider),
),
RuntimeSettingDescriptor(
"provider_model",
"PROVIDER_MODEL",
"Provider model",
"Default model id used for transcription jobs.",
"text",
),
RuntimeSettingDescriptor(
"provider_models",
"PROVIDER_MODELS",
"Provider models (JSON array)",
"Allowed model ids as a JSON array.",
"text",
),
RuntimeSettingDescriptor(
"openrouter_http_referer",
"OPENROUTER_HTTP_REFERER",
"OpenRouter HTTP referer",
"Optional header value sent to OpenRouter.",
"text",
),
RuntimeSettingDescriptor(
"openrouter_app_title",
"OPENROUTER_APP_TITLE",
"OpenRouter app title",
"Optional app title sent to OpenRouter.",
"text",
),
RuntimeSettingDescriptor(
"default_prompt_name",
"DEFAULT_PROMPT_NAME",
"Default prompt file",
"Prompt filename used for new transcription requests.",
"text",
),
RuntimeSettingDescriptor(
"transcription_temperature",
"TRANSCRIPTION_TEMPERATURE",
"Temperature",
"Optional sampling temperature (0.0 to 2.0).",
"text",
),
RuntimeSettingDescriptor(
"transcription_top_p",
"TRANSCRIPTION_TOP_P",
"Top P",
"Optional nucleus sampling probability (0.0 to 1.0).",
"text",
),
RuntimeSettingDescriptor(
"environment",
"ENVIRONMENT",
"Environment",
"Runtime environment mode.",
"select",
options=("development", "test", "production"),
),
RuntimeSettingDescriptor(
"transcription_commit",
"TRANSCRIPTION_COMMIT",
"Transcription commit",
"Optional build or commit identifier for evidence provenance.",
"text",
),
RuntimeSettingDescriptor(
"bootstrap_schema_on_startup",
"BOOTSTRAP_SCHEMA_ON_STARTUP",
"Bootstrap schema on startup",
"Create schema on startup when enabled.",
"bool",
),
RuntimeSettingDescriptor(
"sqlite_check_same_thread",
"SQLITE_CHECK_SAME_THREAD",
"SQLite check same thread",
"SQLite engine same-thread flag.",
"bool",
),
RuntimeSettingDescriptor(
"upload_dir",
"UPLOAD_DIR",
"Upload directory",
"Root directory for uploaded media and generated files.",
"text",
),
RuntimeSettingDescriptor(
"prompt_dir",
"PROMPT_DIR",
"Prompt directory",
"Directory containing editable markdown prompts.",
"text",
),
RuntimeSettingDescriptor(
"database_backup_dir",
"DATABASE_BACKUP_DIR",
"Database backup directory",
"Directory for generated database backups.",
"text",
),
RuntimeSettingDescriptor(
"worker_max_retries",
"WORKER_MAX_RETRIES",
"Worker max retries",
"Maximum retries per source attempt.",
"text",
),
RuntimeSettingDescriptor(
"worker_provider_timeout_seconds",
"WORKER_PROVIDER_TIMEOUT_SECONDS",
"Worker provider timeout seconds",
"Provider call timeout budget in seconds.",
"text",
),
RuntimeSettingDescriptor(
"worker_stale_job_seconds",
"WORKER_STALE_JOB_SECONDS",
"Worker stale job seconds",
"Seconds before queued jobs are considered stale.",
"text",
),
RuntimeSettingDescriptor(
"worker_retry_backoff_seconds",
"WORKER_RETRY_BACKOFF_SECONDS",
"Worker retry backoff seconds",
"Base backoff delay between retries.",
"text",
),
RuntimeSettingDescriptor(
"worker_shutdown_grace_seconds",
"WORKER_SHUTDOWN_GRACE_SECONDS",
"Worker shutdown grace seconds",
"Grace period before forced worker shutdown.",
"text",
),
RuntimeSettingDescriptor(
"worker_poll_interval_seconds",
"WORKER_POLL_INTERVAL_SECONDS",
"Worker poll interval seconds",
"Worker polling interval for queued jobs.",
"text",
),
RuntimeSettingDescriptor(
"worker_min_transcription_chars",
"WORKER_MIN_TRANSCRIPTION_CHARS",
"Worker min transcription chars",
"Minimum character threshold for successful transcription.",
"text",
),
RuntimeSettingDescriptor(
"worker_min_transcription_lines",
"WORKER_MIN_TRANSCRIPTION_LINES",
"Worker min transcription lines",
"Minimum line threshold for successful transcription.",
"text",
),
RuntimeSettingDescriptor(
"worker_fail_on_finish_reason_length",
"WORKER_FAIL_ON_FINISH_REASON_LENGTH",
"Fail on finish reason length",
"Treat provider finish_reason=length as failure when enabled.",
"bool",
),
)
def settings_catalog_field_names() -> frozenset[str]:
return frozenset(item.field_name for item in RUNTIME_SETTINGS_CATALOG)
def read_runtime_settings_snapshot(*, settings: Settings, env_file_path: Path | None = None) -> RuntimeSettingsSnapshot:
resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path)
fields: list[RuntimeSettingField] = []
for descriptor in RUNTIME_SETTINGS_CATALOG:
value = getattr(settings, descriptor.field_name)
fields.append(
RuntimeSettingField(
field_name=descriptor.field_name,
env_key=descriptor.env_key,
label=descriptor.label,
description=descriptor.description,
control=descriptor.control,
value=_display_value(value),
options=descriptor.options,
)
)
return RuntimeSettingsSnapshot(
env_file_path=resolved_env_path,
fields=tuple(fields),
)
def save_runtime_settings(
*,
settings: Settings,
updates: dict[str, str | bool],
env_file_path: Path | None = None,
) -> RuntimeSettingsSnapshot:
descriptors = {item.field_name: item for item in RUNTIME_SETTINGS_CATALOG}
unknown_keys = sorted(set(updates) - set(descriptors))
if unknown_keys:
raise AppError(
"One or more settings fields cannot be edited from this page.",
category=ErrorCategory.VALIDATION,
suggestion="Refresh the page and submit only editable fields.",
detail=f"Unknown settings keys: {unknown_keys}",
)
resolved_env_path = _resolve_env_file_path(settings=settings, env_file_path=env_file_path)
original_lines = _read_env_lines(resolved_env_path)
env_map = _parse_env_map(original_lines)
mutable_lines = list(original_lines)
for field_name, raw_value in updates.items():
descriptor = descriptors[field_name]
encoded = _encode_field_value(raw_value)
if encoded == "":
_delete_env_key(mutable_lines, descriptor.env_key)
env_map.pop(descriptor.env_key, None)
continue
_upsert_env_key(mutable_lines, descriptor.env_key, encoded)
env_map[descriptor.env_key] = encoded
_validate_candidate_env(env_map=env_map, env_file_path=resolved_env_path)
_write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines)
refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False)
return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path)
def _resolve_env_file_path(*, settings: Settings, env_file_path: Path | None) -> Path:
if env_file_path is not None:
return env_file_path
configured = settings.model_config.get("env_file")
if configured is None:
return Path(".env")
if isinstance(configured, Path):
return Path(configured)
if isinstance(configured, str):
return Path(configured)
if isinstance(configured, (list, tuple)) and configured:
first = configured[0]
if isinstance(first, Path):
return Path(first)
if isinstance(first, str):
return Path(first)
return Path(".env")
def _display_value(value: object) -> str | bool:
if isinstance(value, bool):
return value
if value is None:
return ""
if isinstance(value, tuple):
values = [str(item) for item in value]
escaped = ",".join(f'"{_escape_json_string(item)}"' for item in values)
return f"[{escaped}]"
if isinstance(value, Path):
return value.as_posix()
if isinstance(value, Provider):
return value.value
return str(value)
def _encode_field_value(raw_value: str | bool) -> str:
if isinstance(raw_value, bool):
return "true" if raw_value else "false"
text = str(raw_value).strip()
if text == "":
return ""
if text.startswith("[") and text.endswith("]"):
return text
if " " in text or "#" in text:
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return text
def _validate_candidate_env(*, env_map: dict[str, str], env_file_path: Path) -> None:
serialized = "\n".join(f"{key}={value}" for key, value in env_map.items()) + "\n"
with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8", newline="\n", suffix=".env") as handle:
temp_path = Path(handle.name)
handle.write(serialized)
try:
Settings(_env_file=temp_path, _cli_parse_args=False)
except ValidationError as exc:
raise AppError(
"One or more settings values are invalid.",
category=ErrorCategory.VALIDATION,
suggestion="Review the highlighted values and use the same formats shown in .env.example.",
detail=f"Invalid runtime settings for {env_file_path}: {exc}",
) from exc
finally:
temp_path.unlink(missing_ok=True)
def _read_env_lines(path: Path) -> list[str]:
if not path.exists():
return []
return path.read_text(encoding="utf-8").splitlines()
def _parse_env_map(lines: list[str]) -> dict[str, str]:
env_map: dict[str, str] = {}
for line in lines:
parsed = _parse_env_line(line)
if parsed is None:
continue
key, value = parsed
env_map[key] = value
return env_map
def _parse_env_line(line: str) -> tuple[str, str] | None:
if not line or line.lstrip().startswith("#"):
return None
match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*(?:__[A-Za-z0-9_]+)*)\s*=\s*(.*)$", line)
if not match:
return None
return match.group(1), match.group(2)
def _delete_env_key(lines: list[str], key: str) -> None:
for index, line in enumerate(lines):
parsed = _parse_env_line(line)
if parsed is None:
continue
current_key, _ = parsed
if current_key == key:
del lines[index]
return
def _upsert_env_key(lines: list[str], key: str, value: str) -> None:
for index, line in enumerate(lines):
parsed = _parse_env_line(line)
if parsed is None:
continue
current_key, _ = parsed
if current_key == key:
lines[index] = f"{key}={value}"
return
lines.append(f"{key}={value}")
def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
content = "\n".join(lines).rstrip("\n")
if content:
content += "\n"
with tempfile.NamedTemporaryFile(
mode="w",
delete=False,
dir=path.parent,
encoding="utf-8",
newline="\n",
suffix=".env.tmp",
) as handle:
temp_path = Path(handle.name)
handle.write(content)
temp_path.replace(path)
def _escape_json_string(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')