Skip to main content

smriti/services/
assistant.rs

1//! Assistant photo tools.
2//!
3//! This module is deliberately deterministic for the first Assistant release:
4//! it turns a user request into typed photo filters, previews the candidate
5//! photo set, and requires an approval token before creating anything. Provider
6//! planning should drive these same typed tool boundaries instead of growing a
7//! prompt-phrase table in application code.
8
9use std::collections::HashSet;
10
11use rusqlite::{
12    params_from_iter, types::Value, Connection, OptionalExtension, Result as SqliteResult,
13};
14use serde::{Deserialize, Serialize};
15
16use crate::db::album_repo::AlbumRepo;
17use crate::search::date_parser::{DateParser, DateRange};
18
19const MAX_PREVIEW_SAMPLE: usize = 12;
20const MAX_ALBUM_PHOTOS: usize = 10_000;
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum AssistantRunStatus {
25    Running,
26    WaitingForApproval,
27    WaitingForClarification,
28    ResultsReady,
29    Completed,
30    Stopped,
31    Failed,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AssistantActivity {
36    pub label: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct AssistantPersonRef {
41    pub id: i64,
42    pub name: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AssistantPlaceRef {
47    pub city: Option<String>,
48    pub country: Option<String>,
49    pub label: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct AssistantDateRef {
54    pub start: String,
55    pub end: String,
56    pub label: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct AssistantPhotoSample {
61    pub id: i64,
62    pub thumbnail_path: Option<String>,
63    pub date_taken: Option<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct AssistantAlbumPreview {
68    pub approval_id: String,
69    pub album_name: String,
70    pub photo_count: usize,
71    pub sample: Vec<AssistantPhotoSample>,
72    pub people: Vec<AssistantPersonRef>,
73    pub places: Vec<AssistantPlaceRef>,
74    pub date: Option<AssistantDateRef>,
75    pub media_type: Option<String>,
76    pub people_only: bool,
77    pub semantic_text: Option<String>,
78    pub intent: AssistantIntent,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct AssistantRun {
83    pub run_id: String,
84    pub library_root: String,
85    pub status: AssistantRunStatus,
86    pub message: String,
87    pub response: Option<String>,
88    #[serde(default)]
89    pub clarification_options: Vec<String>,
90    pub activity: Vec<AssistantActivity>,
91    pub preview: Option<AssistantAlbumPreview>,
92    pub album_id: Option<i64>,
93}
94
95#[derive(Debug, Clone)]
96pub struct AssistantDraft {
97    pub album_name: String,
98    pub photo_ids: Vec<i64>,
99    pub preview: AssistantAlbumPreview,
100}
101
102#[derive(Debug, Clone, Default)]
103pub struct AssistantRequestPlan {
104    pub people: Vec<AssistantPersonRef>,
105    pub places: Vec<AssistantPlaceRef>,
106    pub date: Option<AssistantDateRef>,
107    pub date_range: Option<DateRange>,
108    pub media_type: Option<String>,
109    pub people_only: bool,
110    pub semantic_text: Option<String>,
111    pub intent: AssistantIntent,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct AssistantSearchArgs {
116    #[serde(default)]
117    pub person_ids: Vec<i64>,
118    #[serde(default)]
119    pub places: Vec<AssistantPlaceRef>,
120    pub date_phrase: Option<String>,
121    pub media_type: Option<String>,
122    #[serde(default)]
123    pub people_only: bool,
124    pub semantic_text: Option<String>,
125    #[serde(default)]
126    pub include_photo_ids: Vec<i64>,
127    #[serde(default)]
128    pub exclude_photo_ids: Vec<i64>,
129    #[serde(default = "default_combine_mode")]
130    pub combine_mode: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct AssistantResolvedPeople {
135    pub matches: Vec<AssistantPersonRef>,
136    pub missing: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct AssistantResolvedPlaces {
141    pub matches: Vec<AssistantPlaceRef>,
142    pub missing: Vec<String>,
143    pub candidates: Vec<AssistantPlaceCandidate>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct AssistantPlaceCandidate {
148    pub query: String,
149    pub candidates: Vec<AssistantPlaceRef>,
150}
151
152fn default_combine_mode() -> String {
153    "intersect".into()
154}
155
156#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
157#[serde(rename_all = "snake_case")]
158pub enum AssistantIntent {
159    CreateAlbum,
160    #[default]
161    Search,
162}
163
164pub struct AssistantService;
165
166impl AssistantService {
167    pub fn preview_album(
168        conn: &Connection,
169        message: &str,
170        approval_id: String,
171        semantic_photo_ids: &[i64],
172    ) -> SqliteResult<(AssistantDraft, Vec<AssistantActivity>)> {
173        Self::preview_album_with_activity(conn, message, approval_id, semantic_photo_ids, |_| {})
174    }
175
176    pub fn preview_album_with_activity(
177        conn: &Connection,
178        message: &str,
179        approval_id: String,
180        semantic_photo_ids: &[i64],
181        mut on_activity: impl FnMut(&AssistantActivity),
182    ) -> SqliteResult<(AssistantDraft, Vec<AssistantActivity>)> {
183        let mut activity = Vec::new();
184        push_activity(&mut activity, &mut on_activity, "Reading request");
185        let plan = Self::plan_request(conn, message)?;
186        if !plan.people.is_empty() {
187            push_activity(
188                &mut activity,
189                &mut on_activity,
190                format!(
191                    "Resolving people: {}",
192                    plan.people
193                        .iter()
194                        .map(|p| p.name.as_str())
195                        .collect::<Vec<_>>()
196                        .join(", ")
197                ),
198            );
199        }
200        if let Some(date) = &plan.date {
201            push_activity(
202                &mut activity,
203                &mut on_activity,
204                format!("Checking date range: {}", date.label),
205            );
206        }
207        if !plan.places.is_empty() {
208            push_activity(
209                &mut activity,
210                &mut on_activity,
211                format!(
212                    "Resolving places: {}",
213                    plan.places
214                        .iter()
215                        .map(|p| p.label.as_str())
216                        .collect::<Vec<_>>()
217                        .join(", ")
218                ),
219            );
220        }
221        if let Some(text) = &plan.semantic_text {
222            push_activity(
223                &mut activity,
224                &mut on_activity,
225                format!("Searching visual meaning: {text}"),
226            );
227        }
228        push_activity(&mut activity, &mut on_activity, "Searching matching photos");
229
230        let matches = Self::search_candidates(conn, &plan, semantic_photo_ids)?;
231        push_activity(
232            &mut activity,
233            &mut on_activity,
234            format!("Found {} photos", matches.len()),
235        );
236
237        let album_name = Self::album_name(message, &plan);
238        let sample = matches
239            .iter()
240            .take(MAX_PREVIEW_SAMPLE)
241            .map(|m| AssistantPhotoSample {
242                id: m.id,
243                thumbnail_path: m.thumbnail_path.clone(),
244                date_taken: m.date_taken.clone(),
245            })
246            .collect();
247        let preview = AssistantAlbumPreview {
248            approval_id,
249            album_name: album_name.clone(),
250            photo_count: matches.len(),
251            sample,
252            people: plan.people.clone(),
253            places: plan.places.clone(),
254            date: plan.date.clone(),
255            media_type: plan.media_type.clone(),
256            people_only: plan.people_only,
257            semantic_text: plan.semantic_text.clone(),
258            intent: plan.intent,
259        };
260        push_activity(&mut activity, &mut on_activity, "Preparing preview");
261        if plan.intent == AssistantIntent::CreateAlbum {
262            push_activity(&mut activity, &mut on_activity, "Waiting for approval");
263        }
264        Ok((
265            AssistantDraft {
266                album_name,
267                photo_ids: matches.into_iter().map(|m| m.id).collect(),
268                preview,
269            },
270            activity,
271        ))
272    }
273
274    pub fn create_album(conn: &Connection, draft: &AssistantDraft) -> SqliteResult<i64> {
275        let repo = AlbumRepo::new(conn);
276        let album_id = repo.create_with_source(&draft.album_name, "agent")?;
277        repo.add_photos(album_id, &draft.photo_ids)?;
278        repo.auto_pick_cover(album_id)?;
279        Ok(album_id)
280    }
281
282    pub fn resolve_people_queries(
283        conn: &Connection,
284        queries: &[String],
285    ) -> SqliteResult<AssistantResolvedPeople> {
286        let all = load_people(conn)?;
287        let mut matches = Vec::new();
288        let mut missing = Vec::new();
289        let mut seen = HashSet::new();
290        for query in queries {
291            let normalized = normalize_lookup(query);
292            if normalized.is_empty() {
293                continue;
294            }
295            let mut found = Vec::new();
296            for person in &all {
297                let name = normalize_lookup(&person.name);
298                if name == normalized || name.contains(&normalized) || normalized.contains(&name) {
299                    found.push(person.clone());
300                }
301            }
302            if found.is_empty() {
303                missing.push(query.clone());
304            } else {
305                found.sort_by_key(|p| p.name.len());
306                for person in found.into_iter().take(3) {
307                    if seen.insert(person.id) {
308                        matches.push(person);
309                    }
310                }
311            }
312        }
313        Ok(AssistantResolvedPeople { matches, missing })
314    }
315
316    pub fn resolve_place_queries(
317        conn: &Connection,
318        queries: &[String],
319    ) -> SqliteResult<AssistantResolvedPlaces> {
320        let all = load_places(conn)?;
321        let mut matches = Vec::new();
322        let mut missing = Vec::new();
323        let mut candidates = Vec::new();
324        let mut seen = HashSet::new();
325        for query in queries {
326            let normalized = normalize_lookup(query);
327            if normalized.is_empty() {
328                continue;
329            }
330            let mut found = Vec::new();
331            for place in &all {
332                let label = normalize_lookup(&place.label);
333                let city = place
334                    .city
335                    .as_deref()
336                    .map(normalize_lookup)
337                    .unwrap_or_default();
338                let country = place
339                    .country
340                    .as_deref()
341                    .map(normalize_lookup)
342                    .unwrap_or_default();
343                if label == normalized
344                    || city == normalized
345                    || country == normalized
346                    || label.contains(&normalized)
347                    || (!city.is_empty() && normalized.contains(&city))
348                {
349                    found.push(place.clone());
350                }
351            }
352            if found.is_empty() {
353                let fuzzy = place_candidates(&all, &normalized);
354                if fuzzy.is_empty() {
355                    missing.push(query.clone());
356                } else {
357                    candidates.push(AssistantPlaceCandidate {
358                        query: query.clone(),
359                        candidates: fuzzy,
360                    });
361                }
362            } else {
363                found.sort_by_key(|p| p.label.len());
364                for place in found.into_iter().take(3) {
365                    let key = place.label.to_lowercase();
366                    if seen.insert(key) {
367                        matches.push(place);
368                    }
369                }
370            }
371        }
372        Ok(AssistantResolvedPlaces {
373            matches,
374            missing,
375            candidates,
376        })
377    }
378
379    pub fn resolve_date_phrase(phrase: &str) -> Option<AssistantDateRef> {
380        DateParser::parse(phrase).map(|range| AssistantDateRef {
381            start: range.start.to_rfc3339(),
382            end: range.end.to_rfc3339(),
383            label: phrase.trim().to_string(),
384        })
385    }
386
387    pub fn search_with_args(
388        conn: &Connection,
389        args: &AssistantSearchArgs,
390        semantic_photo_ids: &[i64],
391    ) -> SqliteResult<AssistantDraft> {
392        let date = args
393            .date_phrase
394            .as_deref()
395            .and_then(DateParser::parse)
396            .map(|range| AssistantDateRef {
397                start: range.start.to_rfc3339(),
398                end: range.end.to_rfc3339(),
399                label: args.date_phrase.clone().unwrap_or_default(),
400            });
401        let date_range = args.date_phrase.as_deref().and_then(DateParser::parse);
402        let mut people = Vec::new();
403        for id in &args.person_ids {
404            if let Some(person) = load_person(conn, *id)? {
405                people.push(person);
406            }
407        }
408        let plan = AssistantRequestPlan {
409            people,
410            places: args.places.clone(),
411            date,
412            date_range,
413            media_type: args.media_type.clone(),
414            people_only: args.people_only,
415            semantic_text: args.semantic_text.clone(),
416            intent: AssistantIntent::Search,
417        };
418        let mut matches = Self::search_candidates(conn, &plan, semantic_photo_ids)?;
419        if !args.exclude_photo_ids.is_empty() {
420            let exclude = args
421                .exclude_photo_ids
422                .iter()
423                .copied()
424                .collect::<HashSet<_>>();
425            matches.retain(|m| !exclude.contains(&m.id));
426        }
427        if !args.include_photo_ids.is_empty() {
428            let mut seen = matches.iter().map(|m| m.id).collect::<HashSet<_>>();
429            if args.combine_mode == "union" {
430                for id in &args.include_photo_ids {
431                    if seen.insert(*id) {
432                        if let Some(photo) = load_candidate_photo(conn, *id)? {
433                            matches.push(photo);
434                        }
435                    }
436                }
437            } else {
438                let include = args
439                    .include_photo_ids
440                    .iter()
441                    .copied()
442                    .collect::<HashSet<_>>();
443                matches.retain(|m| include.contains(&m.id));
444            }
445        }
446        let sample = matches
447            .iter()
448            .take(MAX_PREVIEW_SAMPLE)
449            .map(|m| AssistantPhotoSample {
450                id: m.id,
451                thumbnail_path: m.thumbnail_path.clone(),
452                date_taken: m.date_taken.clone(),
453            })
454            .collect();
455        let album_name = "Assistant album".to_string();
456        let preview = AssistantAlbumPreview {
457            approval_id: String::new(),
458            album_name: album_name.clone(),
459            photo_count: matches.len(),
460            sample,
461            people: plan.people,
462            places: plan.places,
463            date: plan.date,
464            media_type: plan.media_type,
465            people_only: plan.people_only,
466            semantic_text: plan.semantic_text,
467            intent: AssistantIntent::Search,
468        };
469        Ok(AssistantDraft {
470            album_name,
471            photo_ids: matches.into_iter().map(|m| m.id).collect(),
472            preview,
473        })
474    }
475
476    pub fn preview_from_photo_ids(
477        conn: &Connection,
478        photo_ids: &[i64],
479        album_name: &str,
480        approval_id: String,
481        intent: AssistantIntent,
482    ) -> SqliteResult<AssistantDraft> {
483        let mut matches = Vec::new();
484        let mut seen = HashSet::new();
485        for id in photo_ids.iter().take(MAX_ALBUM_PHOTOS) {
486            if seen.insert(*id) {
487                if let Some(photo) = load_candidate_photo(conn, *id)? {
488                    matches.push(photo);
489                }
490            }
491        }
492        let sample = matches
493            .iter()
494            .take(MAX_PREVIEW_SAMPLE)
495            .map(|m| AssistantPhotoSample {
496                id: m.id,
497                thumbnail_path: m.thumbnail_path.clone(),
498                date_taken: m.date_taken.clone(),
499            })
500            .collect();
501        let clean_name = sanitize_album_name(album_name);
502        let preview = AssistantAlbumPreview {
503            approval_id,
504            album_name: clean_name.clone(),
505            photo_count: matches.len(),
506            sample,
507            people: Vec::new(),
508            places: Vec::new(),
509            date: None,
510            media_type: None,
511            people_only: false,
512            semantic_text: None,
513            intent,
514        };
515        Ok(AssistantDraft {
516            album_name: clean_name,
517            photo_ids: matches.into_iter().map(|m| m.id).collect(),
518            preview,
519        })
520    }
521
522    pub fn plan_request(conn: &Connection, message: &str) -> SqliteResult<AssistantRequestPlan> {
523        let mut plan = AssistantRequestPlan {
524            intent: request_intent(message),
525            ..Default::default()
526        };
527        let mut text = remove_tool_words(message, plan.intent);
528        let lower = text.to_lowercase();
529        plan.people_only = lower.contains(" only ") || lower.starts_with("only ");
530
531        if lower.contains(" video") || lower.contains(" videos") {
532            plan.media_type = Some("video".into());
533            text = remove_words(&text, &["video", "videos"]);
534        } else if lower.contains(" photo")
535            || lower.contains(" photos")
536            || lower.contains(" pictures")
537        {
538            plan.media_type = Some("photo".into());
539            text = remove_words(&text, &["photo", "photos", "picture", "pictures"]);
540        }
541
542        if let Some((date_label, range)) = extract_date(&text) {
543            text = remove_phrase(&text, &date_label);
544            plan.date = Some(AssistantDateRef {
545                start: range.start.to_rfc3339(),
546                end: range.end.to_rfc3339(),
547                label: date_label,
548            });
549            plan.date_range = Some(range);
550        }
551
552        let people = resolve_people(conn, &text)?;
553        for person in &people {
554            text = remove_phrase(&text, &person.name);
555        }
556        plan.people = people;
557
558        let places = resolve_places(conn, &text)?;
559        for place in &places {
560            text = remove_phrase(&text, &place.label);
561        }
562        plan.places = places;
563
564        let semantic = clean_semantic_text(&text);
565        if !semantic.is_empty() {
566            plan.semantic_text = Some(semantic);
567        }
568        Ok(plan)
569    }
570
571    fn search_candidates(
572        conn: &Connection,
573        plan: &AssistantRequestPlan,
574        semantic_photo_ids: &[i64],
575    ) -> SqliteResult<Vec<CandidatePhoto>> {
576        let mut sql = String::from(
577            "SELECT p.id, p.thumbnail_path, p.date_taken FROM photos p WHERE p.is_trashed = FALSE",
578        );
579        let mut bind: Vec<Value> = Vec::new();
580
581        if let Some(range) = &plan.date_range {
582            sql.push_str(" AND p.date_taken >= ? AND p.date_taken <= ?");
583            bind.push(Value::Text(range.start.to_rfc3339()));
584            bind.push(Value::Text(range.end.to_rfc3339()));
585        }
586        if let Some(media) = &plan.media_type {
587            sql.push_str(" AND p.media_type = ?");
588            bind.push(Value::Text(media.clone()));
589        }
590        for place in &plan.places {
591            if let Some(city) = &place.city {
592                sql.push_str(" AND LOWER(p.location_city) = LOWER(?)");
593                bind.push(Value::Text(city.clone()));
594            }
595            if let Some(country) = &place.country {
596                sql.push_str(" AND LOWER(p.location_country) = LOWER(?)");
597                bind.push(Value::Text(country.clone()));
598            }
599        }
600        for person in &plan.people {
601            sql.push_str(
602                " AND (EXISTS (
603                    SELECT 1 FROM faces f
604                    WHERE f.photo_id = p.id AND f.cluster_id = ?
605                ) OR EXISTS (
606                    SELECT 1 FROM photo_inferred_identities pii
607                    WHERE pii.photo_id = p.id AND pii.cluster_id = ?
608                ))",
609            );
610            bind.push(Value::Integer(person.id));
611            bind.push(Value::Integer(person.id));
612        }
613        if plan.people_only && !plan.people.is_empty() {
614            sql.push_str(" AND p.faces_processed = TRUE");
615            let placeholders = std::iter::repeat_n("?", plan.people.len())
616                .collect::<Vec<_>>()
617                .join(",");
618            sql.push_str(&format!(
619                " AND NOT EXISTS (
620                    SELECT 1 FROM faces f
621                    WHERE f.photo_id = p.id
622                      AND (f.cluster_id IS NULL OR f.cluster_id NOT IN ({placeholders}))
623                )"
624            ));
625            for person in &plan.people {
626                bind.push(Value::Integer(person.id));
627            }
628            sql.push_str(&format!(
629                " AND NOT EXISTS (
630                    SELECT 1 FROM photo_inferred_identities pii
631                    WHERE pii.photo_id = p.id AND pii.cluster_id NOT IN ({placeholders})
632                )"
633            ));
634            for person in &plan.people {
635                bind.push(Value::Integer(person.id));
636            }
637        }
638        if !semantic_photo_ids.is_empty() {
639            sql.push_str(&format!(
640                " AND p.id IN ({})",
641                std::iter::repeat_n("?", semantic_photo_ids.len())
642                    .collect::<Vec<_>>()
643                    .join(",")
644            ));
645            for id in semantic_photo_ids {
646                bind.push(Value::Integer(*id));
647            }
648        } else if let Some(text) = &plan.semantic_text {
649            sql.push_str(
650                " AND (
651                    LOWER(p.file_name) LIKE LOWER(?) OR
652                    LOWER(p.location_city) LIKE LOWER(?) OR
653                    LOWER(p.location_country) LIKE LOWER(?) OR
654                    LOWER(p.camera_make) LIKE LOWER(?) OR
655                    LOWER(p.camera_model) LIKE LOWER(?)
656                )",
657            );
658            let like = Value::Text(format!("%{text}%"));
659            for _ in 0..5 {
660                bind.push(like.clone());
661            }
662        }
663
664        sql.push_str(&format!(
665            " ORDER BY p.date_taken DESC, p.id DESC LIMIT {}",
666            MAX_ALBUM_PHOTOS
667        ));
668        let mut stmt = conn.prepare(&sql)?;
669        let rows = stmt.query_map(params_from_iter(bind.iter()), |row| {
670            Ok(CandidatePhoto {
671                id: row.get(0)?,
672                thumbnail_path: row.get(1)?,
673                date_taken: row.get(2)?,
674            })
675        })?;
676        let mut out = rows.collect::<SqliteResult<Vec<_>>>()?;
677        if !semantic_photo_ids.is_empty() {
678            let rank = semantic_photo_ids
679                .iter()
680                .enumerate()
681                .map(|(idx, id)| (*id, idx))
682                .collect::<std::collections::HashMap<_, _>>();
683            out.sort_by_key(|p| rank.get(&p.id).copied().unwrap_or(usize::MAX));
684        }
685        Ok(out)
686    }
687
688    fn album_name(message: &str, plan: &AssistantRequestPlan) -> String {
689        if let Some(name) = explicit_album_name(message) {
690            return name;
691        }
692        let mut parts = Vec::new();
693        if !plan.people.is_empty() {
694            parts.push(
695                plan.people
696                    .iter()
697                    .map(|p| p.name.as_str())
698                    .collect::<Vec<_>>()
699                    .join(" and "),
700            );
701        }
702        if !plan.places.is_empty() {
703            parts.push(plan.places[0].label.clone());
704        }
705        if let Some(date) = &plan.date {
706            parts.push(date.label.clone());
707        }
708        if let Some(text) = &plan.semantic_text {
709            parts.push(title_case(text));
710        }
711        if parts.is_empty() {
712            "Assistant album".into()
713        } else {
714            let title = parts.join(", ");
715            match plan.intent {
716                AssistantIntent::CreateAlbum => title,
717                AssistantIntent::Search => format!("{title} photos"),
718            }
719        }
720    }
721}
722
723fn push_activity(
724    activity: &mut Vec<AssistantActivity>,
725    on_activity: &mut impl FnMut(&AssistantActivity),
726    label: impl Into<String>,
727) {
728    let item = AssistantActivity {
729        label: label.into(),
730    };
731    on_activity(&item);
732    activity.push(item);
733}
734
735#[derive(Debug)]
736struct CandidatePhoto {
737    id: i64,
738    thumbnail_path: Option<String>,
739    date_taken: Option<String>,
740}
741
742fn request_intent(message: &str) -> AssistantIntent {
743    let tokens = normalized_tokens(message);
744    let asks_for_album = tokens.iter().any(|t| t == "album" || t == "albums");
745    let asks_to_create = tokens.iter().any(|t| {
746        matches!(
747            t.as_str(),
748            "create" | "make" | "build" | "generate" | "save" | "collect"
749        )
750    });
751    if asks_for_album && asks_to_create {
752        AssistantIntent::CreateAlbum
753    } else {
754        AssistantIntent::Search
755    }
756}
757
758fn remove_tool_words(text: &str, intent: AssistantIntent) -> String {
759    text.split_whitespace()
760        .filter(|word| {
761            let token = clean_token(word);
762            if token.is_empty() {
763                return false;
764            }
765            let is_common_tool_word = matches!(
766                token.as_str(),
767                "find"
768                    | "show"
769                    | "search"
770                    | "get"
771                    | "give"
772                    | "list"
773                    | "all"
774                    | "my"
775                    | "photo"
776                    | "photos"
777                    | "picture"
778                    | "pictures"
779            );
780            let is_album_tool_word = matches!(
781                token.as_str(),
782                "create"
783                    | "make"
784                    | "build"
785                    | "generate"
786                    | "save"
787                    | "collect"
788                    | "album"
789                    | "albums"
790                    | "collection"
791            );
792            !(is_common_tool_word || intent == AssistantIntent::CreateAlbum && is_album_tool_word)
793        })
794        .collect::<Vec<_>>()
795        .join(" ")
796}
797
798fn normalized_tokens(text: &str) -> Vec<String> {
799    text.split_whitespace()
800        .map(clean_token)
801        .filter(|token| !token.is_empty())
802        .collect()
803}
804
805fn clean_token(text: &str) -> String {
806    text.trim_matches(|c: char| !c.is_alphanumeric())
807        .to_lowercase()
808}
809
810fn resolve_people(conn: &Connection, text: &str) -> SqliteResult<Vec<AssistantPersonRef>> {
811    let lower = format!(" {} ", text.to_lowercase());
812    let rows = load_people(conn)?;
813    let mut out = Vec::new();
814    let mut seen = HashSet::new();
815    for person in rows {
816        let needle = format!(" {} ", person.name.to_lowercase());
817        if lower.contains(&needle) && seen.insert(person.id) {
818            out.push(person);
819        }
820    }
821    Ok(out)
822}
823
824fn resolve_places(conn: &Connection, text: &str) -> SqliteResult<Vec<AssistantPlaceRef>> {
825    let lower = format!(" {} ", text.to_lowercase());
826    let rows = load_places(conn)?;
827    let mut out = Vec::new();
828    let mut seen_countries = HashSet::new();
829    for place in rows {
830        let city_match = place
831            .city
832            .as_ref()
833            .map(|c| lower.contains(&format!(" {} ", c.to_lowercase())))
834            .unwrap_or(false);
835        let country_match = place
836            .country
837            .as_ref()
838            .map(|c| lower.contains(&format!(" {} ", c.to_lowercase())))
839            .unwrap_or(false);
840        if city_match {
841            out.push(place);
842        } else if country_match {
843            let Some(country) = place.country.clone() else {
844                continue;
845            };
846            if seen_countries.insert(country.to_lowercase()) {
847                out.push(AssistantPlaceRef {
848                    city: None,
849                    label: country.clone(),
850                    country: Some(country),
851                });
852            }
853        }
854    }
855    Ok(out)
856}
857
858fn load_people(conn: &Connection) -> SqliteResult<Vec<AssistantPersonRef>> {
859    let mut stmt = conn.prepare(
860        "SELECT id, name FROM face_clusters
861         WHERE name IS NOT NULL AND TRIM(name) != '' AND photo_count > 0
862         ORDER BY LENGTH(name) DESC, photo_count DESC",
863    )?;
864    let rows = stmt.query_map([], |row| {
865        Ok(AssistantPersonRef {
866            id: row.get(0)?,
867            name: row.get(1)?,
868        })
869    })?;
870    rows.collect()
871}
872
873fn load_person(conn: &Connection, id: i64) -> SqliteResult<Option<AssistantPersonRef>> {
874    conn.query_row(
875        "SELECT id, name FROM face_clusters
876         WHERE id = ?1 AND name IS NOT NULL AND TRIM(name) != ''",
877        [id],
878        |row| {
879            Ok(AssistantPersonRef {
880                id: row.get(0)?,
881                name: row.get(1)?,
882            })
883        },
884    )
885    .optional()
886}
887
888fn load_places(conn: &Connection) -> SqliteResult<Vec<AssistantPlaceRef>> {
889    let mut stmt = conn.prepare(
890        "SELECT location_city, location_country, COUNT(*) AS n
891         FROM photos
892         WHERE is_trashed = FALSE
893           AND (location_city IS NOT NULL OR location_country IS NOT NULL)
894         GROUP BY location_city, location_country
895         ORDER BY n DESC
896         LIMIT 500",
897    )?;
898    let rows = stmt.query_map([], |row| {
899        let city: Option<String> = row.get(0)?;
900        let country: Option<String> = row.get(1)?;
901        let label = match (&city, &country) {
902            (Some(c), Some(country)) => format!("{c}, {country}"),
903            (Some(c), None) => c.clone(),
904            (None, Some(country)) => country.clone(),
905            (None, None) => String::new(),
906        };
907        Ok(AssistantPlaceRef {
908            city,
909            country,
910            label,
911        })
912    })?;
913    rows.collect()
914}
915
916fn load_candidate_photo(conn: &Connection, id: i64) -> SqliteResult<Option<CandidatePhoto>> {
917    conn.query_row(
918        "SELECT id, thumbnail_path, date_taken
919         FROM photos
920         WHERE id = ?1 AND is_trashed = FALSE",
921        [id],
922        |row| {
923            Ok(CandidatePhoto {
924                id: row.get(0)?,
925                thumbnail_path: row.get(1)?,
926                date_taken: row.get(2)?,
927            })
928        },
929    )
930    .optional()
931}
932
933fn normalize_lookup(text: &str) -> String {
934    text.chars()
935        .filter(|c| c.is_alphanumeric())
936        .flat_map(char::to_lowercase)
937        .collect()
938}
939
940fn place_candidates(all: &[AssistantPlaceRef], normalized: &str) -> Vec<AssistantPlaceRef> {
941    if normalized.len() < 2 {
942        return Vec::new();
943    }
944    let first = normalized.chars().next();
945    let mut scored = all
946        .iter()
947        .filter_map(|place| {
948            let city = place
949                .city
950                .as_deref()
951                .map(normalize_lookup)
952                .unwrap_or_default();
953            let label = normalize_lookup(&place.label);
954            if city.is_empty() && label.is_empty() {
955                return None;
956            }
957            let score = if city.starts_with(normalized) || label.starts_with(normalized) {
958                0
959            } else if first.is_some_and(|c| city.starts_with(c) || label.starts_with(c))
960                && (city.contains(normalized)
961                    || label.contains(normalized)
962                    || normalized.len() <= 4)
963            {
964                1
965            } else {
966                return None;
967            };
968            Some((score, place.label.len(), place.clone()))
969        })
970        .collect::<Vec<_>>();
971    scored.sort_by_key(|(score, len, _)| (*score, *len));
972    scored
973        .into_iter()
974        .map(|(_, _, place)| place)
975        .take(5)
976        .collect()
977}
978
979fn sanitize_album_name(name: &str) -> String {
980    let cleaned = name
981        .chars()
982        .filter(|c| {
983            !c.is_control() && !matches!(c, '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
984        })
985        .collect::<String>()
986        .split_whitespace()
987        .collect::<Vec<_>>()
988        .join(" ");
989    if cleaned.is_empty() {
990        "Assistant album".into()
991    } else {
992        cleaned.chars().take(80).collect()
993    }
994}
995
996fn extract_date(text: &str) -> Option<(String, DateRange)> {
997    let words: Vec<&str> = text.split_whitespace().collect();
998    for window in (1..=3).rev() {
999        if words.len() < window {
1000            continue;
1001        }
1002        for i in 0..=(words.len() - window) {
1003            let candidate = words[i..i + window].join(" ");
1004            if let Some(range) = DateParser::parse(&candidate) {
1005                return Some((candidate, range));
1006            }
1007        }
1008    }
1009    None
1010}
1011
1012fn explicit_album_name(message: &str) -> Option<String> {
1013    let lower = message.to_lowercase();
1014    for marker in ["called ", "named "] {
1015        if let Some(idx) = lower.find(marker) {
1016            let name = message[idx + marker.len()..].trim();
1017            if !name.is_empty() {
1018                return Some(title_case(name));
1019            }
1020        }
1021    }
1022    None
1023}
1024
1025fn clean_semantic_text(text: &str) -> String {
1026    text.split_whitespace()
1027        .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
1028        .filter(|w| {
1029            let lower = w.to_lowercase();
1030            !lower.is_empty()
1031                && !matches!(
1032                    lower.as_str(),
1033                    "and"
1034                        | "or"
1035                        | "the"
1036                        | "a"
1037                        | "an"
1038                        | "me"
1039                        | "my"
1040                        | "only"
1041                        | "just"
1042                        | "in"
1043                        | "at"
1044                        | "to"
1045                        | "of"
1046                )
1047        })
1048        .collect::<Vec<_>>()
1049        .join(" ")
1050}
1051
1052fn remove_words(text: &str, words: &[&str]) -> String {
1053    text.split_whitespace()
1054        .filter(|w| {
1055            let cleaned = w
1056                .trim_matches(|c: char| !c.is_alphanumeric())
1057                .to_lowercase();
1058            !words.iter().any(|needle| *needle == cleaned)
1059        })
1060        .collect::<Vec<_>>()
1061        .join(" ")
1062}
1063
1064fn remove_phrase(text: &str, phrase: &str) -> String {
1065    let lower = text.to_lowercase();
1066    let phrase_lower = phrase.to_lowercase();
1067    if let Some(idx) = lower.find(&phrase_lower) {
1068        let end = idx + phrase.len();
1069        format!("{} {}", &text[..idx], &text[end..])
1070            .trim()
1071            .to_string()
1072    } else {
1073        text.to_string()
1074    }
1075}
1076
1077fn title_case(text: &str) -> String {
1078    text.split_whitespace()
1079        .map(|word| {
1080            let mut chars = word.chars();
1081            match chars.next() {
1082                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1083                None => String::new(),
1084            }
1085        })
1086        .collect::<Vec<_>>()
1087        .join(" ")
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093    use rusqlite::Connection;
1094
1095    fn conn() -> Connection {
1096        let conn = Connection::open_in_memory().unwrap();
1097        conn.execute_batch(
1098            r#"
1099            CREATE TABLE photos (
1100                id INTEGER PRIMARY KEY,
1101                file_name TEXT NOT NULL,
1102                thumbnail_path TEXT,
1103                date_taken TEXT,
1104                media_type TEXT NOT NULL DEFAULT 'photo',
1105                location_city TEXT,
1106                location_country TEXT,
1107                camera_make TEXT,
1108                camera_model TEXT,
1109                width INTEGER,
1110                height INTEGER,
1111                faces_processed BOOLEAN DEFAULT TRUE,
1112                is_trashed BOOLEAN DEFAULT FALSE
1113            );
1114            CREATE TABLE face_clusters (
1115                id INTEGER PRIMARY KEY,
1116                name TEXT,
1117                photo_count INTEGER DEFAULT 0
1118            );
1119            CREATE TABLE faces (
1120                id INTEGER PRIMARY KEY,
1121                photo_id INTEGER NOT NULL,
1122                cluster_id INTEGER
1123            );
1124            CREATE TABLE photo_inferred_identities (
1125                id INTEGER PRIMARY KEY,
1126                photo_id INTEGER NOT NULL,
1127                cluster_id INTEGER NOT NULL
1128            );
1129            CREATE TABLE albums (
1130                id INTEGER PRIMARY KEY,
1131                name TEXT NOT NULL,
1132                cover_photo_id INTEGER,
1133                cover_auto_picked BOOLEAN DEFAULT TRUE,
1134                photo_count INTEGER DEFAULT 0,
1135                created_by TEXT NOT NULL DEFAULT 'user',
1136                created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
1137                updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
1138            );
1139            CREATE TABLE album_photos (
1140                id INTEGER PRIMARY KEY,
1141                album_id INTEGER NOT NULL,
1142                photo_id INTEGER NOT NULL,
1143                UNIQUE(album_id, photo_id)
1144            );
1145            "#,
1146        )
1147        .unwrap();
1148        conn
1149    }
1150
1151    #[test]
1152    fn preview_filters_people_date_and_place() {
1153        let conn = conn();
1154        conn.execute(
1155            "INSERT INTO face_clusters (id, name, photo_count) VALUES (1, 'me', 2), (2, 'mom', 2)",
1156            [],
1157        )
1158        .unwrap();
1159        conn.execute(
1160            "INSERT INTO photos (id, file_name, date_taken, location_city, location_country) VALUES
1161             (1, 'a.jpg', '2014-05-01T00:00:00Z', 'Goa', 'India'),
1162             (2, 'b.jpg', '2015-05-01T00:00:00Z', 'Goa', 'India'),
1163             (3, 'c.jpg', '2014-05-01T00:00:00Z', 'Mumbai', 'India')",
1164            [],
1165        )
1166        .unwrap();
1167        conn.execute("INSERT INTO faces (photo_id, cluster_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (3, 1), (3, 2)", []).unwrap();
1168
1169        let (draft, _) = AssistantService::preview_album(
1170            &conn,
1171            "make an album of me and mom Goa 2014",
1172            "a1".into(),
1173            &[],
1174        )
1175        .unwrap();
1176        assert_eq!(draft.photo_ids, vec![1]);
1177        assert_eq!(draft.preview.people.len(), 2);
1178        assert_eq!(draft.preview.places[0].label, "Goa, India");
1179    }
1180
1181    #[test]
1182    fn people_only_excludes_unknown_faces() {
1183        let conn = conn();
1184        conn.execute(
1185            "INSERT INTO face_clusters (id, name, photo_count) VALUES (1, 'me', 2), (2, 'mom', 2)",
1186            [],
1187        )
1188        .unwrap();
1189        conn.execute(
1190            "INSERT INTO photos (id, file_name, date_taken, faces_processed) VALUES
1191             (1, 'a.jpg', '2014-05-01T00:00:00Z', TRUE),
1192             (2, 'b.jpg', '2014-05-01T00:00:00Z', TRUE)",
1193            [],
1194        )
1195        .unwrap();
1196        conn.execute("INSERT INTO faces (photo_id, cluster_id) VALUES (1, 1), (1, 2), (2, 1), (2, 2), (2, NULL)", []).unwrap();
1197
1198        let (draft, _) = AssistantService::preview_album(
1199            &conn,
1200            "make album only me and mom 2014",
1201            "a1".into(),
1202            &[],
1203        )
1204        .unwrap();
1205        assert_eq!(draft.photo_ids, vec![1]);
1206    }
1207
1208    #[test]
1209    fn create_album_marks_agent_source() {
1210        let conn = conn();
1211        conn.execute("INSERT INTO photos (id, file_name, date_taken) VALUES (1, 'a.jpg', '2014-05-01T00:00:00Z')", []).unwrap();
1212        let (draft, _) =
1213            AssistantService::preview_album(&conn, "make album 2014", "a1".into(), &[]).unwrap();
1214        let id = AssistantService::create_album(&conn, &draft).unwrap();
1215        let created_by: String = conn
1216            .query_row(
1217                "SELECT created_by FROM albums WHERE id = ?1",
1218                rusqlite::params![id],
1219                |row| row.get(0),
1220            )
1221            .unwrap();
1222        assert_eq!(created_by, "agent");
1223    }
1224
1225    #[test]
1226    fn country_only_place_filter_does_not_require_one_city() {
1227        let conn = conn();
1228        conn.execute(
1229            "INSERT INTO photos (id, file_name, date_taken, location_city, location_country) VALUES
1230             (1, 'a.jpg', '2014-05-01T00:00:00Z', 'Goa', 'India'),
1231             (2, 'b.jpg', '2014-06-01T00:00:00Z', 'Mumbai', 'India'),
1232             (3, 'c.jpg', '2014-06-01T00:00:00Z', 'Paris', 'France')",
1233            [],
1234        )
1235        .unwrap();
1236
1237        let (draft, _) =
1238            AssistantService::preview_album(&conn, "make album India 2014", "a1".into(), &[])
1239                .unwrap();
1240        assert_eq!(draft.photo_ids, vec![2, 1]);
1241        assert_eq!(draft.preview.places[0].label, "India");
1242    }
1243
1244    #[test]
1245    fn place_query_does_not_match_empty_city() {
1246        let conn = conn();
1247        conn.execute(
1248            "INSERT INTO photos (id, file_name, date_taken, location_country) VALUES
1249             (1, 'a.jpg', '2014-05-01T00:00:00Z', 'India')",
1250            [],
1251        )
1252        .unwrap();
1253
1254        let resolved = AssistantService::resolve_place_queries(&conn, &["Goa".into()]).unwrap();
1255        assert!(resolved.matches.is_empty());
1256    }
1257
1258    #[test]
1259    fn search_request_gets_clean_result_title_and_search_intent() {
1260        let conn = conn();
1261        conn.execute(
1262            "INSERT INTO photos (id, file_name, date_taken) VALUES
1263             (1, 'beach-family.jpg', '2024-05-01T00:00:00Z')",
1264            [],
1265        )
1266        .unwrap();
1267
1268        let (draft, _) = AssistantService::preview_album(
1269            &conn,
1270            "find me all photos at a beach",
1271            "a1".into(),
1272            &[],
1273        )
1274        .unwrap();
1275        assert_eq!(draft.preview.intent, AssistantIntent::Search);
1276        assert_eq!(draft.album_name, "Beach photos");
1277        assert_eq!(draft.photo_ids, vec![1]);
1278    }
1279
1280    #[test]
1281    fn plain_request_searches_by_default() {
1282        let conn = conn();
1283        conn.execute(
1284            "INSERT INTO photos (id, file_name, date_taken, location_city) VALUES
1285             (1, 'a.jpg', '2024-05-01T00:00:00Z', 'Goa')",
1286            [],
1287        )
1288        .unwrap();
1289
1290        let (draft, _) =
1291            AssistantService::preview_album(&conn, "Goa 2024", "a1".into(), &[]).unwrap();
1292        assert_eq!(draft.preview.intent, AssistantIntent::Search);
1293        assert_eq!(draft.album_name, "Goa, 2024 photos");
1294        assert_eq!(draft.photo_ids, vec![1]);
1295    }
1296
1297    #[test]
1298    fn album_creation_requires_explicit_create_album_intent() {
1299        assert_eq!(request_intent("photos of Goa"), AssistantIntent::Search);
1300        assert_eq!(request_intent("album of Goa"), AssistantIntent::Search);
1301        assert_eq!(
1302            request_intent("create album of Goa"),
1303            AssistantIntent::CreateAlbum
1304        );
1305        assert_eq!(
1306            request_intent("make a Goa album"),
1307            AssistantIntent::CreateAlbum
1308        );
1309    }
1310}