feat(search): explicit filters on unified search; list camera bodies
The app's new search screen keeps tags, type, date, camera and place as chips and sends free text to the unified endpoint. For the AI to add to those chips instead of replacing them, the endpoint now takes them: - /photos/search/unified accepts the /photos filters explicitly (tag_ids with All/Any, exclude_tag_ids, media_type, dates, camera make/model, gps circle) plus a recursive `path`. Explicit tags are their own predicate on top of the model's ANY-mode tags; single-valued filters replace the model's guess. `interpreted` echoes only what the model added, so the client doesn't show the user's own chips twice. - interpret=false skips the LLM and uses q verbatim as the CLIP term, which replaces the app's separate "Semantic" mode with a visual match inside the user's filters. - /image/tags/all also returns `cameras` (make + model with counts) so the camera picker can offer e.g. "Nexus 6" directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed52a07d9d
commit
015ddd6c77
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