Merge pull request 'feat(search): explicit filters on unified search; list camera bodies' (#171) from feat/search-explicit-filters into master
Reviewed-on: #171
This commit was merged in pull request #171.
This commit is contained in:
commit
50dd9aa016
4 files changed
+511
-69
No files matched your search
@@ -525,6 +525,15 @@ GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat
|
||||
// ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search.
|
||||
// A calendar-day range = that day's bounds in the owner's display zone.
|
||||
|
||||
// Natural-language search (LLM translates q into filters + a CLIP term)
|
||||
GET /photos/search/unified?q=...&model=...&interpret=true
|
||||
// Also accepts the /photos filters explicitly (tag_ids, exclude_tag_ids,
|
||||
// tag_filter_mode, media_type, date_from/date_to, camera_make/camera_model,
|
||||
// gps_lat/gps_lon/gps_radius_km) plus path (folder, recursive). They're
|
||||
// AND-ed with what the model extracted; single-valued ones replace the
|
||||
// model's guess, and `interpreted` only echoes what the model added.
|
||||
// interpret=false skips the LLM: q is the CLIP term within the filters.
|
||||
|
||||
// Video streaming (HLS, the default playback path)
|
||||
POST /video/generate (queues .m3u8 playlist + .ts segments, returns playlist_url)
|
||||
GET /video/hls/<hash>/<file> (serves playlist / segments)
|
||||
@@ -534,7 +543,7 @@ POST /video/original (body: {path, library}) -> {url, expires_at}
|
||||
GET /video/original/stream?token=... (Range-capable original bytes)
|
||||
|
||||
// Tags
|
||||
GET /image/tags/all
|
||||
GET /image/tags/all (tags + counts, camera_makes, and cameras: make+model bodies)
|
||||
POST /image/tags (add tag to file)
|
||||
DELETE /image/tags (remove tag from file)
|
||||
POST /image/tags/batch (bulk tag updates)
|
||||
|
||||
@@ -388,6 +388,12 @@ pub trait ExifDao: Sync + Send {
|
||||
/// Get distinct camera makes with counts
|
||||
fn get_camera_makes(&mut self) -> Result<Vec<(String, i64)>, DbError>;
|
||||
|
||||
/// Distinct (make, model) pairs with counts, most-used first. Rows
|
||||
/// without a model are skipped — `get_camera_makes` covers those.
|
||||
fn get_cameras(&mut self) -> Result<Vec<(String, String, i64)>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Update file path in EXIF database
|
||||
fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError>;
|
||||
|
||||
@@ -1147,6 +1153,31 @@ impl ExifDao for SqliteExifDao {
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn get_cameras(&mut self) -> Result<Vec<(String, String, i64)>, DbError> {
|
||||
trace_db_call("query", "get_cameras", |_span| {
|
||||
use diesel::dsl::count;
|
||||
use schema::image_exif::dsl::*;
|
||||
|
||||
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
|
||||
|
||||
image_exif
|
||||
.filter(camera_make.is_not_null())
|
||||
.filter(camera_model.is_not_null())
|
||||
.group_by((camera_make, camera_model))
|
||||
.select((camera_make, camera_model, count(id)))
|
||||
.order(count(id).desc())
|
||||
.load::<(Option<String>, Option<String>, i64)>(connection.deref_mut())
|
||||
.map(|records| {
|
||||
records
|
||||
.into_iter()
|
||||
.filter_map(|(make, model, cnt)| Some((make?, model?, cnt)))
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||
})
|
||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||
}
|
||||
|
||||
fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError> {
|
||||
trace_db_call("update", "update_file_path", |_span| {
|
||||
use schema::image_exif::dsl::*;
|
||||
@@ -2584,6 +2615,56 @@ mod exif_dao_tests {
|
||||
assert_eq!(lib2[0].library_id, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_cameras_groups_make_and_model_most_used_first() {
|
||||
let mut dao = setup_two_libraries();
|
||||
let mut camera = |rel: &str, make: Option<&str>, model: Option<&str>| {
|
||||
dao.store_exif(InsertImageExif {
|
||||
library_id: 1,
|
||||
file_path: rel.to_string(),
|
||||
camera_make: make.map(str::to_string),
|
||||
camera_model: model.map(str::to_string),
|
||||
lens_model: None,
|
||||
width: None,
|
||||
height: None,
|
||||
orientation: None,
|
||||
gps_latitude: None,
|
||||
gps_longitude: None,
|
||||
gps_altitude: None,
|
||||
focal_length: None,
|
||||
aperture: None,
|
||||
shutter_speed: None,
|
||||
iso: None,
|
||||
date_taken: None,
|
||||
created_time: 0,
|
||||
last_modified: 0,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
phash_64: None,
|
||||
dhash_64: None,
|
||||
date_taken_source: None,
|
||||
date_utc: None,
|
||||
camera_offset_minutes: None,
|
||||
date_taken_tz_source: None,
|
||||
})
|
||||
.expect("insert exif row");
|
||||
};
|
||||
camera("a.jpg", Some("motorola"), Some("Nexus 6"));
|
||||
camera("b.jpg", Some("Google"), Some("Pixel 7"));
|
||||
camera("c.jpg", Some("Google"), Some("Pixel 7"));
|
||||
// No model: covered by get_camera_makes, skipped here.
|
||||
camera("d.jpg", Some("Canon"), None);
|
||||
camera("e.jpg", None, None);
|
||||
|
||||
assert_eq!(
|
||||
dao.get_cameras().unwrap(),
|
||||
vec![
|
||||
("Google".to_string(), "Pixel 7".to_string(), 2),
|
||||
("motorola".to_string(), "Nexus 6".to_string(), 1),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Row with the full date triple, for the true-instant filter tests.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_dated_row(
|
||||
|
||||
+23
@@ -213,9 +213,22 @@ async fn get_all_tags<D: TagDao>(
|
||||
.map(|(make, count)| CameraMakeCount { make, count })
|
||||
.collect::<Vec<CameraMakeCount>>();
|
||||
|
||||
let cameras = exif_dao
|
||||
.lock()
|
||||
.expect("Unable to get ExifDao")
|
||||
.get_cameras()
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Failed to get cameras: {:?}", e);
|
||||
Vec::new()
|
||||
})
|
||||
.into_iter()
|
||||
.map(|(make, model, count)| CameraCount { make, model, count })
|
||||
.collect::<Vec<CameraCount>>();
|
||||
|
||||
HttpResponse::Ok().json(AllTagsResponse {
|
||||
tags: tags_response,
|
||||
camera_makes,
|
||||
cameras,
|
||||
})
|
||||
})
|
||||
.into_http_internal_err()
|
||||
@@ -465,10 +478,20 @@ pub struct CameraMakeCount {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// One camera body (make + model) and how many files it took. Lets the
|
||||
/// search screen offer "Nexus 6" directly instead of make-then-model.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct CameraCount {
|
||||
pub make: String,
|
||||
pub model: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct AllTagsResponse {
|
||||
pub tags: Vec<TagWithTagCount>,
|
||||
pub camera_makes: Vec<CameraMakeCount>,
|
||||
pub cameras: Vec<CameraCount>,
|
||||
}
|
||||
|
||||
#[derive(Insertable, Clone, Debug)]
|
||||
|
||||
+397
-68
@@ -25,7 +25,7 @@ use crate::ai::nl_query::{StructuredQuery, translate_nl_query};
|
||||
use crate::clip_search::{
|
||||
SearchHit, parse_library_scope, resolve_hits, score_error_response, score_photos,
|
||||
};
|
||||
use crate::data::Claims;
|
||||
use crate::data::{Claims, FilterMode, MediaType};
|
||||
use crate::database::ExifDao;
|
||||
use crate::file_types::{is_image_file, is_video_file};
|
||||
use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance};
|
||||
@@ -58,6 +58,32 @@ pub struct UnifiedQuery {
|
||||
/// (avoids a llama-swap eviction / cold start). Falls back to the configured
|
||||
/// default local model when absent. Local only — no hybrid here.
|
||||
pub model: Option<String>,
|
||||
/// When false, skip the LLM translation and use `q` verbatim as the CLIP
|
||||
/// term ("visual match only"). Explicit filters below still apply.
|
||||
#[serde(default = "default_interpret")]
|
||||
pub interpret: bool,
|
||||
|
||||
// ── Explicit filters ──
|
||||
// Set by the client's filter chips. They're AND-ed with whatever the
|
||||
// model extracted from `q`; for single-valued filters (camera, date,
|
||||
// media type, place) the explicit value replaces the model's guess.
|
||||
// Same encodings as `/photos`.
|
||||
/// Comma-separated tag ids the results must carry (see `tag_filter_mode`).
|
||||
pub tag_ids: Option<String>,
|
||||
/// Comma-separated tag ids the results must not carry.
|
||||
pub exclude_tag_ids: Option<String>,
|
||||
/// `All` (default) or `Any` for `tag_ids`.
|
||||
pub tag_filter_mode: Option<FilterMode>,
|
||||
pub media_type: Option<MediaType>,
|
||||
pub date_from: Option<i64>,
|
||||
pub date_to: Option<i64>,
|
||||
pub camera_make: Option<String>,
|
||||
pub camera_model: Option<String>,
|
||||
pub gps_lat: Option<f64>,
|
||||
pub gps_lon: Option<f64>,
|
||||
pub gps_radius_km: Option<f64>,
|
||||
/// Folder (relative path) to search under, recursively.
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> usize {
|
||||
@@ -66,6 +92,129 @@ fn default_limit() -> usize {
|
||||
fn default_threshold() -> f32 {
|
||||
0.20
|
||||
}
|
||||
fn default_interpret() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The client's explicit tag filter, applied as its own predicate on top of
|
||||
/// the model's (ANY-mode) tags.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct ExplicitTags {
|
||||
include: Vec<i32>,
|
||||
exclude: Vec<i32>,
|
||||
mode: FilterMode,
|
||||
}
|
||||
|
||||
/// Parse a comma-separated id list, ignoring blanks.
|
||||
fn parse_ids(raw: Option<&str>, name: &str) -> Result<Vec<i32>, String> {
|
||||
raw.unwrap_or("")
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| {
|
||||
s.parse::<i32>()
|
||||
.map_err(|_| format!("invalid id in `{name}`: {s}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merge the explicit filters in `query` into the model's interpretation.
|
||||
///
|
||||
/// Returns `(effective, echo, tags, gps)`:
|
||||
/// - `effective` is what the search runs on: explicit single-valued filters
|
||||
/// replace the model's, and the model's tags lose any the client already
|
||||
/// included or excluded.
|
||||
/// - `echo` is the model's interpretation minus everything the explicit
|
||||
/// filters overrode, so the client's "interpreted" chips only show what
|
||||
/// the model added.
|
||||
/// - `tags` is the explicit tag predicate, if any.
|
||||
/// - `gps` is the explicit place circle, if any (it replaces `place`).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn merge_explicit(
|
||||
sq: StructuredQuery,
|
||||
query: &UnifiedQuery,
|
||||
) -> Result<
|
||||
(
|
||||
StructuredQuery,
|
||||
StructuredQuery,
|
||||
Option<ExplicitTags>,
|
||||
Option<(f64, f64, f64)>,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
let include = parse_ids(query.tag_ids.as_deref(), "tag_ids")?;
|
||||
let exclude = parse_ids(query.exclude_tag_ids.as_deref(), "exclude_tag_ids")?;
|
||||
let tags = (!include.is_empty() || !exclude.is_empty()).then(|| ExplicitTags {
|
||||
include: include.clone(),
|
||||
exclude: exclude.clone(),
|
||||
mode: query.tag_filter_mode.unwrap_or(FilterMode::All),
|
||||
});
|
||||
|
||||
let gps = match (query.gps_lat, query.gps_lon, query.gps_radius_km) {
|
||||
(Some(lat), Some(lon), Some(r)) => Some((lat, lon, r)),
|
||||
(None, None, None) => None,
|
||||
_ => return Err("GPS search requires gps_lat, gps_lon and gps_radius_km".into()),
|
||||
};
|
||||
|
||||
let media = match query.media_type {
|
||||
Some(MediaType::Photo) => Some("photo".to_string()),
|
||||
Some(MediaType::Video) => Some("video".to_string()),
|
||||
Some(MediaType::All) | None => None,
|
||||
};
|
||||
let explicit_date = query.date_from.is_some() || query.date_to.is_some();
|
||||
let explicit_make = query
|
||||
.camera_make
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
let explicit_model = query
|
||||
.camera_model
|
||||
.as_deref()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
|
||||
let mut echo = sq;
|
||||
echo.tag_ids
|
||||
.retain(|id| !include.contains(id) && !exclude.contains(id));
|
||||
echo.exclude_tag_ids
|
||||
.retain(|id| !include.contains(id) && !exclude.contains(id));
|
||||
let mut effective = echo.clone();
|
||||
|
||||
if let Some(m) = media {
|
||||
echo.media_type = None;
|
||||
effective.media_type = Some(m);
|
||||
}
|
||||
if explicit_date {
|
||||
// A date range is one filter: an explicit "after X" replaces the
|
||||
// model's whole range, not just its lower bound.
|
||||
echo.date_from = None;
|
||||
echo.date_to = None;
|
||||
effective.date_from = query.date_from;
|
||||
effective.date_to = query.date_to;
|
||||
}
|
||||
if let Some(make) = explicit_make {
|
||||
echo.camera_make = None;
|
||||
effective.camera_make = Some(make.to_string());
|
||||
}
|
||||
if let Some(model) = explicit_model {
|
||||
echo.camera_model = None;
|
||||
effective.camera_model = Some(model.to_string());
|
||||
}
|
||||
if gps.is_some() {
|
||||
echo.place = None;
|
||||
effective.place = None;
|
||||
}
|
||||
Ok((effective, echo, tags, gps))
|
||||
}
|
||||
|
||||
/// Normalized folder prefix for the `path` filter (`None` = whole library).
|
||||
fn folder_prefix(path: Option<&str>) -> Option<String> {
|
||||
let p = path?.replace('\\', "/");
|
||||
let p = p.trim_matches('/');
|
||||
(!p.is_empty()).then(|| format!("{p}/"))
|
||||
}
|
||||
|
||||
fn in_folder(file_path: &str, prefix: &str) -> bool {
|
||||
file_path.replace('\\', "/").starts_with(prefix)
|
||||
}
|
||||
|
||||
/// A geocoded place echoed back so the client can show / edit the location
|
||||
/// filter it actually searched.
|
||||
@@ -158,73 +307,27 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
};
|
||||
|
||||
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
|
||||
let tag_vocab: Vec<(i32, String)> = {
|
||||
let mut dao = tag_dao.lock().expect("tag dao");
|
||||
match dao.get_all_tags(None) {
|
||||
Ok(tags) => tags.into_iter().map(|(_, t)| (t.id, t.name)).collect(),
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: get_all_tags failed: {e:?}");
|
||||
Vec::new()
|
||||
}
|
||||
// (or take it verbatim as the CLIP term when interpretation is off).
|
||||
let sq = if query.interpret {
|
||||
match translate(&state, &tag_dao, &query, &nl).await {
|
||||
Ok(sq) => sq,
|
||||
Err(resp) => return resp,
|
||||
}
|
||||
} else {
|
||||
StructuredQuery {
|
||||
semantic: Some(nl.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Respect env/config for the LLM backend (LLM_BACKEND → ollama or
|
||||
// llama-swap); local only, no hybrid, per the feature's design.
|
||||
//
|
||||
// Translation-model precedence:
|
||||
// 1. UNIFIED_SEARCH_MODEL env — pin a small, fast model that can stay
|
||||
// co-resident with CLIP (and the chat model) so translation never
|
||||
// evicts them. This is the recommended setup on a tight VRAM budget.
|
||||
// 2. the client-selected model — routes translation to whatever the user
|
||||
// already has loaded (no swap) when no dedicated model is pinned.
|
||||
// 3. None → resolve_backend uses the configured default local model.
|
||||
let translation_model = std::env::var("UNIFIED_SEARCH_MODEL")
|
||||
.ok()
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.or_else(|| query.model.clone())
|
||||
.filter(|m| !m.trim().is_empty());
|
||||
let overrides = SamplingOverrides {
|
||||
model: translation_model,
|
||||
num_ctx: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
min_p: None,
|
||||
enable_thinking: None,
|
||||
};
|
||||
let backend = match state
|
||||
.insight_generator
|
||||
.resolve_backend(BackendKind::Local, &overrides)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: resolve_backend failed: {e:?}");
|
||||
return HttpResponse::ServiceUnavailable().json(ErrorBody {
|
||||
error: "LLM backend unavailable".into(),
|
||||
});
|
||||
}
|
||||
};
|
||||
log::info!("unified_search: translating with model={}", backend.model());
|
||||
|
||||
// The owner's day and zone, not UTC's: relative dates ("last week")
|
||||
// anchor on the owner's today, and ISO dates become display-zone day
|
||||
// bounds that the true-instant filters compare against (#152).
|
||||
let display_zone = crate::date_view::owner_display_zone(&state.user_ai_prefs_dao);
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
let today = chrono::Utc::now().with_timezone(&display_zone).date_naive();
|
||||
let sq = match translate_nl_query(backend.chat(), &nl, &tag_vocab, today, display_zone).await {
|
||||
Ok(sq) => sq,
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: translate_nl_query failed: {e:?}");
|
||||
return HttpResponse::BadGateway().json(ErrorBody {
|
||||
error: "could not interpret the query".into(),
|
||||
});
|
||||
}
|
||||
let (sq, echo, explicit_tags, explicit_gps) = match merge_explicit(sq, &query) {
|
||||
Ok(merged) => merged,
|
||||
Err(msg) => return bad_request(msg),
|
||||
};
|
||||
let folder = folder_prefix(query.path.as_deref());
|
||||
|
||||
// ── 2. Forward-geocode the place name into a gps circle ──
|
||||
// (an explicit place from the client wins; merge_explicit dropped the
|
||||
// model's place in that case, so no geocode call is spent on it).
|
||||
let resolved_place = match sq.place.as_deref() {
|
||||
Some(p) => forward_geocode(p).await.map(|g| ResolvedPlace {
|
||||
display_name: g.display_name,
|
||||
@@ -234,7 +337,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
let gps = resolved_place.as_ref().map(|p| (p.lat, p.lon, p.radius_km));
|
||||
let gps = explicit_gps.or(resolved_place.as_ref().map(|p| (p.lat, p.lon, p.radius_km)));
|
||||
|
||||
let semantic = effective_semantic(&sq);
|
||||
|
||||
@@ -243,8 +346,14 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
|| sq.lens_model.is_some()
|
||||
|| sq.date_from.is_some()
|
||||
|| sq.date_to.is_some();
|
||||
let has_struct =
|
||||
has_exif_filter || gps.is_some() || !sq.tag_ids.is_empty() || sq.media_type.is_some();
|
||||
let has_struct = has_exif_filter
|
||||
|| gps.is_some()
|
||||
|| !sq.tag_ids.is_empty()
|
||||
|| sq.media_type.is_some()
|
||||
|| explicit_tags.is_some()
|
||||
|| folder.is_some();
|
||||
let display_zone = crate::date_view::owner_display_zone(&state.user_ai_prefs_dao);
|
||||
let server_zone = crate::camera_tz::server_zone();
|
||||
|
||||
// Stage trace: what the model extracted + whether a structured filter is
|
||||
// active. The chips show this to the user too, but logging it makes the
|
||||
@@ -284,6 +393,36 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
}
|
||||
}
|
||||
};
|
||||
// The client's explicit tags: their own predicate with the user's
|
||||
// ALL/ANY choice. Exclude-only becomes a "must not carry" set.
|
||||
let explicit_tag_filter: Option<(bool, HashSet<String>)> =
|
||||
explicit_tags.as_ref().map(|t| {
|
||||
let mut dao = tag_dao.lock().expect("tag dao");
|
||||
let (must_contain, files) = if t.include.is_empty() {
|
||||
(
|
||||
false,
|
||||
dao.get_files_with_any_tag_ids(t.exclude.clone(), Vec::new()),
|
||||
)
|
||||
} else if t.mode == FilterMode::Any {
|
||||
(
|
||||
true,
|
||||
dao.get_files_with_any_tag_ids(t.include.clone(), t.exclude.clone()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
true,
|
||||
dao.get_files_with_all_tag_ids(t.include.clone(), t.exclude.clone()),
|
||||
)
|
||||
};
|
||||
let files = files.unwrap_or_else(|e| {
|
||||
log::warn!("unified_search: explicit tag filter failed: {e:?}");
|
||||
Vec::new()
|
||||
});
|
||||
(
|
||||
must_contain,
|
||||
files.into_iter().map(|f| f.file_name).collect(),
|
||||
)
|
||||
});
|
||||
log::info!(
|
||||
"unified_search: tag_ids={:?} -> tag_set_files={:?}",
|
||||
sq.tag_ids,
|
||||
@@ -351,6 +490,17 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some((must_contain, ts)) = &explicit_tag_filter
|
||||
&& ts.contains(&row.file_path) != *must_contain
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Folder scope.
|
||||
if let Some(prefix) = &folder
|
||||
&& !in_folder(&row.file_path, prefix)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
@@ -410,7 +560,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
let results = resolve_hits(&exif_dao, &page);
|
||||
HttpResponse::Ok().json(UnifiedResponse {
|
||||
query: nl,
|
||||
interpreted: interpreted(&sq, resolved_place),
|
||||
interpreted: interpreted(&echo, resolved_place),
|
||||
model_version: Some(scored.model_version),
|
||||
considered: scored.considered,
|
||||
total_matching,
|
||||
@@ -443,7 +593,7 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
};
|
||||
HttpResponse::Ok().json(UnifiedResponse {
|
||||
query: nl,
|
||||
interpreted: interpreted(&sq, resolved_place),
|
||||
interpreted: interpreted(&echo, resolved_place),
|
||||
model_version: None,
|
||||
considered: 0,
|
||||
total_matching,
|
||||
@@ -454,6 +604,79 @@ pub async fn unified_search<TagD: TagDao>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate `nl` into a structured query with the local LLM, grounded on the
|
||||
/// real tag vocabulary. Errors are ready-to-send responses.
|
||||
async fn translate<TagD: TagDao>(
|
||||
state: &AppState,
|
||||
tag_dao: &Mutex<TagD>,
|
||||
query: &UnifiedQuery,
|
||||
nl: &str,
|
||||
) -> Result<StructuredQuery, HttpResponse> {
|
||||
let tag_vocab: Vec<(i32, String)> = {
|
||||
let mut dao = tag_dao.lock().expect("tag dao");
|
||||
match dao.get_all_tags(None) {
|
||||
Ok(tags) => tags.into_iter().map(|(_, t)| (t.id, t.name)).collect(),
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: get_all_tags failed: {e:?}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Respect env/config for the LLM backend (LLM_BACKEND → ollama or
|
||||
// llama-swap); local only, no hybrid, per the feature's design.
|
||||
//
|
||||
// Translation-model precedence:
|
||||
// 1. UNIFIED_SEARCH_MODEL env — pin a small, fast model that can stay
|
||||
// co-resident with CLIP (and the chat model) so translation never
|
||||
// evicts them. This is the recommended setup on a tight VRAM budget.
|
||||
// 2. the client-selected model — routes translation to whatever the user
|
||||
// already has loaded (no swap) when no dedicated model is pinned.
|
||||
// 3. None → resolve_backend uses the configured default local model.
|
||||
let translation_model = std::env::var("UNIFIED_SEARCH_MODEL")
|
||||
.ok()
|
||||
.filter(|m| !m.trim().is_empty())
|
||||
.or_else(|| query.model.clone())
|
||||
.filter(|m| !m.trim().is_empty());
|
||||
let overrides = SamplingOverrides {
|
||||
model: translation_model,
|
||||
num_ctx: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
top_k: None,
|
||||
min_p: None,
|
||||
enable_thinking: None,
|
||||
};
|
||||
let backend = match state
|
||||
.insight_generator
|
||||
.resolve_backend(BackendKind::Local, &overrides)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
log::warn!("unified_search: resolve_backend failed: {e:?}");
|
||||
return Err(HttpResponse::ServiceUnavailable().json(ErrorBody {
|
||||
error: "LLM backend unavailable".into(),
|
||||
}));
|
||||
}
|
||||
};
|
||||
log::info!("unified_search: translating with model={}", backend.model());
|
||||
|
||||
// The owner's day and zone, not UTC's: relative dates ("last week")
|
||||
// anchor on the owner's today, and ISO dates become display-zone day
|
||||
// bounds that the true-instant filters compare against (#152).
|
||||
let display_zone = crate::date_view::owner_display_zone(&state.user_ai_prefs_dao);
|
||||
let today = chrono::Utc::now().with_timezone(&display_zone).date_naive();
|
||||
translate_nl_query(backend.chat(), nl, &tag_vocab, today, display_zone)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("unified_search: translate_nl_query failed: {e:?}");
|
||||
HttpResponse::BadGateway().json(ErrorBody {
|
||||
error: "could not interpret the query".into(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Slice a sorted hit list at `[offset, offset+limit)`, tolerating
|
||||
/// out-of-range offsets (empty page).
|
||||
fn paginate(hits: &[(f32, String)], offset: usize, limit: usize) -> Vec<(f32, String)> {
|
||||
@@ -513,6 +736,112 @@ mod tests {
|
||||
assert_eq!(effective_semantic(&sq).as_deref(), Some("disco"));
|
||||
}
|
||||
|
||||
fn parse(qs: &str) -> UnifiedQuery {
|
||||
Query::<UnifiedQuery>::from_query(qs)
|
||||
.expect("valid query string")
|
||||
.into_inner()
|
||||
}
|
||||
|
||||
fn model_guess() -> StructuredQuery {
|
||||
StructuredQuery {
|
||||
semantic: Some("dog".into()),
|
||||
tag_ids: vec![1, 2],
|
||||
exclude_tag_ids: vec![3],
|
||||
camera_make: Some("Canon".into()),
|
||||
date_from: Some(100),
|
||||
date_to: Some(200),
|
||||
media_type: Some("photo".into()),
|
||||
place: Some("Paris".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_defaults_to_interpreting_with_no_explicit_filters() {
|
||||
let q = parse("q=dog");
|
||||
assert!(q.interpret);
|
||||
let (effective, echo, tags, gps) = merge_explicit(model_guess(), &q).unwrap();
|
||||
assert_eq!(effective, model_guess());
|
||||
assert_eq!(echo, model_guess());
|
||||
assert_eq!(tags, None);
|
||||
assert_eq!(gps, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_filters_replace_the_models_and_leave_the_echo() {
|
||||
let q = parse(
|
||||
"q=dog&interpret=false&media_type=video&date_from=500&camera_model=Nexus%206\
|
||||
&gps_lat=1.5&gps_lon=2.5&gps_radius_km=10",
|
||||
);
|
||||
assert!(!q.interpret);
|
||||
let (effective, echo, _, gps) = merge_explicit(model_guess(), &q).unwrap();
|
||||
|
||||
assert_eq!(effective.media_type.as_deref(), Some("video"));
|
||||
// A date range is one filter: the model's upper bound goes too.
|
||||
assert_eq!((effective.date_from, effective.date_to), (Some(500), None));
|
||||
assert_eq!(effective.camera_model.as_deref(), Some("Nexus 6"));
|
||||
// Not overridden: the model's make still applies.
|
||||
assert_eq!(effective.camera_make.as_deref(), Some("Canon"));
|
||||
assert_eq!(effective.place, None);
|
||||
assert_eq!(gps, Some((1.5, 2.5, 10.0)));
|
||||
|
||||
// The echo keeps only what the model added on top.
|
||||
assert_eq!(echo.media_type, None);
|
||||
assert_eq!((echo.date_from, echo.date_to), (None, None));
|
||||
assert_eq!(echo.camera_model, None);
|
||||
assert_eq!(echo.camera_make.as_deref(), Some("Canon"));
|
||||
assert_eq!(echo.place, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_tags_become_their_own_predicate() {
|
||||
let q = parse("q=dog&tag_ids=2,7&exclude_tag_ids=1&tag_filter_mode=Any");
|
||||
let (effective, echo, tags, _) = merge_explicit(model_guess(), &q).unwrap();
|
||||
assert_eq!(
|
||||
tags,
|
||||
Some(ExplicitTags {
|
||||
include: vec![2, 7],
|
||||
exclude: vec![1],
|
||||
mode: FilterMode::Any,
|
||||
})
|
||||
);
|
||||
// The model's guesses lose tags the user already decided on.
|
||||
assert!(effective.tag_ids.is_empty());
|
||||
assert!(echo.tag_ids.is_empty());
|
||||
assert_eq!(echo.exclude_tag_ids, vec![3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_tags_default_to_all_mode() {
|
||||
let q = parse("q=dog&tag_ids=5");
|
||||
let (_, _, tags, _) = merge_explicit(StructuredQuery::default(), &q).unwrap();
|
||||
assert_eq!(tags.unwrap().mode, FilterMode::All);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_type_all_is_no_filter() {
|
||||
let q = parse("q=dog&media_type=all");
|
||||
let (effective, _, _, _) = merge_explicit(model_guess(), &q).unwrap();
|
||||
assert_eq!(effective.media_type.as_deref(), Some("photo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_gps_and_bad_ids_are_rejected() {
|
||||
assert!(merge_explicit(StructuredQuery::default(), &parse("q=a&gps_lat=1")).is_err());
|
||||
assert!(merge_explicit(StructuredQuery::default(), &parse("q=a&tag_ids=1,x")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_prefix_matches_whole_segments() {
|
||||
assert_eq!(folder_prefix(None), None);
|
||||
assert_eq!(folder_prefix(Some("/")), None);
|
||||
let p = folder_prefix(Some("2019\\Trip/")).unwrap();
|
||||
assert_eq!(p, "2019/Trip/");
|
||||
assert!(in_folder("2019/Trip/a.jpg", &p));
|
||||
assert!(in_folder("2019\\Trip\\sub\\b.jpg", &p));
|
||||
assert!(!in_folder("2019/Trips/c.jpg", &p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_handles_out_of_range_offset() {
|
||||
let hits = vec![(0.9, "a".to_string()), (0.8, "b".to_string())];
|
||||
|
||||
Reference in new issue
Block a user