1use 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}