66b3b7e4c8
Replaces the `.transcript.txt` sidecar with a structured `.json` file for recording metadata. This change consolidates transcript text, duration, and other potential metadata into a single, extensible JSON object. This also refactors the `TranscriptState` enum to better represent the on-disk state (absence of file means pending) and the in-memory representation. The `Transcript` enum now specifically models the terminal outcomes of the transcriber (`Silent` or `Content`). The commit includes updates to documentation, data structures, path handling, and various tests to align with the new metadata format.
630 lines
19 KiB
Rust
630 lines
19 KiB
Rust
mod common;
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use doctate_server::config::WhisperUserSettings;
|
|
use doctate_server::transcribe;
|
|
use doctate_server::transcribe::ffmpeg::remux_faststart;
|
|
use doctate_server::transcribe::ollama::{OllamaError, generate_oneliner};
|
|
use doctate_server::transcribe::recovery::scan_and_enqueue;
|
|
use doctate_server::transcribe::whisper::{WhisperError, transcribe};
|
|
use serde_json::json;
|
|
use wiremock::matchers::{body_partial_json, method, path, query_param};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
use common::{TestConfig, test_user};
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures")
|
|
.join(name)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ffmpeg_remux_produces_valid_output() {
|
|
let input = fixture("sample.m4a");
|
|
assert!(input.exists(), "fixture missing: {}", input.display());
|
|
|
|
let output = remux_faststart(&input).await.expect("remux failed");
|
|
|
|
let meta = std::fs::metadata(output.path()).expect("output missing");
|
|
assert!(meta.len() > 0, "output file is empty");
|
|
|
|
// Sanity check: ffprobe should still recognize it as an m4a/mp4 container.
|
|
let probe = std::process::Command::new("ffprobe")
|
|
.arg("-hide_banner")
|
|
.arg("-loglevel")
|
|
.arg("error")
|
|
.arg("-show_entries")
|
|
.arg("format=format_name")
|
|
.arg("-of")
|
|
.arg("default=nw=1:nk=1")
|
|
.arg(output.path())
|
|
.output()
|
|
.expect("ffprobe spawn failed");
|
|
assert!(probe.status.success(), "ffprobe failed");
|
|
let fmt = String::from_utf8_lossy(&probe.stdout);
|
|
assert!(
|
|
fmt.contains("mp4") || fmt.contains("m4a"),
|
|
"unexpected format: {fmt}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ffmpeg_remux_fails_on_missing_input() {
|
|
let input = PathBuf::from("/nonexistent/does-not-exist.m4a");
|
|
let result = remux_faststart(&input).await;
|
|
assert!(result.is_err(), "expected error for missing input");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_posts_multipart_and_returns_text() {
|
|
let server = MockServer::start().await;
|
|
let expected = "Hallo Welt, das ist ein Test.";
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.and(query_param("output", "txt"))
|
|
.and(query_param("language", "de"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(expected))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let text = transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect("transcribe failed");
|
|
|
|
assert_eq!(text, expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_returns_status_error_on_500() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect_err("expected error");
|
|
|
|
match err {
|
|
WhisperError::Status { status, body } => {
|
|
assert_eq!(status, 500);
|
|
assert_eq!(body, "boom");
|
|
}
|
|
other => panic!("unexpected error variant: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_times_out() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(2)))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_millis(100),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect_err("expected timeout");
|
|
|
|
assert!(
|
|
matches!(err, WhisperError::Http(_)),
|
|
"expected Http error, got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_forwards_hotwords_and_prompt_when_set() {
|
|
let server = MockServer::start().await;
|
|
|
|
// Accept any POST; inspect captured body below. language query param is
|
|
// still matched strictly to verify override from settings.
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.and(query_param("language", "en"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let settings = WhisperUserSettings {
|
|
language: Some("en".into()),
|
|
hotwords: Some("HOCM Valsalva".into()),
|
|
initial_prompt: Some("Kardiologie".into()),
|
|
};
|
|
transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&settings,
|
|
)
|
|
.await
|
|
.expect("transcribe failed");
|
|
|
|
let received = server.received_requests().await.unwrap();
|
|
let body = String::from_utf8_lossy(&received[0].body);
|
|
assert!(body.contains("name=\"hotwords\""), "hotwords part missing");
|
|
assert!(
|
|
body.contains("HOCM Valsalva"),
|
|
"hotwords value missing: {body}"
|
|
);
|
|
assert!(
|
|
body.contains("name=\"initial_prompt\""),
|
|
"initial_prompt part missing"
|
|
);
|
|
assert!(body.contains("Kardiologie"), "initial_prompt value missing");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn whisper_client_omits_empty_optional_fields() {
|
|
let server = MockServer::start().await;
|
|
|
|
// Default settings = all None → only `audio_file` part, language defaults
|
|
// to `de`. Assert that neither optional field appears in the body.
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.and(query_param("language", "de"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
transcribe(
|
|
&client,
|
|
&server.uri(),
|
|
&fixture("sample.m4a"),
|
|
Duration::from_secs(10),
|
|
&WhisperUserSettings::default(),
|
|
)
|
|
.await
|
|
.expect("transcribe failed");
|
|
|
|
// Inspect the captured request to confirm absence of the optional parts.
|
|
let received = server.received_requests().await.unwrap();
|
|
let body = String::from_utf8_lossy(&received[0].body);
|
|
assert!(
|
|
!body.contains("name=\"hotwords\""),
|
|
"hotwords part leaked: {body}"
|
|
);
|
|
assert!(
|
|
!body.contains("name=\"initial_prompt\""),
|
|
"initial_prompt part leaked: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recovery_enqueues_only_pending_recordings() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = tmp.path();
|
|
|
|
// User 1, case with two .m4a — one pending, one already transcribed.
|
|
let case_a = root.join("dr_a/aaaa");
|
|
std::fs::create_dir_all(&case_a).unwrap();
|
|
std::fs::write(case_a.join("2026-04-10T10-00-00Z.m4a"), b"x").unwrap();
|
|
std::fs::write(case_a.join("2026-04-11T10-00-00Z.m4a"), b"x").unwrap();
|
|
common::seed_recording_meta(
|
|
&case_a,
|
|
"2026-04-11T10-00-00Z",
|
|
common::Transcript::Content {
|
|
text: "done".into(),
|
|
},
|
|
None,
|
|
);
|
|
|
|
// User 2, case with one pending .m4a.
|
|
let case_b = root.join("dr_b/bbbb");
|
|
std::fs::create_dir_all(&case_b).unwrap();
|
|
std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap();
|
|
|
|
let (tx, mut rx) = transcribe::channel();
|
|
scan_and_enqueue(root, &tx).await;
|
|
drop(tx); // close channel so the loop below terminates
|
|
|
|
let mut received = Vec::new();
|
|
while let Some(job) = rx.recv().await {
|
|
received.push(job.audio_path);
|
|
}
|
|
|
|
assert_eq!(received.len(), 2, "got: {received:?}");
|
|
// Oldest first:
|
|
assert!(received[0].ends_with("2026-04-10T10-00-00Z.m4a"));
|
|
assert!(received[1].ends_with("2026-04-12T10-00-00Z.m4a"));
|
|
}
|
|
|
|
// -------------------- Worker: failure marker --------------------
|
|
|
|
fn config_with_whisper(
|
|
whisper_url: String,
|
|
) -> (
|
|
std::sync::Arc<doctate_server::config::Config>,
|
|
std::sync::Arc<doctate_server::settings::Settings>,
|
|
) {
|
|
// Ollama URL is irrelevant — worker never reaches it in the failure path.
|
|
// Port 1 is effectively unreachable, matching the historical intent.
|
|
TestConfig::new()
|
|
.with_label("transcribe")
|
|
.with_user(test_user("dr_test"))
|
|
.with_whisper(whisper_url, 5)
|
|
.with_ollama("http://127.0.0.1:1")
|
|
.build_pair()
|
|
}
|
|
|
|
/// Permanent Whisper errors (4xx other than 408/429) must rename `.m4a` to
|
|
/// `.m4a.failed` so recovery treats the recording as terminal. HTTP 400 is
|
|
/// the archetypal "the payload itself is wrong" response — retrying the
|
|
/// same bytes will hit the same wall, so the file is marked and only a
|
|
/// manual admin reset un-fails it.
|
|
#[tokio::test]
|
|
async fn worker_renames_audio_to_failed_on_permanent_whisper_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(400).set_body_string("bad payload"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
// Real case dir with a real m4a fixture so ffmpeg remux succeeds.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let case_dir = tmp.path().join("dr_test/aaaa");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
|
|
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
|
|
|
let (config, settings) = config_with_whisper(server.uri());
|
|
let (tx, rx) = transcribe::channel();
|
|
tx.send(transcribe::TranscribeJob {
|
|
audio_path: audio.clone(),
|
|
user_slug: "dr_test".into(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
drop(tx); // close channel so the worker loop exits after processing the job
|
|
|
|
let client = reqwest::Client::new();
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
transcribe::worker::run(
|
|
rx,
|
|
config,
|
|
settings,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
doctate_server::oneliner_locks::OnelinerLocks::new(),
|
|
)
|
|
.await;
|
|
|
|
// Audio must have been renamed so recovery skips it next time.
|
|
assert!(!audio.exists(), "original .m4a still present");
|
|
let failed = case_dir.join("2026-04-13T10-30-00Z.m4a.failed");
|
|
assert!(failed.exists(), "expected {} to exist", failed.display());
|
|
}
|
|
|
|
/// Transient Whisper errors (5xx / 408 / 429 / network) must NOT rename
|
|
/// the audio. The file stays as plain `.m4a` so the page-load heal
|
|
/// (`enqueue_pending_for_user`) picks it up automatically on the next
|
|
/// refresh — which is what happens when Minerva recovers.
|
|
#[tokio::test]
|
|
async fn worker_leaves_m4a_intact_on_transient_whisper_error() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let case_dir = tmp.path().join("dr_test/bbbb");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
let audio = case_dir.join("2026-04-13T10-31-00Z.m4a");
|
|
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
|
|
|
let (config, settings) = config_with_whisper(server.uri());
|
|
let (tx, rx) = transcribe::channel();
|
|
tx.send(transcribe::TranscribeJob {
|
|
audio_path: audio.clone(),
|
|
user_slug: "dr_test".into(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
drop(tx);
|
|
|
|
let client = reqwest::Client::new();
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let vocab = Arc::new(doctate_server::gazetteer::Gazetteer::empty());
|
|
transcribe::worker::run(
|
|
rx,
|
|
config,
|
|
settings,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
doctate_server::oneliner_locks::OnelinerLocks::new(),
|
|
)
|
|
.await;
|
|
|
|
// Original .m4a must still be there; .failed must NOT exist.
|
|
assert!(audio.exists(), "transient error must not consume the .m4a");
|
|
let failed = case_dir.join("2026-04-13T10-31-00Z.m4a.failed");
|
|
assert!(
|
|
!failed.exists(),
|
|
"transient error must not mark {} as failed",
|
|
failed.display()
|
|
);
|
|
// And no metadata sidecar yet either (the 503 produced no text).
|
|
let meta = case_dir.join("2026-04-13T10-31-00Z.json");
|
|
assert!(!meta.exists(), "no recording meta expected on 503");
|
|
}
|
|
|
|
/// The worker must route the Whisper response through the Gazetteer
|
|
/// before persisting. Mock returns the drift form "Zerebrum"; the
|
|
/// persisted `<stem>.json` must carry the canonical "Cerebrum".
|
|
#[tokio::test]
|
|
async fn transcribe_worker_normalizes_whisper_output() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/asr"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_string("Patient mit Blutung im Zerebrum."),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let case_dir = tmp.path().join("dr_test/ffff");
|
|
std::fs::create_dir_all(&case_dir).unwrap();
|
|
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
|
|
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
|
|
|
let vocab_dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(vocab_dir.path().join("anatomy.txt"), "Cerebrum\n").unwrap();
|
|
let vocab = Arc::new(
|
|
doctate_server::gazetteer::Gazetteer::load_dir(vocab_dir.path())
|
|
.await
|
|
.unwrap(),
|
|
);
|
|
assert_eq!(vocab.len(), 1);
|
|
|
|
let (config, settings) = config_with_whisper(server.uri());
|
|
let (tx, rx) = transcribe::channel();
|
|
tx.send(transcribe::TranscribeJob {
|
|
audio_path: audio.clone(),
|
|
user_slug: "dr_test".into(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
drop(tx);
|
|
|
|
let client = reqwest::Client::new();
|
|
let busy = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
transcribe::worker::run(
|
|
rx,
|
|
config,
|
|
settings,
|
|
client,
|
|
busy,
|
|
vocab,
|
|
doctate_server::events::channel(),
|
|
doctate_server::oneliner_locks::OnelinerLocks::new(),
|
|
)
|
|
.await;
|
|
|
|
let meta_path = case_dir.join("2026-04-13T10-30-00Z.json");
|
|
assert!(meta_path.exists(), "recording meta missing");
|
|
let bytes = std::fs::read(&meta_path).unwrap();
|
|
let meta: doctate_common::RecordingMeta = serde_json::from_slice(&bytes).unwrap();
|
|
match meta.transcript {
|
|
doctate_common::Transcript::Content { text } => {
|
|
assert_eq!(text, "Patient mit Blutung im Cerebrum.");
|
|
}
|
|
other => panic!("expected Transcript::Content, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// -------------------- Ollama client --------------------
|
|
|
|
const MODEL: &str = "gemma3:4b";
|
|
|
|
fn ok_chat_body(content: &str) -> serde_json::Value {
|
|
json!({
|
|
"model": MODEL,
|
|
"message": {"role": "assistant", "content": content},
|
|
"done": true
|
|
})
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_parses_chat_response() {
|
|
let server = MockServer::start().await;
|
|
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_json(ok_chat_body("HOCM mit Septum-Hypertrophie")),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let out = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"irrelevant",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect("call failed");
|
|
|
|
assert_eq!(out, "HOCM mit Septum-Hypertrophie");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_normalizes_response() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body(
|
|
"\"Kniegelenk re., V.a. Meniskus.\"\nSonstiges",
|
|
)))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let out = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"x",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(out, "Kniegelenk re., V.a. Meniskus");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_returns_status_on_500() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"x",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect_err("expected error");
|
|
match err {
|
|
OllamaError::Status { status, body } => {
|
|
assert_eq!(status, 500);
|
|
assert_eq!(body, "boom");
|
|
}
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_sends_keep_alive_and_model() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.and(body_partial_json(json!({
|
|
"model": MODEL,
|
|
"stream": false,
|
|
"keep_alive": 0
|
|
})))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body("OK")))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let _ = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"irrelevant",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect("call failed");
|
|
// Mock::expect(1) + server drop verifies the matcher hit exactly once.
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_empty_response_when_model_obeys_silence_rule() {
|
|
// Model followed the silence rule for a keyword-less transcript and
|
|
// returned empty content. That's a valid outcome, not a parse error.
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(ok_chat_body(" \n ")))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"äh, Moment",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect_err("expected EmptyResponse");
|
|
assert!(matches!(err, OllamaError::EmptyResponse), "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ollama_oneliner_parse_error_on_missing_content() {
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("POST"))
|
|
.and(path("/api/chat"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"done": true})))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let client = reqwest::Client::new();
|
|
let err = generate_oneliner(
|
|
&client,
|
|
&server.uri(),
|
|
MODEL,
|
|
0,
|
|
"x",
|
|
Duration::from_secs(5),
|
|
)
|
|
.await
|
|
.expect_err("expected parse error");
|
|
assert!(matches!(err, OllamaError::Parse(_)), "got {err:?}");
|
|
}
|