997af407b6
Integrates the `spellbook` crate to allow for a Hunspell dictionary to be used as a veto mechanism within the gazetteer. This prevents common German words from being incorrectly rewritten to vocabulary entries if they fall within the edit distance threshold. For example, "Kaktus" will no longer be rewritten to "Lantus" if a Hunspell dictionary is provided and contains "Kaktus". The `hunspell_dict_path` configuration option is added, defaulting to `/usr/share/hunspell/de_DE`. This path should point to the stem of the Hunspell dictionary files (e.g., `/usr/share/hunspell/de_DE.aff` and `/usr/share/hunspell/de_DE.dic`). The gazetteer now supports an optional `DictChecker` trait, allowing for pluggable dictionary implementations. `SpellbookDict` is the initial implementation using the `spellbook` crate. Includes new tests to verify the dict veto functionality, including handling of case sensitivity and exact matches. An ignored test is added for live verification against system-installed Hunspell dictionaries.
336 lines
12 KiB
Rust
336 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// Per-user Whisper transcription settings from users.toml.
|
|
/// All fields optional — missing `[user.whisper]` block yields defaults (None).
|
|
#[derive(Debug, Default, Clone, Deserialize)]
|
|
pub struct WhisperUserSettings {
|
|
pub language: Option<String>,
|
|
pub hotwords: Option<String>,
|
|
pub initial_prompt: Option<String>,
|
|
}
|
|
|
|
/// 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,
|
|
#[serde(default)]
|
|
pub whisper: WhisperUserSettings,
|
|
}
|
|
|
|
/// Wrapper for deserializing the [[user]] array from TOML.
|
|
#[derive(Deserialize)]
|
|
struct UsersFile {
|
|
user: Vec<User>,
|
|
}
|
|
|
|
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
|
|
pub retention_audio_days: u32,
|
|
pub retention_transcript_days: u32,
|
|
pub retention_document_days: u32,
|
|
pub whisper_url: String,
|
|
pub whisper_timeout_seconds: u64,
|
|
pub ollama_url: String,
|
|
pub ollama_model: String,
|
|
pub ollama_keep_alive: u32,
|
|
pub llm_url: String,
|
|
pub llm_api_key: String,
|
|
pub llm_model: String,
|
|
pub llm_temperature: f32,
|
|
pub llm_timeout_seconds: u64,
|
|
/// System prompt sent to the analysis LLM. Defaults to
|
|
/// `crate::analyze::prompt::SYSTEM_PROMPT` if `LLM_SYSTEM_PROMPT` is unset
|
|
/// or empty. Runtime-configurable so admins can iterate and use the
|
|
/// "Neu analysieren" button to re-run cases.
|
|
pub llm_system_prompt: String,
|
|
/// 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,
|
|
}
|
|
|
|
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,
|
|
|
|
retention_audio_days: optional_env_parsed("RETENTION_AUDIO_DAYS", 30),
|
|
retention_transcript_days: optional_env_parsed("RETENTION_TRANSCRIPT_DAYS", 30),
|
|
retention_document_days: optional_env_parsed("RETENTION_DOCUMENT_DAYS", 0),
|
|
whisper_url: optional_env("WHISPER_URL", "http://localhost:10300"),
|
|
whisper_timeout_seconds: optional_env_parsed("WHISPER_TIMEOUT_SECONDS", 120),
|
|
ollama_url: optional_env("OLLAMA_URL", "http://localhost:11434"),
|
|
ollama_model: optional_env("OLLAMA_MODEL", "gemma3:4b"),
|
|
ollama_keep_alive: optional_env_parsed("OLLAMA_KEEP_ALIVE", 0),
|
|
llm_url: optional_env("LLM_URL", ""),
|
|
llm_api_key: optional_env("LLM_API_KEY", ""),
|
|
llm_model: optional_env("LLM_MODEL", ""),
|
|
llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0),
|
|
llm_timeout_seconds: optional_env_parsed("LLM_TIMEOUT_SECONDS", 180),
|
|
llm_system_prompt: {
|
|
let v = optional_env("LLM_SYSTEM_PROMPT", "");
|
|
if v.is_empty() {
|
|
crate::analyze::prompt::SYSTEM_PROMPT.to_string()
|
|
} else {
|
|
v
|
|
}
|
|
},
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// True iff the external analysis LLM is configured. `llm_api_key` is
|
|
/// intentionally **not** required — Ollama's OpenAI-compatible endpoint
|
|
/// at `/v1/chat/completions` accepts unauthenticated requests and is a
|
|
/// supported deployment target. For hosted providers (Ionos, OpenAI) the
|
|
/// key remains necessary in practice; the provider will reject with 401
|
|
/// if it is missing, which is correct feedback.
|
|
pub fn llm_configured(&self) -> bool {
|
|
!self.llm_url.is_empty() && !self.llm_model.is_empty()
|
|
}
|
|
|
|
/// 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(),
|
|
retention_audio_days: 30,
|
|
retention_transcript_days: 30,
|
|
retention_document_days: 0,
|
|
whisper_url: "http://localhost:10300".into(),
|
|
whisper_timeout_seconds: 120,
|
|
ollama_url: "http://localhost:11434".into(),
|
|
ollama_model: "gemma3:4b".into(),
|
|
ollama_keep_alive: 0,
|
|
llm_url: String::new(),
|
|
llm_api_key: String::new(),
|
|
llm_model: String::new(),
|
|
llm_temperature: 0.0,
|
|
llm_timeout_seconds: 180,
|
|
llm_system_prompt: crate::analyze::prompt::SYSTEM_PROMPT.to_string(),
|
|
vocab_dir: PathBuf::new(),
|
|
hunspell_dict_path: PathBuf::new(),
|
|
session_timeout_hours: 8,
|
|
cookie_secure: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 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 !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(),
|
|
whisper: WhisperUserSettings::default(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_without_whisper_block() {
|
|
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.whisper.language.is_none());
|
|
assert!(u.whisper.hotwords.is_none());
|
|
assert!(u.whisper.initial_prompt.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_user_with_full_whisper_block() {
|
|
let toml_str = r#"
|
|
[[user]]
|
|
slug = "a"
|
|
api_key = "k1"
|
|
web_password = ""
|
|
role = "doctor"
|
|
[user.whisper]
|
|
language = "de"
|
|
hotwords = "HOCM Valsalva"
|
|
initial_prompt = "Kardiologie"
|
|
"#;
|
|
let parsed: UsersFile = toml::from_str(toml_str).unwrap();
|
|
let w = &parsed.user[0].whisper;
|
|
assert_eq!(w.language.as_deref(), Some("de"));
|
|
assert_eq!(w.hotwords.as_deref(), Some("HOCM Valsalva"));
|
|
assert_eq!(w.initial_prompt.as_deref(), Some("Kardiologie"));
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
}
|