Add local spoken passages with Google retrieval and isolated runtime setup

This commit is contained in:
karl committed 2026-10-13 20:18:44 +02:00
1 parent bc9459785d
commit dab703413e
55 files changed
+3633 -70

No files matched your search

+12
View File
@@ -3809,6 +3809,7 @@ dependencies = [
"searchi-core",
"searchi-media",
"searchi-service",
"searchi-speech",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -3859,11 +3860,22 @@ dependencies = [
"searchi-core",
"searchi-media",
"searchi-semantic",
"searchi-speech",
"serde",
"sha2 0.10.9",
"usearch",
]
[[package]]
name = "searchi-speech"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"serde_json",
"sha2 0.10.9",
]
[[package]]
name = "selectors"
version = "0.38.0"
+1
View File
@@ -26,6 +26,7 @@ sha2 = "0.10"
tempfile = "3"
searchi-core = { path = "../../../crates/searchi-core" }
searchi-service = { path = "../../../crates/searchi-service" }
searchi-speech = { path = "../../../crates/searchi-speech" }
searchi-assets = { path = "../../../crates/searchi-assets" }
searchi-media = { path = "../../../crates/searchi-media" }
searchi-ai = { path = "../../../crates/searchi-ai" }
+15 -1
View File
@@ -11,6 +11,7 @@ mod media_preview;
mod model_download;
mod semantic_commands;
mod settings;
mod speech_setup;
mod watching;
use ai_commands::{generate_ai, prepare_ai_source, AiSession};
@@ -650,7 +651,18 @@ fn main() {
"The media decoder is missing from this installation. Repair or reinstall searchi.".into()
})
})();
let speech_worker = std::env::var_os("SEARCHI_SPEECH_WORKER").map(PathBuf::from).unwrap_or_else(|| {
let backend = if std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1") { "cpu" } else { "auto" };
let mut development = development_root.join(".searchi/speech-runtime").join(backend);
if cfg!(windows) { development = development.join("Release"); }
let name = if cfg!(windows) { "searchi-speech-worker.exe" } else { "searchi-speech-worker" };
let development = development.join(name);
if cfg!(debug_assertions) && development.is_file() { development } else {
app.path().resource_dir().unwrap_or_else(|_| directory.clone()).join("speech-runtime").join(name)
}
});
let mut ignored = vec![directory.clone(), model_directory.clone()];
if let Some(parent) = speech_worker.parent().filter(|path| path.is_absolute()) { ignored.push(parent.to_path_buf()); }
if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) {
ignored.push(parent.to_path_buf());
}
@@ -667,7 +679,9 @@ fn main() {
).map_err(std::io::Error::other)?);
let semantic = Arc::new(
SemanticState::new(model_directory, worker, media_tools)
.with_media_scratch(directory.join("MediaScratch")),
.with_media_scratch(directory.join("MediaScratch"))
.with_speech_assets(
speech_worker, directory.join("SpeechModels")),
);
semantic.begin_automatic_setup(database.clone(), settings.value.cpu_threads);
#[cfg(target_os = "macos")]
@@ -18,6 +18,7 @@ use tauri::State;
pub struct SemanticState {
pub model_directory: PathBuf,
pub worker: PathBuf,
speech: std::sync::Arc<crate::speech_setup::SpeechSetup>,
verified: AtomicBool,
text_verified: AtomicBool,
media_verified: AtomicBool,
@@ -48,7 +49,23 @@ impl SemanticState {
Ok(tools) => (Some(tools), None),
Err(error) => (None, Some(error)),
};
let speech = crate::speech_setup::SpeechSetup::new(
worker
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("speech-runtime")
.join(if cfg!(windows) {
"searchi-speech-worker.exe"
} else {
"searchi-speech-worker"
}),
model_directory
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.join("speech"),
);
Self {
speech,
model_directory,
worker,
verified: AtomicBool::new(false),
@@ -72,6 +89,11 @@ impl SemanticState {
}
}
pub fn with_speech_assets(mut self, worker: PathBuf, models: PathBuf) -> Self {
self.speech = crate::speech_setup::SpeechSetup::new(worker, models);
self
}
/// Keep generated video frames below the application's excluded data tree.
pub fn with_media_scratch(mut self, directory: PathBuf) -> Self {
self.media_limits.scratch_directory = Some(directory);
@@ -222,7 +244,12 @@ impl SemanticState {
})
.unwrap_or(Ok(()));
self.warming.store(false, Ordering::Release);
self.finish_setup(provision, prepared)
let result = self.finish_setup(provision, prepared);
if result.is_ok() {
self.speech
.start(self.inference_budget.clone(), self.media_limits.clone());
}
result
}
fn finish_setup(
@@ -299,6 +326,7 @@ impl SemanticState {
#[derive(Serialize)]
pub struct SemanticStatus {
speech: crate::speech_setup::SpeechStatus,
model: &'static str,
ready: bool,
text_model_ready: bool,
@@ -340,8 +368,11 @@ pub fn semantic_status(state: State<'_, AppState>) -> Result<SemanticStatus, Str
let desired = semantic.desired_coverage();
let index = HybridSearch::embedding_status_for_coverage(&state.database, desired)
.map_err(|error| error.to_string())?;
let indexing_ready = semantic.can_index(index.text_indexing_allowed);
let speech = semantic.speech.status(&index);
let indexing_ready = semantic.can_index(index.text_indexing_allowed)
&& (index.speech_policy.is_none() || speech.ready);
Ok(SemanticStatus {
speech,
model: "Google EmbeddingGemma 2",
ready: worker_ready && semantic.warmed.load(Ordering::Acquire),
text_model_ready: semantic.text_verified.load(Ordering::Acquire),
@@ -428,7 +459,9 @@ pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingRep
let coverage = semantic.desired_coverage();
let index = HybridSearch::embedding_status_for_coverage(&state.database, coverage)
.map_err(|error| error.to_string())?;
if !semantic.can_index(index.text_indexing_allowed) {
if !semantic.can_index(index.text_indexing_allowed)
|| (index.speech_policy.is_some() && !semantic.speech.status(&index).ready)
{
return Err(
"Semantic indexing is waiting for verified models and query preparation.".into(),
);
@@ -464,6 +497,14 @@ pub async fn index_embeddings(state: State<'_, AppState>) -> Result<EmbeddingRep
.and_then(|report| report.ok_or_else(|| anyhow::anyhow!("Full media index retained while media setup finishes."))),
SemanticCoverage::FullMedia => {
let tools = semantic.media_tools.as_ref().ok_or_else(|| anyhow::anyhow!("The media decoder is missing from this installation. Repair or reinstall searchi."))?;
let admission = semantic.inference_budget.acquire(InferenceRole::Background, 1)?;
let speech = semantic.speech.worker_for_index()?;
drop(admission);
if let Some(speech) = speech {
service = service.with_speech_provider(Box::new(searchi_service::BudgetedSpeechProvider::new(
Box::new(speech), semantic.inference_budget.clone(),
)))?;
}
service.index_embeddings_with_media(tools, &semantic.media_limits)
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Supplemental local speech setup; Google search readiness stays independent.
use searchi_service::{EmbeddingStatus, InferenceBudget, InferenceRole, MediaLimits};
use searchi_speech::{Device, SpeechWorker};
use serde::Serialize;
use std::{
path::PathBuf,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
};
pub struct SpeechSetup {
worker: PathBuf,
models: PathBuf,
downloading: AtomicBool,
downloaded: AtomicU64,
ready: AtomicBool,
error: Mutex<Option<String>>,
policy: Mutex<Option<String>>,
prepared: Mutex<Option<SpeechWorker>>,
}
#[derive(Serialize)]
pub struct SpeechStatus {
worker_ready: bool,
pub ready: bool,
downloading: bool,
downloaded_bytes: u64,
total_bytes: u64,
error: Option<String>,
requested_policy: Option<String>,
pub pending: bool,
corpus_revision: i64,
failures: usize,
}
impl SpeechSetup {
pub fn new(worker: PathBuf, models: PathBuf) -> Arc<Self> {
Arc::new(Self {
worker,
models,
downloading: AtomicBool::new(false),
downloaded: AtomicU64::new(0),
ready: AtomicBool::new(false),
error: Mutex::new(None),
policy: Mutex::new(None),
prepared: Mutex::new(None),
})
}
pub fn start(self: &Arc<Self>, budget: InferenceBudget, limits: MediaLimits) {
if self.downloading.swap(true, Ordering::AcqRel) {
return;
}
let setup = self.clone();
let spawned = std::thread::Builder::new().name("searchi-speech-setup".into()).spawn(move || {
let result = (|| -> anyhow::Result<()> {
anyhow::ensure!(setup.worker.is_file(), "Speech recognition is unavailable in this installation. Repair searchi to enable it.");
searchi_assets::speech::provision(&setup.models, &setup.downloaded)?;
let _admission = budget.acquire(InferenceRole::Background, 1)?;
let worker = setup.load()?;
let policy = searchi_service::speech_policy_key(worker.fingerprint(), &limits)?;
*setup.prepared.lock().map_err(|_| anyhow::anyhow!("Speech setup unavailable"))? = Some(worker);
*setup.policy.lock().map_err(|_| anyhow::anyhow!("Speech setup unavailable"))? = Some(policy);
Ok(())
})();
setup.ready.store(result.is_ok(), Ordering::Release);
if let Ok(mut error) = setup.error.lock() { *error = result.err().map(|error| error.to_string()); }
setup.downloading.store(false, Ordering::Release);
});
if let Err(error) = spawned {
if let Ok(mut last) = self.error.lock() {
*last = Some(format!("Could not start speech setup: {error}"));
}
self.downloading.store(false, Ordering::Release);
}
}
fn load(&self) -> anyhow::Result<SpeechWorker> {
SpeechWorker::load(
&self.worker,
&self.models.join("ggml-base.bin"),
&self.models.join("ggml-silero-v6.2.0.bin"),
Device::Auto,
)
}
pub fn worker_for_index(&self) -> anyhow::Result<Option<SpeechWorker>> {
if !self.ready.load(Ordering::Acquire) {
return Ok(None);
}
if let Some(worker) = self
.prepared
.lock()
.map_err(|_| anyhow::anyhow!("Speech setup unavailable"))?
.take()
{
return Ok(Some(worker));
}
self.load().map(Some)
}
pub fn status(&self, index: &EmbeddingStatus) -> SpeechStatus {
let policy = self.policy.lock().ok().and_then(|value| value.clone());
let ready = self.ready.load(Ordering::Acquire);
SpeechStatus {
worker_ready: self.worker.is_file(),
ready,
downloading: self.downloading.load(Ordering::Acquire),
downloaded_bytes: self.downloaded.load(Ordering::Acquire),
total_bytes: searchi_assets::speech::TOTAL_BYTES,
error: self.error.lock().ok().and_then(|value| value.clone()),
pending: ready && (index.speech_policy != policy || !index.speech_failures.is_empty()),
requested_policy: policy,
corpus_revision: index.corpus_revision,
failures: index.speech_failures.len(),
}
}
}
+41 -5
View File
@@ -104,7 +104,20 @@ interface DesktopStatus {
watch_error: string | null;
}[];
}
interface SpeechStatus {
worker_ready: boolean;
ready: boolean;
downloading: boolean;
downloaded_bytes: number;
total_bytes: number;
error: string | null;
requested_policy: string | null;
pending: boolean;
corpus_revision: number;
failures: number;
}
interface SemanticStatus {
speech?: SpeechStatus;
model: string;
ready: boolean;
model_directory: string;
@@ -150,7 +163,7 @@ document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
<header><div class="brand"><img class="brand-symbol" src="/searchi-symbol.png" alt=""><span class="wordmark">searchi</span></div><span id="index-status" class="index-status">Loading your index…</span><div class="header-actions"><button id="add-folder" class="quiet-button" type="button">${icon("folderAdd")}<span>Add folder</span></button><details id="filters" class="popover"><summary>${icon("filter")}<span id="scope-label">All files</span>${icon("chevron")}</summary><div class="popover-panel"><h2>Filter your search</h2><div class="filters-grid"><label>Search scope<select id="filter-scope"><option value="all">All files</option><option value="code">Code</option></select></label><label>Within folder<input id="filter-folder" placeholder="Absolute folder path"></label><label>Extension<input id="filter-extension" placeholder="pdf, md, txt…"></label><label>Artist (exact)<input id="filter-artist" placeholder="Artist from music tags" maxlength="256"></label><label>Track title (exact)<input id="filter-title" placeholder="Title from music tags" maxlength="256"></label><label>Minimum size (bytes)<input id="filter-min" type="number" min="0" step="1"></label><label>Maximum size (bytes)<input id="filter-max" type="number" min="0" step="1"></label><label>Minimum duration (seconds)<input id="filter-duration-min" type="number" min="0" step="0.1"></label><label>Maximum duration (seconds)<input id="filter-duration-max" type="number" min="0" step="0.1"></label><label>Modified from<input id="filter-after" type="date"></label><label>Modified through<input id="filter-before" type="date"></label></div></div></details><details id="settings" class="popover"><summary class="icon-button" aria-label="Settings" title="Settings">${icon("settings")}</summary><div class="popover-panel"><h2>Settings</h2><section class="appearance-settings" aria-labelledby="appearance-heading"><h3 id="appearance-heading">Appearance</h3><label for="background-material">Window background</label><select id="background-material"><option value="solid">Solid</option><option value="glass">Glass</option><option value="frosted">Frosted glass</option></select><div class="appearance-opacity"><label for="background-opacity">Background tint</label><output id="background-opacity-value" for="background-opacity">90%</output></div><input id="background-opacity" type="range" min="70" max="100" step="1" value="90" aria-describedby="appearance-status"><p id="appearance-status" class="hint" role="status">A subtle window background. Search results and passages stay clear.</p></section><h3>Folders & resources</h3><p id="shortcut-status" class="hint"></p><label class="resource-label" for="cpu-threads">CPU thread budget</label><select id="cpu-threads">${Array.from({ length: 8 }, (_, i) => `<option value="${i + 1}">${i + 1} ${i === 1 ? "threads (default)" : i ? "threads" : "thread (low power)"}</option>`).join("")}</select><p class="hint">Search automatically uses supported acceleration with CPU fallback. Two or more threads let search run alongside indexing; one thread can delay semantic search during indexing.</p><ul id="roots" aria-label="Selected folders"></ul><label class="performance-toggle"><input id="performance-toggle" type="checkbox">Performance details</label><p class="hint">Local timing diagnostics for new searches. Queries and file paths are excluded from copied reports.</p><details id="ai-settings" class="ai-settings"><summary>Optional AI answers</summary><label class="performance-toggle"><input id="ai-enabled" type="checkbox">Enable summaries and questions</label><p class="hint">Disabled by default. Only an explicit action sends the shown passage to your configured endpoint.</p><label>OpenAI-compatible base URL<input id="ai-endpoint" type="url" placeholder="http://localhost:1234/v1" autocomplete="off" maxlength="2048"></label><label>Model<input id="ai-model" placeholder="Your model name" autocomplete="off" maxlength="200"></label><label>API key (optional, this session only)<input id="ai-key" type="password" autocomplete="off" maxlength="2048"></label><button id="ai-clear-key" type="button">Clear key</button><p class="hint">Endpoint, model and key stay in this app session. The key authenticates requests to your endpoint.</p></details></div></details></div></header>
<main>
<form id="search-form" role="search"><label class="sr-only" for="query">Search your files</label>${icon("search")}<input id="query" type="search" placeholder="Search a filename, phrase, or what you remember…" autocomplete="off" spellcheck="false" maxlength="1000"><kbd id="focus-shortcut">Ctrl K</kbd><button id="clear-query" class="icon-button" type="button" aria-label="Clear search" title="Clear search">${icon("dismiss")}</button></form>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
<details id="semantic-panel"><summary>${icon("sparkles")}<span id="semantic-status" role="status" aria-live="polite">Preparing local semantic search…</span><span id="semantic-device" class="kind">Local model</span>${icon("chevron")}</summary><div class="semantic-content"><strong>Google EmbeddingGemma 2</strong><progress id="semantic-progress" aria-label="Local model setup progress"></progress><p id="semantic-detail" class="hint">The verified local model downloads automatically on first launch (865 MB). Your files stay on this device.</p><p id="speech-status" class="hint" role="status" hidden></p><progress id="speech-progress" aria-label="Local speech model setup progress" hidden></progress><div class="semantic-actions"><button id="retry-model" type="button" hidden>Retry model setup</button><button id="rebuild-semantic" type="button" hidden>Rebuild semantic index</button></div></div></details>
<form id="folder-form" hidden><label for="folder">Folder to index</label><div class="folder-row"><input id="folder" placeholder="Absolute folder path" required><button id="browse-folder" type="button">Browse…</button><button type="submit">Index folder</button><button id="cancel-folder" type="button">Cancel</button></div><p class="hint">Only choose folders you want searched. Large folders may take a while.</p></form>
<p id="notice" role="status" aria-live="polite"></p><details id="performance-panel" hidden><summary>Performance details</summary><div id="performance-detail"></div><button id="copy-performance" type="button">Copy local timing report</button><span id="copy-performance-status" role="status"></span></details><div class="workspace"><section class="results-pane" aria-label="Search results"><p id="result-summary" class="eyebrow">READY WHEN YOU ARE</p><ul id="results" aria-label="Matching files"></ul><div id="empty">${icon("search")}<h2>Find what matters. Locally.</h2><p>Index a folder, then search a filename, phrase, or what you remember.</p></div></section><aside id="preview" aria-label="Result preview" hidden><div class="preview-header"><span id="preview-icon" class="file-icon"></span><div class="preview-heading"><h2 id="preview-title"></h2><p id="preview-path" class="path"></p></div></div><div class="preview-toolbar"><span id="preview-kind"></span><div class="preview-actions"><button id="open-result" type="button">${icon("open")}<span>Open file</span></button><button id="reveal-result" type="button" title="Show in folder" aria-label="Show in folder">${icon("folder")}</button></div></div><details id="match-details" class="match-details"><summary>Why this result?</summary><p id="match-reason"></p><p id="match-legend"></p></details><div id="media-preview" class="media-preview" hidden></div><p id="media-preview-status" class="hint" role="status" hidden></p><details id="ai-panel" class="ai-panel" hidden><summary>Summarize or ask</summary><p id="ai-disclosure" class="hint"></p><p class="eyebrow">PASSAGE THAT WILL BE SENT</p><pre id="ai-source-text"></pre><div class="ai-actions"><button id="ai-summarize" type="button" disabled>Summarize this passage</button><label class="sr-only" for="ai-question">Question about this passage</label><input id="ai-question" placeholder="Ask about this passage…" maxlength="2048"><button id="ai-ask" type="button" disabled>Ask</button></div><p id="ai-status" class="hint" role="status"></p><pre id="ai-answer"></pre><p id="ai-answer-label" class="hint"></p><button id="ai-citation" type="button" hidden>Selected source</button></details><div class="passage"><p id="passage-label" class="eyebrow">EXTRACTED PASSAGE</p><pre id="preview-text"></pre></div></aside></div>
</main><footer><span class="footer-privacy">${icon("lock")}<span id="privacy-summary">Private search. Everything stays on your device.</span><span id="privacy" class="privacy">Local</span></span><span class="keyboard-hints"><kbd>${icon("arrowUp")}</kbd><kbd>${icon("arrowDown")}</kbd><span>Navigate</span><kbd>Enter</kbd><span>Open</span><kbd>Esc</kbd><span>Clear</span></span></footer>`;
@@ -452,9 +465,31 @@ async function refreshSemanticStatus() {
element("semantic-detail").textContent +=
` ${status.media_failures.length} files could not be indexed. The first issue: ${status.media_failures[0].path}: ${status.media_failures[0].message}. Filename search remains available; rebuild after fixing the file.`;
}
const speech = status.speech;
const speechStatus = element("speech-status");
speechStatus.hidden = !speech;
const speechProgress = element<HTMLProgressElement>("speech-progress");
speechProgress.hidden = !speech?.downloading;
if (speech) {
speechProgress.max = speech.total_bytes;
speechProgress.value = speech.downloaded_bytes;
speechStatus.textContent = speech.downloading
? `Preparing local speech passages · ${Math.round(speech.downloaded_bytes / 1_000_000)} of ${Math.round(speech.total_bytes / 1_000_000)} MB`
: speech.error
? `Speech passages: ${speech.error}`
: speech.ready
? speech.failures
? `Speech passages incomplete for ${speech.failures} files. Other search results remain available.`
: speech.pending
? "Speech models ready · Spoken passages are waiting to be indexed."
: "Local speech passages ready. Recognized words and timings can contain errors."
: "Local speech passages are preparing. Filename and existing content search remain available.";
}
const retry = element<HTMLButtonElement>("retry-model");
retry.hidden = !semanticRetryVisible({ ...status, error });
retry.disabled = working || isCaptureSurface();
retry.hidden =
!semanticRetryVisible({ ...status, error }) && !speech?.error;
retry.disabled =
working || Boolean(speech?.downloading) || isCaptureSurface();
const rebuild = element<HTMLButtonElement>("rebuild-semantic");
rebuild.hidden = !status.ready || (!status.chunks && !status.pending);
rebuild.disabled =
@@ -467,8 +502,9 @@ async function refreshSemanticStatus() {
if (
!isCaptureSurface() &&
status.ready &&
status.stale &&
status.pending > 0 &&
((status.stale && status.pending > 0) ||
(Boolean(speech?.pending) &&
Boolean(status.generation !== null || status.pending))) &&
semanticIndexReady(status) &&
!semanticJobActive &&
!indexingActive
+6
View File
@@ -224,6 +224,12 @@ export function matchExplanation(result: MatchResult) {
legend: literalLegend,
semanticPassage: false,
};
case "semantic_speech":
return {
reason: `Related spoken passage${time}. Recognized locally; its time is an estimated token position.`,
legend: `${literalLegend} Speech recognition may contain mistakes.`,
semanticPassage: true,
};
case "hybrid":
return {
reason: `Query words and related meaning matched this file; showing the passage${location}.`,
+1
View File
@@ -34,6 +34,7 @@ export function matchLabel(kind: string): string {
semantic_image: "Image meaning",
semantic_video: "Video frame meaning",
semantic_audio: "Audio meaning",
semantic_speech: "Spoken passage",
semantic: "Meaning",
hybrid: "Keyword + meaning",
exact_filename: "Exact filename",
+11
View File
@@ -14,6 +14,12 @@ export interface SemanticSetupStatus {
media_model_ready?: boolean;
media_model_error?: string | null;
requested_coverage?: SemanticCoverage;
speech?: {
ready: boolean;
pending: boolean;
requested_policy: string | null;
corpus_revision: number;
};
}
export function semanticIndexReady(status: SemanticSetupStatus) {
@@ -30,6 +36,11 @@ export function semanticIndexKey(
status: SemanticSetupStatus,
folderRevision: number,
) {
if (status.speech?.ready && status.speech.pending) {
// Failed optional speech can still publish raw media: publication must not
// create an infinite retry loop for the same source corpus and policy.
return `speech:${status.speech.requested_policy}:${status.speech.corpus_revision}:${folderRevision}`;
}
return `${status.generation}:${status.pending}:${folderRevision}:${status.requested_coverage ?? "full_media"}`;
}
@@ -74,6 +74,9 @@ test('optional reasons use real source routes and never claim semantic word conf
assert.match(text.legend,/without the same words/);
assert.equal(text.semanticPassage,true);
assert.match(matchExplanation({match_kind:'semantic_audio',source:{kind:'timestamp_ms',number:13129}}).reason,/0:13.*not an exact spoken phrase/);
const speech=matchExplanation({match_kind:'semantic_speech',source:{kind:'timestamp_ms',number:13129}});
assert.match(speech.reason,/0:13.*estimated token position/);
assert.equal(speech.semanticPassage,true);
assert.match(matchExplanation({match_kind:'semantic_video',source:{kind:'timestamp_ms',number:24167}}).reason,/frame.*0:24/);
assert.match(matchExplanation({match_kind:'semantic_image'}).reason,/whole/);
assert.match(matchExplanation({match_kind:'name_terms'}).reason,/filename, path or content/);
@@ -51,3 +51,13 @@ test('legacy full-pair status remains blocked during download and ready after se
assert.equal(semanticIndexReady({...legacy,downloading:false}), true);
assert.equal(semanticMediaReady({...legacy,downloading:false}), true);
});
test('partial speech publication cannot create an automatic retry loop', () => {
const gate = new AutomaticIndexGate();
const first = {...text(), speech:{ready:true,pending:true,requested_policy:'speech-v1-a',corpus_revision:7}};
assert.equal(gate.claim(semanticIndexKey(first,4)),true);
assert.equal(gate.claim(semanticIndexKey({...first,generation:99,pending:0},4)),false);
assert.equal(gate.claim(semanticIndexKey({...first,speech:{...first.speech,corpus_revision:8}},4)),true);
assert.equal(gate.claim(semanticIndexKey({...first,speech:{...first.speech,requested_policy:'speech-v1-b'}},4)),true);
});