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.
|
// 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.
|
// 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)
|
// Video streaming (HLS, the default playback path)
|
||||||
POST /video/generate (queues .m3u8 playlist + .ts segments, returns playlist_url)
|
POST /video/generate (queues .m3u8 playlist + .ts segments, returns playlist_url)
|
||||||
GET /video/hls/<hash>/<file> (serves playlist / segments)
|
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)
|
GET /video/original/stream?token=... (Range-capable original bytes)
|
||||||
|
|
||||||
// Tags
|
// 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)
|
POST /image/tags (add tag to file)
|
||||||
DELETE /image/tags (remove tag from file)
|
DELETE /image/tags (remove tag from file)
|
||||||
POST /image/tags/batch (bulk tag updates)
|
POST /image/tags/batch (bulk tag updates)
|
||||||
|
|||||||
@@ -388,6 +388,12 @@ pub trait ExifDao: Sync + Send {
|
|||||||
/// Get distinct camera makes with counts
|
/// Get distinct camera makes with counts
|
||||||
fn get_camera_makes(&mut self) -> Result<Vec<(String, i64)>, DbError>;
|
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
|
/// Update file path in EXIF database
|
||||||
fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError>;
|
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))
|
.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> {
|
fn update_file_path(&mut self, old_path: &str, new_path: &str) -> Result<(), DbError> {
|
||||||
trace_db_call("update", "update_file_path", |_span| {
|
trace_db_call("update", "update_file_path", |_span| {
|
||||||
use schema::image_exif::dsl::*;
|
use schema::image_exif::dsl::*;
|
||||||
@@ -2584,6 +2615,56 @@ mod exif_dao_tests {
|
|||||||
assert_eq!(lib2[0].library_id, 2);
|
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.
|
/// Row with the full date triple, for the true-instant filter tests.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn insert_dated_row(
|
fn insert_dated_row(
|
||||||
|
|||||||
+23
@@ -213,9 +213,22 @@ async fn get_all_tags<D: TagDao>(
|
|||||||
.map(|(make, count)| CameraMakeCount { make, count })
|
.map(|(make, count)| CameraMakeCount { make, count })
|
||||||
.collect::<Vec<CameraMakeCount>>();
|
.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 {
|
HttpResponse::Ok().json(AllTagsResponse {
|
||||||
tags: tags_response,
|
tags: tags_response,
|
||||||
camera_makes,
|
camera_makes,
|
||||||
|
cameras,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.into_http_internal_err()
|
.into_http_internal_err()
|
||||||
@@ -465,10 +478,20 @@ pub struct CameraMakeCount {
|
|||||||
pub count: i64,
|
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)]
|
#[derive(Serialize, Debug)]
|
||||||
pub struct AllTagsResponse {
|
pub struct AllTagsResponse {
|
||||||
pub tags: Vec<TagWithTagCount>,
|
pub tags: Vec<TagWithTagCount>,
|
||||||
pub camera_makes: Vec<CameraMakeCount>,
|
pub camera_makes: Vec<CameraMakeCount>,
|
||||||
|
pub cameras: Vec<CameraCount>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Insertable, Clone, Debug)]
|
#[derive(Insertable, Clone, Debug)]
|
||||||
|
|||||||
+397
-68
@@ -25,7 +25,7 @@ use crate::ai::nl_query::{StructuredQuery, translate_nl_query};
|
|||||||
use crate::clip_search::{
|
use crate::clip_search::{
|
||||||
SearchHit, parse_library_scope, resolve_hits, score_error_response, score_photos,
|
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::database::ExifDao;
|
||||||
use crate::file_types::{is_image_file, is_video_file};
|
use crate::file_types::{is_image_file, is_video_file};
|
||||||
use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance};
|
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
|
/// (avoids a llama-swap eviction / cold start). Falls back to the configured
|
||||||
/// default local model when absent. Local only — no hybrid here.
|
/// default local model when absent. Local only — no hybrid here.
|
||||||
pub model: Option<String>,
|
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 {
|
fn default_limit() -> usize {
|
||||||
@@ -66,6 +92,129 @@ fn default_limit() -> usize {
|
|||||||
fn default_threshold() -> f32 {
|
fn default_threshold() -> f32 {
|
||||||
0.20
|
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
|
/// A geocoded place echoed back so the client can show / edit the location
|
||||||
/// filter it actually searched.
|
/// 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 ──
|
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
|
||||||
let tag_vocab: Vec<(i32, String)> = {
|
// (or take it verbatim as the CLIP term when interpretation is off).
|
||||||
let mut dao = tag_dao.lock().expect("tag dao");
|
let sq = if query.interpret {
|
||||||
match dao.get_all_tags(None) {
|
match translate(&state, &tag_dao, &query, &nl).await {
|
||||||
Ok(tags) => tags.into_iter().map(|(_, t)| (t.id, t.name)).collect(),
|
Ok(sq) => sq,
|
||||||
Err(e) => {
|
Err(resp) => return resp,
|
||||||
log::warn!("unified_search: get_all_tags failed: {e:?}");
|
}
|
||||||
Vec::new()
|
} else {
|
||||||
}
|
StructuredQuery {
|
||||||
|
semantic: Some(nl.clone()),
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let (sq, echo, explicit_tags, explicit_gps) = match merge_explicit(sq, &query) {
|
||||||
// Respect env/config for the LLM backend (LLM_BACKEND → ollama or
|
Ok(merged) => merged,
|
||||||
// llama-swap); local only, no hybrid, per the feature's design.
|
Err(msg) => return bad_request(msg),
|
||||||
//
|
|
||||||
// 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 folder = folder_prefix(query.path.as_deref());
|
||||||
|
|
||||||
// ── 2. Forward-geocode the place name into a gps circle ──
|
// ── 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() {
|
let resolved_place = match sq.place.as_deref() {
|
||||||
Some(p) => forward_geocode(p).await.map(|g| ResolvedPlace {
|
Some(p) => forward_geocode(p).await.map(|g| ResolvedPlace {
|
||||||
display_name: g.display_name,
|
display_name: g.display_name,
|
||||||
@@ -234,7 +337,7 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
}),
|
}),
|
||||||
None => None,
|
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);
|
let semantic = effective_semantic(&sq);
|
||||||
|
|
||||||
@@ -243,8 +346,14 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
|| sq.lens_model.is_some()
|
|| sq.lens_model.is_some()
|
||||||
|| sq.date_from.is_some()
|
|| sq.date_from.is_some()
|
||||||
|| sq.date_to.is_some();
|
|| sq.date_to.is_some();
|
||||||
let has_struct =
|
let has_struct = has_exif_filter
|
||||||
has_exif_filter || gps.is_some() || !sq.tag_ids.is_empty() || sq.media_type.is_some();
|
|| 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
|
// 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
|
// 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!(
|
log::info!(
|
||||||
"unified_search: tag_ids={:?} -> tag_set_files={:?}",
|
"unified_search: tag_ids={:?} -> tag_set_files={:?}",
|
||||||
sq.tag_ids,
|
sq.tag_ids,
|
||||||
@@ -351,6 +490,17 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
{
|
{
|
||||||
return false;
|
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
|
true
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -410,7 +560,7 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
let results = resolve_hits(&exif_dao, &page);
|
let results = resolve_hits(&exif_dao, &page);
|
||||||
HttpResponse::Ok().json(UnifiedResponse {
|
HttpResponse::Ok().json(UnifiedResponse {
|
||||||
query: nl,
|
query: nl,
|
||||||
interpreted: interpreted(&sq, resolved_place),
|
interpreted: interpreted(&echo, resolved_place),
|
||||||
model_version: Some(scored.model_version),
|
model_version: Some(scored.model_version),
|
||||||
considered: scored.considered,
|
considered: scored.considered,
|
||||||
total_matching,
|
total_matching,
|
||||||
@@ -443,7 +593,7 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
};
|
};
|
||||||
HttpResponse::Ok().json(UnifiedResponse {
|
HttpResponse::Ok().json(UnifiedResponse {
|
||||||
query: nl,
|
query: nl,
|
||||||
interpreted: interpreted(&sq, resolved_place),
|
interpreted: interpreted(&echo, resolved_place),
|
||||||
model_version: None,
|
model_version: None,
|
||||||
considered: 0,
|
considered: 0,
|
||||||
total_matching,
|
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
|
/// Slice a sorted hit list at `[offset, offset+limit)`, tolerating
|
||||||
/// out-of-range offsets (empty page).
|
/// out-of-range offsets (empty page).
|
||||||
fn paginate(hits: &[(f32, String)], offset: usize, limit: usize) -> Vec<(f32, String)> {
|
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"));
|
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]
|
#[test]
|
||||||
fn paginate_handles_out_of_range_offset() {
|
fn paginate_handles_out_of_range_offset() {
|
||||||
let hits = vec![(0.9, "a".to_string()), (0.8, "b".to_string())];
|
let hits = vec![(0.9, "a".to_string()), (0.8, "b".to_string())];
|
||||||
|
|||||||
Reference in new issue
Block a user