2fc7f8ddd2
Introduce `display_name` field to `User` struct for human-readable names in the UI. This falls back to the user's `slug` if not provided. Update `AuthenticatedWebUser` and `MyCasesTemplate` to use this new field for rendering. Add tests for parsing `display_name` and ensure fallback logic works correctly. Update `users.toml.example` to document the new field.
552 lines
18 KiB
Rust
552 lines
18 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// Per-user retention settings from users.toml.
|
|
///
|
|
/// `auto_close_days = 0` disables the auto-close sweep (a case is never
|
|
/// closed for age alone). `auto_delete_days = 0` disables the auto-purge
|
|
/// sweep (closed cases stay forever until manual purge) — important for
|
|
/// admin / dev setups where data must survive across restarts.
|
|
#[derive(Debug, Default, Clone, Deserialize)]
|
|
pub struct RetentionUserSettings {
|
|
#[serde(default)]
|
|
pub auto_close_days: u32,
|
|
#[serde(default)]
|
|
pub auto_delete_days: u32,
|
|
}
|
|
|
|
/// Default time window (hours) of visible cases when the user entry
|
|
/// omits `window_hours`. Chosen so Monday morning still shows Friday's
|
|
/// unfinished work. Server-authoritative: clients never override this.
|
|
fn default_window_hours() -> u32 {
|
|
72
|
|
}
|
|
|
|
/// Default number of analysis-preview lines rendered under each case on
|
|
/// the list view when the user entry omits `preview_lines`. Two lines
|
|
/// show the first sentence of the LLM output in most cases without
|
|
/// dominating the list row.
|
|
fn default_preview_lines() -> u32 {
|
|
2
|
|
}
|
|
|
|
/// A single user entry from users.toml.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct User {
|
|
pub slug: String,
|
|
pub api_key: String,
|
|
pub web_password: String,
|
|
pub role: String,
|
|
/// Optional human-readable display name shown in the web UI in
|
|
/// place of the slug (e.g. "Dr. Müller"). Falls back to `slug`
|
|
/// when omitted — see [`User::display_name`].
|
|
#[serde(default)]
|
|
pub display_name: Option<String>,
|
|
/// ASR input language code (e.g. "de", "en"). Forwarded as the
|
|
/// `language` form/query field to the active ASR backend
|
|
/// (Whisper or Canary). Missing field → backend default ("de").
|
|
#[serde(default)]
|
|
pub language: Option<String>,
|
|
#[serde(default)]
|
|
pub retention: RetentionUserSettings,
|
|
/// Time window (hours) that defines which cases are visible to this
|
|
/// user's clients. Server-authoritative: the `/api/oneliners` scan
|
|
/// filters recordings older than `now - window_hours` before they
|
|
/// reach any client. Clients render what the server returns and
|
|
/// apply no additional time filter.
|
|
#[serde(default = "default_window_hours")]
|
|
pub window_hours: u32,
|
|
/// Number of analysis-preview lines rendered under each case row on
|
|
/// the list view. The preview itself is always the first paragraph of
|
|
/// `document.md`; this value only controls how many *visual* lines
|
|
/// the browser will show before truncating with `…`. Applied via a
|
|
/// CSS `line-clamp` custom property, so a single server value adapts
|
|
/// to any viewport width.
|
|
#[serde(default = "default_preview_lines")]
|
|
pub preview_lines: u32,
|
|
}
|
|
|
|
impl User {
|
|
/// Borrow the human-readable display name for this user. Returns
|
|
/// `display_name` when set, otherwise falls back to `slug`. Used
|
|
/// by web handlers to render the page header without the caller
|
|
/// having to repeat the fallback logic.
|
|
pub fn display_name(&self) -> &str {
|
|
self.display_name.as_deref().unwrap_or(&self.slug)
|
|
}
|
|
}
|
|
|
|
/// Wrapper for deserializing the [[user]] array from TOML.
|
|
#[derive(Deserialize)]
|
|
struct UsersFile {
|
|
user: Vec<User>,
|
|
}
|
|
|
|
/// Active ASR backend, picked once at server startup from the
|
|
/// `ASR_BACKEND` env var. `Whisper` (default) routes the worker to the
|
|
/// `whisper-asr-webservice` at `[whisper].url`; `Canary` routes it to
|
|
/// the NeMo Canary container at `[canary].url`. Switching backends
|
|
/// requires a server restart — both cannot run on the same GPU at
|
|
/// once (12 GB VRAM budget; see docs/canary.md §2.6).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum AsrBackend {
|
|
#[default]
|
|
Whisper,
|
|
Canary,
|
|
}
|
|
|
|
impl std::str::FromStr for AsrBackend {
|
|
type Err = String;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
"whisper" => Ok(Self::Whisper),
|
|
"canary" => Ok(Self::Canary),
|
|
other => Err(format!("expected 'whisper' or 'canary', got '{other}'")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Bootstrap config loaded once from `.env` at startup. Runtime-tunable
|
|
/// values (retention, whisper/ollama/llm endpoints, prompts) live in
|
|
/// `Settings` (loaded from `settings.toml`).
|
|
pub struct Config {
|
|
// Phase 1 — required
|
|
pub server_port: u16,
|
|
pub data_path: PathBuf,
|
|
pub log_level: String,
|
|
pub log_path: PathBuf,
|
|
pub log_max_days: u32,
|
|
pub users: Vec<User>,
|
|
pub api_keys: HashMap<String, String>, // api_key_value → slug
|
|
|
|
// Phase 2+ — optional with defaults
|
|
/// Directory containing gazetteer `*.txt` files (anatomy, substances,
|
|
/// medications). Loaded once at startup; a missing directory is a
|
|
/// non-fatal WARN — analysis runs without proper-name correction.
|
|
pub vocab_dir: PathBuf,
|
|
/// Path prefix (without extension) to a hunspell dictionary pair
|
|
/// `<stem>.aff` + `<stem>.dic`. Used by the gazetteer as a dict
|
|
/// veto against false-positive rewrites (e.g. `Kaktus` → `Lantus`).
|
|
/// Missing / unreadable files are a non-fatal WARN — the gazetteer
|
|
/// falls back to dict-less behaviour.
|
|
pub hunspell_dict_path: PathBuf,
|
|
pub session_timeout_hours: u32,
|
|
/// Whether to set the `Secure` flag on the session cookie. Default `true`.
|
|
/// Set `COOKIE_SECURE=false` only for plain-HTTP local development —
|
|
/// browsers refuse `Secure` cookies on non-HTTPS origins.
|
|
pub cookie_secure: bool,
|
|
/// Selected ASR backend (Whisper or Canary). Read from `ASR_BACKEND`
|
|
/// env var at startup; default `Whisper`. Worker uses this to dispatch
|
|
/// the transcription call to the matching endpoint.
|
|
pub asr_backend: AsrBackend,
|
|
}
|
|
|
|
impl Config {
|
|
pub fn from_env() -> Self {
|
|
let users_file = required_env("USERS_FILE");
|
|
let (users, api_keys) = load_users(&users_file);
|
|
|
|
Self {
|
|
server_port: required_env_parsed("SERVER_PORT"),
|
|
data_path: PathBuf::from(required_env("DATA_PATH")),
|
|
log_level: optional_env("LOG_LEVEL", "info"),
|
|
log_path: PathBuf::from(optional_env("LOG_PATH", "/var/log/recorder")),
|
|
log_max_days: optional_env_parsed("LOG_MAX_DAYS", 90),
|
|
users,
|
|
api_keys,
|
|
|
|
vocab_dir: PathBuf::from(optional_env("VOCAB_DIR", "./vocab")),
|
|
hunspell_dict_path: PathBuf::from(optional_env(
|
|
"HUNSPELL_DICT",
|
|
"/usr/share/hunspell/de_DE",
|
|
)),
|
|
session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8),
|
|
cookie_secure: optional_env_parsed("COOKIE_SECURE", true),
|
|
asr_backend: optional_env("ASR_BACKEND", "whisper")
|
|
.parse::<AsrBackend>()
|
|
.unwrap_or_else(|e| panic!("Invalid value for ASR_BACKEND: {e}")),
|
|
}
|
|
}
|
|
|
|
/// Sane defaults for integration tests. Not a `Default` impl on purpose:
|
|
/// production code must go through `from_env()`, and `Default::default()`
|
|
/// carries an implicit "safe fallback" connotation this value does not
|
|
/// satisfy (empty users, `/tmp` paths). Override fields via
|
|
/// struct-update syntax:
|
|
///
|
|
/// ```ignore
|
|
/// let cfg = Config {
|
|
/// data_path: tmp_dir,
|
|
/// users: vec![my_user],
|
|
/// ..Config::test_default()
|
|
/// };
|
|
/// ```
|
|
#[doc(hidden)]
|
|
pub fn test_default() -> Self {
|
|
Self {
|
|
server_port: 3000,
|
|
data_path: PathBuf::from("/tmp"),
|
|
log_level: "info".into(),
|
|
log_path: PathBuf::from("/tmp"),
|
|
log_max_days: 90,
|
|
users: Vec::new(),
|
|
api_keys: HashMap::new(),
|
|
vocab_dir: PathBuf::new(),
|
|
hunspell_dict_path: PathBuf::new(),
|
|
session_timeout_hours: 8,
|
|
cookie_secure: false,
|
|
asr_backend: AsrBackend::Whisper,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Load users from a TOML file and build the API key lookup map.
|
|
fn load_users(path: &str) -> (Vec<User>, HashMap<String, String>) {
|
|
let content = std::fs::read_to_string(path)
|
|
.unwrap_or_else(|e| panic!("Failed to read users file {path}: {e}"));
|
|
|
|
let parsed: UsersFile = toml::from_str(&content)
|
|
.unwrap_or_else(|e| panic!("Failed to parse users file {path}: {e}"));
|
|
|
|
if parsed.user.is_empty() {
|
|
panic!("No users defined in {path}. At least one is required.");
|
|
}
|
|
|
|
let api_keys = validate_and_index_users(&parsed.user)
|
|
.unwrap_or_else(|e| panic!("Invalid users file {path}: {e}"));
|
|
|
|
(parsed.user, api_keys)
|
|
}
|
|
|
|
/// Build the api_key → slug lookup map. Rejects malformed slugs (would
|
|
/// silently fail at request time when no matching data directory exists),
|
|
/// duplicate slugs (collide on data directories) and duplicate api_keys
|
|
/// (ambiguous authentication).
|
|
pub fn validate_and_index_users(users: &[User]) -> Result<HashMap<String, String>, String> {
|
|
let mut slugs: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
let mut api_keys: HashMap<String, String> = HashMap::new();
|
|
|
|
for u in users {
|
|
if let Err(why) = crate::validate::validate_slug(&u.slug) {
|
|
return Err(format!("invalid slug `{}`: {why}", u.slug));
|
|
}
|
|
if !slugs.insert(u.slug.as_str()) {
|
|
return Err(format!("duplicate slug: {}", u.slug));
|
|
}
|
|
if let Some(existing) = api_keys.insert(u.api_key.clone(), u.slug.clone()) {
|
|
return Err(format!(
|
|
"duplicate api_key shared by users {existing} and {}",
|
|
u.slug
|
|
));
|
|
}
|
|
}
|
|
|
|
Ok(api_keys)
|
|
}
|
|
|
|
/// Read a required environment variable. Panics with a clear message if missing.
|
|
fn required_env(name: &str) -> String {
|
|
std::env::var(name).unwrap_or_else(|_| panic!("Missing required environment variable: {name}"))
|
|
}
|
|
|
|
/// Read a required environment variable and parse it. Panics if missing or unparseable.
|
|
fn required_env_parsed<T: std::str::FromStr>(name: &str) -> T
|
|
where
|
|
T::Err: std::fmt::Display,
|
|
{
|
|
let raw = required_env(name);
|
|
raw.parse()
|
|
.unwrap_or_else(|e| panic!("Invalid value for {name}: {e}"))
|
|
}
|
|
|
|
/// Read an optional environment variable, falling back to a default.
|
|
fn optional_env(name: &str, default: &str) -> String {
|
|
std::env::var(name).unwrap_or_else(|_| default.to_owned())
|
|
}
|
|
|
|
/// Read an optional environment variable and parse it, falling back to a default.
|
|
fn optional_env_parsed<T: std::str::FromStr>(name: &str, default: T) -> T
|
|
where
|
|
T::Err: std::fmt::Display,
|
|
{
|
|
match std::env::var(name) {
|
|
Ok(raw) => raw
|
|
.parse()
|
|
.unwrap_or_else(|e| panic!("Invalid value for {name}: {e}")),
|
|
Err(_) => default,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn u(slug: &str, api_key: &str) -> User {
|
|
User {
|
|
slug: slug.into(),
|
|
api_key: api_key.into(),
|
|
web_password: String::new(),
|
|
role: "doctor".into(),
|
|
display_name: None,
|
|
language: None,
|
|
retention: RetentionUserSettings::default(),
|
|
window_hours: default_window_hours(),
|
|
preview_lines: default_preview_lines(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_without_language() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let u = &parsed.user[0];
|
|
assert!(u.language.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_without_retention_block_defaults_to_zero() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let r = &parsed.user[0].retention;
|
|
// Both zero means "feature disabled", matching the pre-migration
|
|
// status quo — no case is ever auto-closed or auto-purged.
|
|
assert_eq!(r.auto_close_days, 0);
|
|
assert_eq!(r.auto_delete_days, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_retention_block() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
[user.retention]
|
|
auto_close_days = 7
|
|
auto_delete_days = 30
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let r = &parsed.user[0].retention;
|
|
assert_eq!(r.auto_close_days, 7);
|
|
assert_eq!(r.auto_delete_days, 30);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_without_preview_lines_defaults_to_2() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(parsed.user[0].preview_lines, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_explicit_preview_lines() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
preview_lines = 5
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(parsed.user[0].preview_lines, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_partial_retention_block() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
[user.retention]
|
|
auto_close_days = 5
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let r = &parsed.user[0].retention;
|
|
assert_eq!(r.auto_close_days, 5);
|
|
// Missing field defaults to 0 = disabled — lets admins test auto-close
|
|
// in isolation without losing data to auto-purge.
|
|
assert_eq!(r.auto_delete_days, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_explicit_language() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
language = "de"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(parsed.user[0].language.as_deref(), Some("de"));
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_admin_role() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "root"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "admin"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(parsed.user[0].role, "admin");
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_distinct_users() {
|
|
let users = vec![u("a", "k1"), u("b", "k2")];
|
|
let map = validate_and_index_users(&users).unwrap();
|
|
assert_eq!(map.len(), 2);
|
|
assert_eq!(map.get("k1").unwrap(), "a");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_api_keys() {
|
|
let users = vec![u("a", "same"), u("b", "same")];
|
|
let err = validate_and_index_users(&users).unwrap_err();
|
|
assert!(err.contains("duplicate api_key"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_duplicate_slugs() {
|
|
let users = vec![u("same", "k1"), u("same", "k2")];
|
|
let err = validate_and_index_users(&users).unwrap_err();
|
|
assert!(err.contains("duplicate slug"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_malformed_slug_at_boot() {
|
|
// "Brummel" with uppercase B used to slip past the loader and
|
|
// only surfaced as a runtime "no data directory" oddity. Boot
|
|
// must now fail loudly so the operator fixes users.toml before
|
|
// requests start arriving.
|
|
let users = vec![u("Brummel", "k1")];
|
|
let err = validate_and_index_users(&users).unwrap_err();
|
|
assert!(err.contains("invalid slug"), "got: {err}");
|
|
assert!(err.contains("Brummel"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_without_display_name_falls_back_to_slug() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "dr_a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let u = &parsed.user[0];
|
|
assert!(u.display_name.is_none());
|
|
assert_eq!(u.display_name(), "dr_a");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_display_name() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "dr_a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
display_name = "Dr. Müller"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let u = &parsed.user[0];
|
|
assert_eq!(u.display_name.as_deref(), Some("Dr. Müller"));
|
|
assert_eq!(u.display_name(), "Dr. Müller");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_without_window_hours_defaults_to_72() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
// Missing field falls back to the serde default so existing
|
|
// users.toml files continue to work unchanged.
|
|
assert_eq!(parsed.user[0].window_hours, 72);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_explicit_window_hours() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
window_hours = 168
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
assert_eq!(parsed.user[0].window_hours, 168);
|
|
}
|
|
|
|
// ----- AsrBackend -----
|
|
|
|
#[test]
|
|
fn asr_backend_default_is_whisper() {
|
|
assert_eq!(AsrBackend::default(), AsrBackend::Whisper);
|
|
}
|
|
|
|
#[test]
|
|
fn asr_backend_parses_whisper() {
|
|
assert_eq!(
|
|
"whisper".parse::<AsrBackend>().unwrap(),
|
|
AsrBackend::Whisper
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn asr_backend_parses_canary() {
|
|
assert_eq!("canary".parse::<AsrBackend>().unwrap(), AsrBackend::Canary);
|
|
}
|
|
|
|
#[test]
|
|
fn asr_backend_parses_case_insensitive_with_whitespace() {
|
|
assert_eq!(
|
|
" Whisper ".parse::<AsrBackend>().unwrap(),
|
|
AsrBackend::Whisper
|
|
);
|
|
assert_eq!("CANARY".parse::<AsrBackend>().unwrap(), AsrBackend::Canary);
|
|
}
|
|
|
|
#[test]
|
|
fn asr_backend_rejects_invalid() {
|
|
let err = "wav2vec".parse::<AsrBackend>().unwrap_err();
|
|
assert!(err.contains("wav2vec"), "got: {err}");
|
|
}
|
|
}
|