219 lines
6.5 KiB
Rust
219 lines
6.5 KiB
Rust
use super::*;
|
|
|
|
/// Explicit semantic source channel, independent of filename/type filters.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SemanticOrigin {
|
|
All,
|
|
Text,
|
|
Image,
|
|
Video,
|
|
Audio,
|
|
}
|
|
impl SemanticOrigin {
|
|
fn source_type(self) -> Option<&'static str> {
|
|
match self {
|
|
Self::All => None,
|
|
Self::Text => Some("text"),
|
|
Self::Image => Some("image"),
|
|
Self::Video => Some("video"),
|
|
Self::Audio => Some("audio"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A conservative intent hint, not a language understanding model. Explicit
|
|
/// document/visual cues veto it; ordinary topic and instrument queries do not
|
|
/// silently restrict modality. Word boundaries avoid filename substrings.
|
|
fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
|
if query.split_whitespace().count() == 1 && Path::new(query).extension().is_some() {
|
|
return None;
|
|
}
|
|
let lower = query.to_lowercase();
|
|
let words: Vec<_> = lower
|
|
.split(|c: char| !c.is_alphanumeric())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
let has = |values: &[&str]| words.iter().any(|word| values.contains(word));
|
|
if has(&[
|
|
"image",
|
|
"photo",
|
|
"picture",
|
|
"screenshot",
|
|
"diagram",
|
|
"document",
|
|
"pdf",
|
|
"text",
|
|
"paper",
|
|
"bild",
|
|
"foto",
|
|
"screenshot",
|
|
"dokument",
|
|
"textdatei",
|
|
]) {
|
|
return None;
|
|
}
|
|
if has(&[
|
|
"audio",
|
|
"spoken",
|
|
"speech",
|
|
"recording",
|
|
"conversation",
|
|
"interview",
|
|
"podcast",
|
|
"sound",
|
|
"music",
|
|
"gesprochen",
|
|
"gespräch",
|
|
"gespraech",
|
|
"geräusch",
|
|
"geraeusch",
|
|
"musik",
|
|
"hörbar",
|
|
"hörbarer",
|
|
"hörbare",
|
|
"hoerbar",
|
|
"hoerbarer",
|
|
"hoerbare",
|
|
"tonaufnahme",
|
|
]) {
|
|
return Some(SemanticOrigin::Audio);
|
|
}
|
|
if (has(&["someone", "speaker"])
|
|
&& has(&[
|
|
"mentions",
|
|
"mentioned",
|
|
"says",
|
|
"said",
|
|
"speaks",
|
|
"speaking",
|
|
]))
|
|
|| (has(&["jemand", "sprecher", "sprecherin"])
|
|
&& has(&["erwähnt", "erwaehnt", "sagt", "gesagt", "spricht"]))
|
|
{
|
|
return Some(SemanticOrigin::Audio);
|
|
}
|
|
None
|
|
}
|
|
|
|
impl HybridSearch {
|
|
pub(super) fn routed_semantic(
|
|
&mut self,
|
|
query: &str,
|
|
vector: &[f32],
|
|
limit: usize,
|
|
options: &SearchOptions,
|
|
) -> Result<Vec<SearchResult>> {
|
|
let mut trace = TraceCollector::new(MetricsMode::Basic);
|
|
self.routed_semantic_traced(query, vector, limit, options, &mut trace, None)
|
|
}
|
|
pub(super) fn routed_semantic_traced(
|
|
&mut self,
|
|
query: &str,
|
|
vector: &[f32],
|
|
limit: usize,
|
|
options: &SearchOptions,
|
|
trace: &mut TraceCollector,
|
|
parent: Option<SpanToken>,
|
|
) -> Result<Vec<SearchResult>> {
|
|
trace.timed("semantic.routing", parent, |trace, parent| {
|
|
if let Some(origin) = if options.code_only {
|
|
None
|
|
} else {
|
|
preferred_origin(query)
|
|
} {
|
|
trace.counter(parent, "audio_preference", 1);
|
|
let preferred = self.store.search_origin_traced(
|
|
vector,
|
|
&self.generation_fingerprint,
|
|
limit,
|
|
options,
|
|
origin.source_type(),
|
|
trace,
|
|
parent,
|
|
)?;
|
|
if !preferred.is_empty() {
|
|
return Ok(preferred);
|
|
}
|
|
}
|
|
trace.counter(parent, "general_lane", 1);
|
|
self.store.search_origin_traced(
|
|
vector,
|
|
&self.generation_fingerprint,
|
|
limit,
|
|
options,
|
|
None,
|
|
trace,
|
|
parent,
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Query one actual source channel, or All to bypass automatic spoken/audio
|
|
/// preference. This never embeds files again or changes an active policy.
|
|
pub fn search_semantic_with_origin(
|
|
&mut self,
|
|
query: &str,
|
|
limit: usize,
|
|
options: &SearchOptions,
|
|
origin: SemanticOrigin,
|
|
) -> Result<Vec<SearchResult>> {
|
|
let _ = self.lexical.search_with_options(query, 0, options)?;
|
|
if query.trim().is_empty() || limit == 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut trace = TraceCollector::new(MetricsMode::Basic);
|
|
let vector = self.query_vector(query, options.code_only, &mut trace, None)?;
|
|
self.store.search_origin(
|
|
&vector,
|
|
&self.generation_fingerprint,
|
|
limit.min(1000),
|
|
options,
|
|
origin.source_type(),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn persisted_custom_limits_require_identical_model_and_preprocessing() {
|
|
let expected = "pinned-model:pinned-media:preprocessing-v1";
|
|
assert!(reusable_policy(
|
|
expected,
|
|
&format!("{expected}:custom-240-2000-120-10000-2000-1800-14400000-17179869184")
|
|
));
|
|
for active in ["other-model:pinned-media:preprocessing-v1:custom-240-2000-120-10000-2000-1800-14400000-17179869184","pinned-model:pinned-media:preprocessing-v2:custom-240-2000-120-10000-2000-1800-14400000-17179869184","pinned-model:pinned-media:preprocessing-v1:custom-unknown"] {assert!(!reusable_policy(expected,active));}
|
|
}
|
|
#[test]
|
|
fn explicit_spoken_intent_is_conservative_and_bilingual() {
|
|
for query in [
|
|
"where someone mentions a contract",
|
|
"a speaker says the date",
|
|
"spoken discussion of ecology",
|
|
"audio of a waterfall",
|
|
"jemand erwähnt das Budget",
|
|
"Wo spricht jemand über Kündigung?",
|
|
"Gespräch über die neue Wohnung",
|
|
"hörbarer Regen",
|
|
] {
|
|
assert_eq!(
|
|
preferred_origin(query),
|
|
Some(SemanticOrigin::Audio),
|
|
"{query}"
|
|
);
|
|
}
|
|
for query in [
|
|
"contract deadline",
|
|
"violin solo",
|
|
"photo of someone speaking",
|
|
"Bild von einem Sprecher",
|
|
"a document that someone mentioned",
|
|
"speech.png",
|
|
"a screenshot of an audio waveform",
|
|
] {
|
|
assert_eq!(preferred_origin(query), None, "{query}");
|
|
}
|
|
}
|
|
}
|