Formatting

This commit is contained in:
2026-04-19 15:35:10 +02:00
parent 041f9015ca
commit 0d5c2f5888
42 changed files with 612 additions and 357 deletions
+15 -4
View File
@@ -84,7 +84,10 @@ pub async fn chat_once(
user_content: &str,
timeout: Duration,
) -> Result<String, LlmError> {
let url = format!("{}/v1/chat/completions", settings.base_url.trim_end_matches('/'));
let url = format!(
"{}/v1/chat/completions",
settings.base_url.trim_end_matches('/')
);
debug!(%url, model = settings.model, "calling llm chat completions");
let body = ChatRequest {
@@ -92,8 +95,14 @@ pub async fn chat_once(
temperature: settings.temperature,
stream: false,
messages: vec![
Message { role: "system", content: system_prompt },
Message { role: "user", content: user_content },
Message {
role: "system",
content: system_prompt,
},
Message {
role: "user",
content: user_content,
},
],
};
@@ -107,7 +116,9 @@ pub async fn chat_once(
if !status.is_success() {
// Body deliberately dropped — see LlmError docstring.
drop(response);
return Err(LlmError::Status { status: status.as_u16() });
return Err(LlmError::Status {
status: status.as_u16(),
});
}
let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?;
+1 -1
View File
@@ -2,7 +2,7 @@ use std::path::Path;
use tracing::{info, warn};
use super::{AnalyzeJob, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use super::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
/// Walk `data_path/*/` and enqueue every pending analysis for every user.
/// Intended to run once at startup; the same primitive backs the per-user
+1 -1
View File
@@ -10,7 +10,7 @@
//! Pipeline:
//! raw markdown → html-escape → ==X== → <mark>X</mark> → pulldown-cmark → html
use pulldown_cmark::{html, Options, Parser};
use pulldown_cmark::{Options, Parser, html};
/// Render analysis Markdown to HTML. Safe to inject into an Askama template
/// with `{{ var|safe }}` because every `<`/`>` from the LLM is pre-escaped
+6 -6
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tracing::{error, info, warn};
use super::{llm, prompt, AnalysisInput, AnalyzeReceiver, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use super::{ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeReceiver, DOCUMENT_FILE, llm, prompt};
use crate::config::Config;
use crate::gazetteer::Gazetteer;
use crate::{BusyGuard, WorkerBusy};
@@ -20,10 +20,7 @@ pub async fn run(
worker_busy: WorkerBusy,
vocab: Arc<Gazetteer>,
) {
info!(
vocab_entries = vocab.len(),
"Analyze worker started"
);
info!(vocab_entries = vocab.len(), "Analyze worker started");
let timeout = Duration::from_secs(config.llm_timeout_seconds);
while let Some(job) = rx.recv().await {
@@ -144,7 +141,10 @@ async fn process(
/// state machine simply sees the job as still pending and retries.
async fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension({
let mut ext = path.extension().map(|s| s.to_os_string()).unwrap_or_default();
let mut ext = path
.extension()
.map(|s| s.to_os_string())
.unwrap_or_default();
ext.push(".tmp");
ext
});
+1 -4
View File
@@ -48,10 +48,7 @@ where
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let slug = config
.api_keys
.get(api_key)
.ok_or(AppError::Unauthorized)?;
let slug = config.api_keys.get(api_key).ok_or(AppError::Unauthorized)?;
let user = config
.users
+11 -9
View File
@@ -35,7 +35,10 @@ fn main() -> ExitCode {
Ok(Mode::PatchFile { slug, file }) => match read_password_twice() {
Ok(pw) => match patch_users_toml(&file, &slug, &hash(&pw)) {
Ok(()) => {
eprintln!("Updated web_password for slug \"{slug}\" in {}", file.display());
eprintln!(
"Updated web_password for slug \"{slug}\" in {}",
file.display()
);
ExitCode::SUCCESS
}
Err(e) => {
@@ -131,11 +134,8 @@ fn read_password_twice() -> Result<String, String> {
/// Patch `web_password` for the `[[user]]` entry whose `slug` matches.
/// Preserves comments and formatting via `toml_edit`. Writes atomically.
fn patch_users_toml(path: &Path, slug: &str, new_hash: &str) -> Result<(), String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("read: {e}"))?;
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| format!("parse: {e}"))?;
let content = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
let mut doc: toml_edit::DocumentMut = content.parse().map_err(|e| format!("parse: {e}"))?;
let users = doc
.get_mut("user")
@@ -158,8 +158,7 @@ fn patch_users_toml(path: &Path, slug: &str, new_hash: &str) -> Result<(), Strin
// Atomic write: tmp file in same dir, then rename.
let tmp = path.with_extension("toml.tmp");
{
let mut f = std::fs::File::create(&tmp)
.map_err(|e| format!("create tmp: {e}"))?;
let mut f = std::fs::File::create(&tmp).map_err(|e| format!("create tmp: {e}"))?;
f.write_all(doc.to_string().as_bytes())
.map_err(|e| format!("write tmp: {e}"))?;
f.sync_all().map_err(|e| format!("sync tmp: {e}"))?;
@@ -203,7 +202,10 @@ role = "doctor"
patch_users_toml(&p, "dr_a", "$2b$12$NEW").unwrap();
let out = std::fs::read_to_string(&p).unwrap();
assert!(out.contains(r#"web_password = "$2b$12$NEW""#), "got: {out}");
assert!(out.contains(r#"web_password = "$2b$12$KEEP""#), "dr_b changed: {out}");
assert!(
out.contains(r#"web_password = "$2b$12$KEEP""#),
"dr_b changed: {out}"
);
assert!(out.contains("# header comment"), "comment lost: {out}");
std::fs::remove_file(&p).ok();
}
+2 -2
View File
@@ -50,8 +50,8 @@ where
let Path(s) = Path::<String>::from_request_parts(parts, state)
.await
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
let id = CaseId::from_str(&s)
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
let id =
CaseId::from_str(&s).map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
Ok(CaseIdPath(id))
}
}
+1 -2
View File
@@ -216,8 +216,7 @@ pub fn validate_and_index_users(users: &[User]) -> Result<HashMap<String, String
/// 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}"))
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.
+1 -5
View File
@@ -495,10 +495,7 @@ mod tests {
#[test]
fn dict_veto_allows_nonword_rewrite() {
let g = from_entries(&["Cerebrum"]).with_dict(Box::new(HashSetDict::new(&["Kaktus"])));
assert_eq!(
g.replace("Blutung im Zerebrum."),
"Blutung im Cerebrum."
);
assert_eq!(g.replace("Blutung im Zerebrum."), "Blutung im Cerebrum.");
}
/// The exact-match short-circuit runs *before* any dict lookup.
@@ -552,5 +549,4 @@ mod tests {
"inflected form must be recognized via affix expansion"
);
}
}
+2 -2
View File
@@ -10,11 +10,11 @@ pub mod routes;
pub mod transcribe;
pub mod web_session;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use axum::extract::FromRef;
use axum::Router;
use axum::extract::FromRef;
use analyze::AnalyzeSender;
use config::Config;
+6 -8
View File
@@ -4,15 +4,15 @@ use tokio::net::TcpListener;
use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::TraceLayer;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use doctate_server::AppState;
use doctate_server::analyze;
use doctate_server::config::Config;
use doctate_server::gazetteer::{Gazetteer, SpellbookDict};
use doctate_server::transcribe;
use doctate_server::AppState;
#[tokio::main]
async fn main() {
@@ -20,8 +20,8 @@ async fn main() {
let config = Arc::new(Config::from_env());
// Logging: stdout + daily rotating log files.
let env_filter = EnvFilter::try_new(&config.log_level)
.unwrap_or_else(|_| EnvFilter::new("info"));
let env_filter =
EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let file_appender = tracing_appender::rolling::daily(&config.log_path, "recorder.log");
let (file_writer, _guard) = tracing_appender::non_blocking(file_appender);
@@ -131,10 +131,8 @@ async fn main() {
transcribe::recovery::scan_and_enqueue(&data_path, &tx).await;
// Then catch up oneliners for cases that crashed between
// transcript-write and oneliner-write in a previous run.
transcribe::recovery::regenerate_missing_oneliners(
&data_path, &client, &cfg, &vocab,
)
.await;
transcribe::recovery::regenerate_missing_oneliners(&data_path, &client, &cfg, &vocab)
.await;
});
}
+1 -4
View File
@@ -41,10 +41,7 @@ pub async fn read_delete_marker(case_dir: &Path) -> Option<DeleteMarker> {
}
/// Write the `.deleted` marker as JSON. Overwrites any existing marker.
pub async fn write_delete_marker(
case_dir: &Path,
marker: &DeleteMarker,
) -> std::io::Result<()> {
pub async fn write_delete_marker(case_dir: &Path, marker: &DeleteMarker) -> std::io::Result<()> {
let bytes = serde_json::to_vec(marker)
.map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?;
tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await
+9 -5
View File
@@ -4,16 +4,18 @@ use axum::extract::State;
use axum::response::Redirect;
use axum_extra::extract::Form;
use serde::Deserialize;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tracing::{info, warn};
use crate::analyze::{AnalyzeJob, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::paths::{write_delete_marker, DeleteMarker};
use crate::routes::case_actions::{build_analysis_input, reset_case_artefacts, write_input_create_new};
use crate::paths::{DeleteMarker, write_delete_marker};
use crate::routes::case_actions::{
build_analysis_input, reset_case_artefacts, write_input_create_new,
};
use crate::routes::user_web::locate_case;
#[derive(Debug, Deserialize)]
@@ -37,7 +39,9 @@ pub async fn handle_bulk_action(
let user_root = config.data_path.join(&user.slug);
match form.action.as_str() {
"analyze" => bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await,
"analyze" => {
bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await
}
"delete" => bulk_delete(&user.slug, &user_root, &form.case_ids).await,
"reset" => {
if !user.is_admin() {
+9 -12
View File
@@ -5,21 +5,21 @@ use std::time::SystemTime;
use askama::Template;
use axum::extract::State;
use axum::response::{Html, Redirect};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokio::io::AsyncWriteExt;
use tracing::{info, warn};
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
use crate::analyze::{
AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput, ANALYSIS_INPUT_FILE, DOCUMENT_FILE,
ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE, RecordingInput,
};
use crate::auth::AuthenticatedWebUser;
use crate::case_id::CaseIdPath;
use crate::config::Config;
use crate::error::AppError;
use crate::paths::{read_delete_marker, write_delete_marker, DeleteMarker, DELETE_MARKER};
use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker};
use crate::routes::user_web::locate_case_or_404;
#[derive(Template)]
@@ -157,17 +157,13 @@ fn compute_last_mtime(m4as: &[(PathBuf, SystemTime)]) -> SystemTime {
/// transkribiert" body. Blank transcripts are silently skipped — they
/// represent recordings the transcriber classified as silence and must
/// not be pushed to the LLM.
async fn read_recordings(
m4as: &[(PathBuf, SystemTime)],
) -> Result<Vec<RecordingInput>, AppError> {
async fn read_recordings(m4as: &[(PathBuf, SystemTime)]) -> Result<Vec<RecordingInput>, AppError> {
let mut recordings: Vec<RecordingInput> = Vec::new();
for (m4a_path, _) in m4as {
let transcript_path = m4a_path.with_extension("transcript.txt");
let text = tokio::fs::read_to_string(&transcript_path)
.await
.map_err(|_| {
AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into())
})?;
.map_err(|_| AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into()))?;
if text.trim().is_empty() {
continue;
@@ -240,7 +236,9 @@ pub(crate) async fn write_input_create_new(
/// Read `case_dir/document.md`. Returns `None` if no document exists.
pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)).await.ok()
tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE))
.await
.ok()
}
/// Reset a case to its raw audio: delete all derived artefacts
@@ -388,8 +386,7 @@ async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> {
None => Some(candidate),
// Order: deleted_at desc, then batch desc as tie-breaker.
Some(ref cur)
if candidate.1 > cur.1
|| (candidate.1 == cur.1 && candidate.0 > cur.0) =>
if candidate.1 > cur.1 || (candidate.1 == cur.1 && candidate.0 > cur.0) =>
{
Some(candidate)
}
+1 -1
View File
@@ -1,5 +1,5 @@
use axum::Json;
use serde_json::{json, Value};
use serde_json::{Value, json};
use crate::auth::AuthenticatedUser;
use crate::error::AppError;
+2 -2
View File
@@ -1,8 +1,8 @@
use std::sync::Arc;
use axum::extract::State;
use axum::Json;
use serde_json::{json, Value};
use axum::extract::State;
use serde_json::{Value, json};
use crate::config::Config;
+6 -3
View File
@@ -2,12 +2,12 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use askama::Template;
use axum::Form;
use axum::extract::State;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::Form;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use rand::distributions::Alphanumeric;
use rand::Rng;
use rand::distributions::Alphanumeric;
use serde::Deserialize;
use tracing::{info, warn};
@@ -49,7 +49,10 @@ pub async fn handle_login_submit(
if !valid {
if user.is_none() {
// Dummy verify so timing is similar regardless of slug existence.
let _ = bcrypt::verify(&form.password, "$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii");
let _ = bcrypt::verify(
&form.password,
"$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii",
);
}
warn!(slug = %form.slug, "login failed");
return render_login(Some("Login fehlgeschlagen")).map(IntoResponse::into_response);
+5 -2
View File
@@ -8,8 +8,8 @@ mod upload;
pub(crate) mod user_web;
pub(crate) mod web;
use axum::routing::{get, post};
use axum::Router;
use axum::routing::{get, post};
use crate::AppState;
@@ -19,7 +19,10 @@ pub fn api_router() -> Router<AppState> {
.route("/api/debug/whoami", get(debug::handle_whoami))
.route("/api/upload", post(upload::handle_upload))
.route("/api/oneliners", get(oneliners::handle_oneliners))
.route("/web/login", get(login::handle_login_page).post(login::handle_login_submit))
.route(
"/web/login",
get(login::handle_login_page).post(login::handle_login_submit),
)
.route("/web/logout", post(login::handle_logout))
.route("/web/cases", get(user_web::handle_my_cases))
.route("/web/cases/{case_id}", get(user_web::handle_case_detail))
+3 -3
View File
@@ -20,10 +20,10 @@
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use axum::extract::Query;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use axum::extract::Query;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse};
use serde::Deserialize;
use time::format_description::well_known::Rfc3339;
+1 -1
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf};
use axum::extract::{Multipart, State};
use axum::Json;
use axum::extract::{Multipart, State};
use doctate_common::timestamp::recorded_at_to_filename_stem;
use tokio::sync::mpsc::error::TrySendError;
use tracing::{info, warn};
+6 -8
View File
@@ -1,22 +1,22 @@
use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use askama::Template;
use axum::extract::State;
use axum::response::Html;
use tracing::{info, warn};
use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
use crate::analyze::{recovery as analyze_recovery, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use crate::PipelineState;
use crate::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE, recovery as analyze_recovery};
use crate::auth::AuthenticatedWebUser;
use crate::case_id::{CaseId, CaseIdPath};
use crate::config::Config;
use crate::error::AppError;
use crate::routes::web::{scan_recordings, RecordingView};
use crate::routes::web::{RecordingView, scan_recordings};
use crate::transcribe::recovery as transcribe_recovery;
use crate::PipelineState;
struct UserCaseView {
case_id: String,
@@ -366,9 +366,7 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
/// a valid UUID. Returns the (case_id-string, absolute path) tuple so
/// the caller does not need to recompute either. Non-UUID names are
/// logged at WARN level; other rejections are silent (common case).
async fn filter_valid_case_dir(
entry: tokio::fs::DirEntry,
) -> Option<(String, std::path::PathBuf)> {
async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, std::path::PathBuf)> {
let case_id = entry.file_name().into_string().ok()?;
if uuid::Uuid::parse_str(&case_id).is_err() {
warn!(case_id = %case_id, "Skipping non-UUID directory");
+2 -8
View File
@@ -36,9 +36,7 @@ struct CasesTemplate {
cases: Vec<CaseView>,
}
pub async fn handle_case_list(
State(config): State<Arc<Config>>,
) -> Result<Html<String>, AppError> {
pub async fn handle_case_list(State(config): State<Arc<Config>>) -> Result<Html<String>, AppError> {
let cases = scan_cases(&config.data_path).await;
let template = CasesTemplate { cases };
template
@@ -82,11 +80,7 @@ fn validate_user_slug(user: &str) -> Result<(), AppError> {
fn validate_filename(filename: &str) -> Result<(), AppError> {
let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed");
if !ok_suffix
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains("..")
{
if !ok_suffix || filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err(AppError::BadRequest("Invalid filename".into()));
}
Ok(())
+13 -8
View File
@@ -4,7 +4,7 @@ use std::time::Duration;
use tracing::{error, info, warn};
use super::{ffmpeg, ollama, whisper, TranscribeReceiver};
use super::{TranscribeReceiver, ffmpeg, ollama, whisper};
use crate::config::{Config, WhisperUserSettings};
use crate::gazetteer::Gazetteer;
use crate::{BusyGuard, WorkerBusy};
@@ -53,7 +53,15 @@ pub async fn run(
}
};
let text = match whisper::transcribe(&client, &config.whisper_url, remuxed.path(), timeout, &settings).await {
let text = match whisper::transcribe(
&client,
&config.whisper_url,
remuxed.path(),
timeout,
&settings,
)
.await
{
Ok(t) => t,
Err(e) => {
error!(audio = %audio_path.display(), error = %e, "whisper call failed");
@@ -258,12 +266,9 @@ mod tests {
#[tokio::test]
async fn all_transcripts_joined_skips_empty_recordings() {
let dir = tempdir().unwrap();
tokio::fs::write(
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
"",
)
.await
.unwrap();
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.transcript.txt"), "")
.await
.unwrap();
tokio::fs::write(
dir.path().join("2026-04-16T10-05-00Z.transcript.txt"),
"non-empty content",