1use std::cmp::Ordering;
9use std::collections::HashMap;
10use std::num::NonZeroU64;
11use std::path::PathBuf;
12
13use anyhow::{anyhow, bail, Result};
14use blake3::hash;
15use clap::{ArgAction, Args, ValueEnum};
16#[cfg(feature = "temporal_track")]
17use memvid_core::{
18 types::SearchHitTemporal, TemporalContext, TemporalFilter, TemporalNormalizer,
19 TemporalResolution, TemporalResolutionValue,
20};
21use memvid_core::{
22 types::{AskContextFragment, AskContextFragmentKind, SearchHitMetadata},
23 AskMode, AskRequest, AskResponse, AskRetriever, FrameId, Memvid, SearchEngineKind, SearchHit,
24 SearchRequest, SearchResponse, TimelineEntry, TimelineQueryBuilder, VecEmbedder,
25};
26#[cfg(feature = "temporal_track")]
27use serde::Serialize;
28use serde_json::json;
29#[cfg(feature = "temporal_track")]
30use time::format_description::well_known::Rfc3339;
31use time::{Date, PrimitiveDateTime, Time};
32#[cfg(feature = "temporal_track")]
33use time::{Duration as TimeDuration, Month, OffsetDateTime, UtcOffset};
34use tracing::{info, warn};
35
36use fastembed::{RerankerModel, RerankInitOptions, TextRerank};
37
38use memvid_ask_model::{
39 run_model_inference, ModelAnswer, ModelContextFragment, ModelContextFragmentKind,
40 ModelInference,
41};
42
43use crate::config::{
45 load_embedding_runtime, load_embedding_runtime_for_mv2,
46 resolve_llm_context_budget_override, try_load_embedding_runtime,
47 try_load_embedding_runtime_for_mv2, CliConfig, EmbeddingRuntime,
48};
49use crate::utils::{
50 autodetect_memory_file, format_timestamp, looks_like_memory, open_read_only_mem,
51 parse_date_boundary, parse_vector, read_embedding,
52};
53
54const OUTPUT_CONTEXT_MAX_LEN: usize = 4_000;
55#[cfg(feature = "temporal_track")]
56const DEFAULT_TEMPORAL_TZ: &str = "America/Chicago";
57
58#[derive(Args)]
60pub struct TimelineArgs {
61 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
62 pub file: PathBuf,
63 #[arg(long)]
64 pub json: bool,
65 #[arg(long)]
66 pub reverse: bool,
67 #[arg(long, value_name = "LIMIT")]
68 pub limit: Option<NonZeroU64>,
69 #[arg(long, value_name = "TIMESTAMP")]
70 pub since: Option<i64>,
71 #[arg(long, value_name = "TIMESTAMP")]
72 pub until: Option<i64>,
73 #[cfg(feature = "temporal_track")]
74 #[arg(long = "on", value_name = "PHRASE")]
75 pub phrase: Option<String>,
76 #[cfg(feature = "temporal_track")]
77 #[arg(long = "tz", value_name = "IANA_ZONE")]
78 pub tz: Option<String>,
79 #[cfg(feature = "temporal_track")]
80 #[arg(long = "anchor", value_name = "RFC3339")]
81 pub anchor: Option<String>,
82 #[cfg(feature = "temporal_track")]
83 #[arg(long = "window", value_name = "MINUTES")]
84 pub window: Option<u64>,
85 #[arg(long = "as-of-frame", value_name = "FRAME_ID")]
87 pub as_of_frame: Option<u64>,
88 #[arg(long = "as-of-ts", value_name = "UNIX_TIMESTAMP")]
90 pub as_of_ts: Option<i64>,
91}
92
93#[cfg(feature = "temporal_track")]
95#[derive(Args)]
96pub struct WhenArgs {
97 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
98 pub file: PathBuf,
99 #[arg(long = "on", value_name = "PHRASE")]
100 pub phrase: String,
101 #[arg(long = "tz", value_name = "IANA_ZONE")]
102 pub tz: Option<String>,
103 #[arg(long = "anchor", value_name = "RFC3339")]
104 pub anchor: Option<String>,
105 #[arg(long = "window", value_name = "MINUTES")]
106 pub window: Option<u64>,
107 #[arg(long, value_name = "LIMIT")]
108 pub limit: Option<NonZeroU64>,
109 #[arg(long, value_name = "TIMESTAMP")]
110 pub since: Option<i64>,
111 #[arg(long, value_name = "TIMESTAMP")]
112 pub until: Option<i64>,
113 #[arg(long)]
114 pub reverse: bool,
115 #[arg(long)]
116 pub json: bool,
117}
118
119#[derive(Args)]
121pub struct AskArgs {
122 #[arg(value_name = "TARGET", num_args = 0..)]
123 pub targets: Vec<String>,
124 #[arg(long = "question", value_name = "TEXT")]
125 pub question: Option<String>,
126 #[arg(long = "uri", value_name = "URI")]
127 pub uri: Option<String>,
128 #[arg(long = "scope", value_name = "URI_PREFIX")]
129 pub scope: Option<String>,
130 #[arg(long = "top-k", value_name = "K", default_value = "8", alias = "limit")]
131 pub top_k: usize,
132 #[arg(long = "snippet-chars", value_name = "N", default_value = "480")]
133 pub snippet_chars: usize,
134 #[arg(long = "cursor", value_name = "TOKEN")]
135 pub cursor: Option<String>,
136 #[arg(long = "mode", value_enum, default_value = "hybrid")]
137 pub mode: AskModeArg,
138 #[arg(long)]
139 pub json: bool,
140 #[arg(long = "context-only", action = ArgAction::SetTrue)]
141 pub context_only: bool,
142 #[arg(long = "sources", action = ArgAction::SetTrue)]
144 pub sources: bool,
145 #[arg(long = "mask-pii", action = ArgAction::SetTrue)]
147 pub mask_pii: bool,
148 #[arg(long = "memories", action = ArgAction::SetTrue)]
150 pub memories: bool,
151 #[arg(long = "llm-context-depth", value_name = "CHARS")]
153 pub llm_context_depth: Option<usize>,
154 #[arg(long = "start", value_name = "DATE")]
155 pub start: Option<String>,
156 #[arg(long = "end", value_name = "DATE")]
157 pub end: Option<String>,
158 #[arg(
159 long = "use-model",
160 value_name = "MODEL",
161 num_args = 0..=1,
162 default_missing_value = "tinyllama"
163 )]
164 pub use_model: Option<String>,
165 #[arg(long = "query-embedding-model", value_name = "EMB_MODEL")]
168 pub query_embedding_model: Option<String>,
169 #[arg(long = "as-of-frame", value_name = "FRAME_ID")]
171 pub as_of_frame: Option<u64>,
172 #[arg(long = "as-of-ts", value_name = "UNIX_TIMESTAMP")]
174 pub as_of_ts: Option<i64>,
175 #[arg(long = "system-prompt", value_name = "TEXT")]
177 pub system_prompt: Option<String>,
178 #[arg(long = "no-rerank", action = ArgAction::SetTrue)]
180 pub no_rerank: bool,
181}
182
183#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
185pub enum AskModeArg {
186 Lex,
187 Sem,
188 Hybrid,
189}
190
191impl From<AskModeArg> for AskMode {
192 fn from(value: AskModeArg) -> Self {
193 match value {
194 AskModeArg::Lex => AskMode::Lex,
195 AskModeArg::Sem => AskMode::Sem,
196 AskModeArg::Hybrid => AskMode::Hybrid,
197 }
198 }
199}
200
201#[derive(Args)]
203pub struct FindArgs {
204 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
205 pub file: PathBuf,
206 #[arg(long = "query", value_name = "TEXT")]
207 pub query: String,
208 #[arg(long = "uri", value_name = "URI")]
209 pub uri: Option<String>,
210 #[arg(long = "scope", value_name = "URI_PREFIX")]
211 pub scope: Option<String>,
212 #[arg(long = "top-k", value_name = "K", default_value = "8", alias = "limit")]
213 pub top_k: usize,
214 #[arg(long = "snippet-chars", value_name = "N", default_value = "480")]
215 pub snippet_chars: usize,
216 #[arg(long = "cursor", value_name = "TOKEN")]
217 pub cursor: Option<String>,
218 #[arg(long)]
219 pub json: bool,
220 #[arg(long = "json-legacy", conflicts_with = "json")]
221 pub json_legacy: bool,
222 #[arg(long = "mode", value_enum, default_value = "auto")]
223 pub mode: SearchMode,
224 #[arg(long = "as-of-frame", value_name = "FRAME_ID")]
226 pub as_of_frame: Option<u64>,
227 #[arg(long = "as-of-ts", value_name = "UNIX_TIMESTAMP")]
229 pub as_of_ts: Option<i64>,
230 #[arg(long = "query-embedding-model", value_name = "EMB_MODEL")]
233 pub query_embedding_model: Option<String>,
234}
235
236#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
238pub enum SearchMode {
239 Auto,
240 Lex,
241 Sem,
242}
243
244#[derive(Args)]
246pub struct VecSearchArgs {
247 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
248 pub file: PathBuf,
249 #[arg(long, conflicts_with = "embedding", value_name = "CSV")]
250 pub vector: Option<String>,
251 #[arg(long, conflicts_with = "vector", value_name = "PATH", value_parser = clap::value_parser!(PathBuf))]
252 pub embedding: Option<PathBuf>,
253 #[arg(long, value_name = "K", default_value = "10")]
254 pub limit: usize,
255 #[arg(long)]
256 pub json: bool,
257}
258
259#[derive(Args)]
261pub struct AuditArgs {
262 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
263 pub file: PathBuf,
264 #[arg(value_name = "QUESTION")]
266 pub question: String,
267 #[arg(long = "out", short = 'o', value_name = "PATH", value_parser = clap::value_parser!(PathBuf))]
269 pub out: Option<PathBuf>,
270 #[arg(long = "format", value_enum, default_value = "text")]
272 pub format: AuditFormat,
273 #[arg(long = "top-k", value_name = "K", default_value = "10")]
275 pub top_k: usize,
276 #[arg(long = "snippet-chars", value_name = "N", default_value = "500")]
278 pub snippet_chars: usize,
279 #[arg(long = "mode", value_enum, default_value = "hybrid")]
281 pub mode: AskModeArg,
282 #[arg(long = "scope", value_name = "URI_PREFIX")]
284 pub scope: Option<String>,
285 #[arg(long = "start", value_name = "DATE")]
287 pub start: Option<String>,
288 #[arg(long = "end", value_name = "DATE")]
290 pub end: Option<String>,
291 #[arg(long = "use-model", value_name = "MODEL")]
293 pub use_model: Option<String>,
294}
295
296#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
298pub enum AuditFormat {
299 Text,
301 Markdown,
303 Json,
305}
306
307pub fn handle_timeline(_config: &CliConfig, args: TimelineArgs) -> Result<()> {
312 let mut mem = open_read_only_mem(&args.file)?;
313 let mut builder = TimelineQueryBuilder::default();
314 #[cfg(feature = "temporal_track")]
315 if args.phrase.is_none()
316 && (args.tz.is_some() || args.anchor.is_some() || args.window.is_some())
317 {
318 bail!("E-TEMP-005 use --on when supplying --tz/--anchor/--window");
319 }
320 if let Some(limit) = args.limit {
321 builder = builder.limit(limit);
322 }
323 if let Some(since) = args.since {
324 builder = builder.since(since);
325 }
326 if let Some(until) = args.until {
327 builder = builder.until(until);
328 }
329 builder = builder.reverse(args.reverse);
330 #[cfg(feature = "temporal_track")]
331 let temporal_summary = if let Some(ref phrase) = args.phrase {
332 let (filter, summary) = build_temporal_filter(
333 phrase,
334 args.tz.as_deref(),
335 args.anchor.as_deref(),
336 args.window,
337 )?;
338 builder = builder.temporal(filter);
339 Some(summary)
340 } else {
341 None
342 };
343 let query = builder.build();
344 let mut entries = mem.timeline(query)?;
345
346 if args.as_of_frame.is_some() || args.as_of_ts.is_some() {
348 entries.retain(|entry| {
349 if let Some(cutoff_frame) = args.as_of_frame {
351 if entry.frame_id > cutoff_frame {
352 return false;
353 }
354 }
355
356 if let Some(cutoff_ts) = args.as_of_ts {
358 if entry.timestamp > cutoff_ts {
359 return false;
360 }
361 }
362
363 true
364 });
365 }
366
367 if args.json {
368 #[cfg(feature = "temporal_track")]
369 if let Some(summary) = temporal_summary.as_ref() {
370 println!(
371 "{}",
372 serde_json::to_string_pretty(&TimelineOutput {
373 temporal: Some(summary_to_output(summary)),
374 entries: &entries,
375 })?
376 );
377 } else {
378 println!("{}", serde_json::to_string_pretty(&entries)?);
379 }
380 #[cfg(not(feature = "temporal_track"))]
381 println!("{}", serde_json::to_string_pretty(&entries)?);
382 } else if entries.is_empty() {
383 println!("Timeline is empty");
384 } else {
385 #[cfg(feature = "temporal_track")]
386 if let Some(summary) = temporal_summary.as_ref() {
387 print_temporal_summary(summary);
388 }
389 for entry in entries {
390 println!(
391 "#{} @ {} — {}",
392 entry.frame_id,
393 entry.timestamp,
394 entry.preview.replace('\n', " ")
395 );
396 if let Some(uri) = entry.uri.as_deref() {
397 println!(" URI: {uri}");
398 }
399 if !entry.child_frames.is_empty() {
400 let child_list = entry
401 .child_frames
402 .iter()
403 .map(|id| id.to_string())
404 .collect::<Vec<_>>()
405 .join(", ");
406 println!(" Child frames: {child_list}");
407 }
408 #[cfg(feature = "temporal_track")]
409 if let Some(temporal) = entry.temporal.as_ref() {
410 print_entry_temporal_details(temporal);
411 }
412 }
413 }
414 Ok(())
415}
416
417#[cfg(feature = "temporal_track")]
418pub fn handle_when(_config: &CliConfig, args: WhenArgs) -> Result<()> {
419 let mut mem = open_read_only_mem(&args.file)?;
420
421 let (filter, summary) = build_temporal_filter(
422 &args.phrase,
423 args.tz.as_deref(),
424 args.anchor.as_deref(),
425 args.window,
426 )?;
427
428 let mut builder = TimelineQueryBuilder::default();
429 if let Some(limit) = args.limit {
430 builder = builder.limit(limit);
431 }
432 if let Some(since) = args.since {
433 builder = builder.since(since);
434 }
435 if let Some(until) = args.until {
436 builder = builder.until(until);
437 }
438 builder = builder.reverse(args.reverse).temporal(filter.clone());
439 let entries = mem.timeline(builder.build())?;
440
441 if args.json {
442 let entry_views: Vec<WhenEntry> = entries.iter().map(entry_to_when_entry).collect();
443 let output = WhenOutput {
444 summary: summary_to_output(&summary),
445 entries: entry_views,
446 };
447 println!("{}", serde_json::to_string_pretty(&output)?);
448 return Ok(());
449 }
450
451 print_temporal_summary(&summary);
452 if entries.is_empty() {
453 println!("No frames matched the resolved window");
454 return Ok(());
455 }
456
457 for entry in &entries {
458 let iso = format_timestamp(entry.timestamp).unwrap_or_default();
459 println!(
460 "#{} @ {} ({iso}) — {}",
461 entry.frame_id,
462 entry.timestamp,
463 entry.preview.replace('\n', " ")
464 );
465 if let Some(uri) = entry.uri.as_deref() {
466 println!(" URI: {uri}");
467 }
468 if !entry.child_frames.is_empty() {
469 let child_list = entry
470 .child_frames
471 .iter()
472 .map(|id| id.to_string())
473 .collect::<Vec<_>>()
474 .join(", ");
475 println!(" Child frames: {child_list}");
476 }
477 if let Some(temporal) = entry.temporal.as_ref() {
478 print_entry_temporal_details(temporal);
479 }
480 }
481
482 Ok(())
483}
484
485#[cfg(feature = "temporal_track")]
486#[derive(Serialize)]
487struct TimelineOutput<'a> {
488 #[serde(skip_serializing_if = "Option::is_none")]
489 temporal: Option<TemporalSummaryOutput>,
490 entries: &'a [TimelineEntry],
491}
492
493#[cfg(feature = "temporal_track")]
494#[derive(Serialize)]
495struct WhenOutput {
496 summary: TemporalSummaryOutput,
497 entries: Vec<WhenEntry>,
498}
499
500#[cfg(feature = "temporal_track")]
501#[derive(Serialize)]
502struct WhenEntry {
503 frame_id: FrameId,
504 timestamp: i64,
505 #[serde(skip_serializing_if = "Option::is_none")]
506 timestamp_iso: Option<String>,
507 preview: String,
508 #[serde(skip_serializing_if = "Option::is_none")]
509 uri: Option<String>,
510 #[serde(skip_serializing_if = "Vec::is_empty")]
511 child_frames: Vec<FrameId>,
512 #[serde(skip_serializing_if = "Option::is_none")]
513 temporal: Option<SearchHitTemporal>,
514}
515
516#[cfg(feature = "temporal_track")]
517#[derive(Serialize)]
518struct TemporalSummaryOutput {
519 phrase: String,
520 timezone: String,
521 anchor_utc: i64,
522 anchor_iso: String,
523 confidence: u16,
524 #[serde(skip_serializing_if = "Vec::is_empty")]
525 flags: Vec<&'static str>,
526 resolution_kind: &'static str,
527 window_start_utc: Option<i64>,
528 window_start_iso: Option<String>,
529 window_end_utc: Option<i64>,
530 window_end_iso: Option<String>,
531 #[serde(skip_serializing_if = "Option::is_none")]
532 window_minutes: Option<u64>,
533}
534
535#[cfg(feature = "temporal_track")]
536struct TemporalSummary {
537 phrase: String,
538 tz: String,
539 anchor: OffsetDateTime,
540 start_utc: Option<i64>,
541 end_utc: Option<i64>,
542 resolution: TemporalResolution,
543 window_minutes: Option<u64>,
544}
545
546#[cfg(feature = "temporal_track")]
547fn build_temporal_filter(
548 phrase: &str,
549 tz_override: Option<&str>,
550 anchor_override: Option<&str>,
551 window_minutes: Option<u64>,
552) -> Result<(TemporalFilter, TemporalSummary)> {
553 let tz = tz_override
554 .unwrap_or(DEFAULT_TEMPORAL_TZ)
555 .trim()
556 .to_string();
557 if tz.is_empty() {
558 bail!("E-TEMP-003 timezone must not be empty");
559 }
560
561 let anchor = if let Some(raw) = anchor_override {
562 OffsetDateTime::parse(raw, &Rfc3339)
563 .map_err(|_| anyhow!("E-TEMP-002 anchor must be RFC3339: {raw}"))?
564 } else {
565 OffsetDateTime::now_utc()
566 };
567
568 let context = TemporalContext::new(anchor, tz.clone());
569 let normalizer = TemporalNormalizer::new(context);
570 let resolution = normalizer
571 .resolve(phrase)
572 .map_err(|err| anyhow!("E-TEMP-001 {err}"))?;
573
574 let (mut start, mut end) = resolution_bounds(&resolution)?;
575 if let Some(minutes) = window_minutes {
576 if minutes > 0 {
577 let delta = TimeDuration::minutes(minutes as i64);
578 if let (Some(s), Some(e)) = (start, end) {
579 if s == e {
580 start = Some(s.saturating_sub(delta.whole_seconds()));
581 end = Some(e.saturating_add(delta.whole_seconds()));
582 } else {
583 start = Some(s.saturating_sub(delta.whole_seconds()));
584 end = Some(e.saturating_add(delta.whole_seconds()));
585 }
586 }
587 }
588 }
589
590 let filter = TemporalFilter {
591 start_utc: start,
592 end_utc: end,
593 phrase: None,
594 tz: None,
595 };
596
597 let summary = TemporalSummary {
598 phrase: phrase.to_owned(),
599 tz,
600 anchor,
601 start_utc: start,
602 end_utc: end,
603 resolution,
604 window_minutes,
605 };
606
607 Ok((filter, summary))
608}
609
610#[cfg(feature = "temporal_track")]
611fn summary_to_output(summary: &TemporalSummary) -> TemporalSummaryOutput {
612 TemporalSummaryOutput {
613 phrase: summary.phrase.clone(),
614 timezone: summary.tz.clone(),
615 anchor_utc: summary.anchor.unix_timestamp(),
616 anchor_iso: summary
617 .anchor
618 .format(&Rfc3339)
619 .unwrap_or_else(|_| summary.anchor.unix_timestamp().to_string()),
620 confidence: summary.resolution.confidence,
621 flags: summary
622 .resolution
623 .flags
624 .iter()
625 .map(|flag| flag.as_str())
626 .collect(),
627 resolution_kind: resolution_kind(&summary.resolution),
628 window_start_utc: summary.start_utc,
629 window_start_iso: summary.start_utc.and_then(format_timestamp),
630 window_end_utc: summary.end_utc,
631 window_end_iso: summary.end_utc.and_then(format_timestamp),
632 window_minutes: summary.window_minutes,
633 }
634}
635
636#[cfg(feature = "temporal_track")]
637fn entry_to_when_entry(entry: &TimelineEntry) -> WhenEntry {
638 WhenEntry {
639 frame_id: entry.frame_id,
640 timestamp: entry.timestamp,
641 timestamp_iso: format_timestamp(entry.timestamp),
642 preview: entry.preview.clone(),
643 uri: entry.uri.clone(),
644 child_frames: entry.child_frames.clone(),
645 temporal: entry.temporal.clone(),
646 }
647}
648
649#[cfg(feature = "temporal_track")]
650fn print_temporal_summary(summary: &TemporalSummary) {
651 println!("Phrase: \"{}\"", summary.phrase);
652 println!("Timezone: {}", summary.tz);
653 println!(
654 "Anchor: {}",
655 summary
656 .anchor
657 .format(&Rfc3339)
658 .unwrap_or_else(|_| summary.anchor.unix_timestamp().to_string())
659 );
660 let start_iso = summary.start_utc.and_then(format_timestamp);
661 let end_iso = summary.end_utc.and_then(format_timestamp);
662 match (start_iso, end_iso) {
663 (Some(start), Some(end)) if start == end => println!("Resolved to: {start}"),
664 (Some(start), Some(end)) => println!("Window: {start} → {end}"),
665 (Some(start), None) => println!("Window start: {start}"),
666 (None, Some(end)) => println!("Window end: {end}"),
667 _ => println!("Window: (not resolved)"),
668 }
669 println!("Confidence: {}", summary.resolution.confidence);
670 let flags: Vec<&'static str> = summary
671 .resolution
672 .flags
673 .iter()
674 .map(|flag| flag.as_str())
675 .collect();
676 if !flags.is_empty() {
677 println!("Flags: {}", flags.join(", "));
678 }
679 if let Some(window) = summary.window_minutes {
680 if window > 0 {
681 println!("Window padding: {window} minute(s)");
682 }
683 }
684 println!();
685}
686
687#[cfg(feature = "temporal_track")]
688fn print_entry_temporal_details(temporal: &SearchHitTemporal) {
689 if let Some(anchor) = temporal.anchor.as_ref() {
690 let iso = anchor
691 .iso_8601
692 .clone()
693 .or_else(|| format_timestamp(anchor.ts_utc));
694 println!(
695 " Anchor: {} (source: {:?})",
696 iso.unwrap_or_else(|| anchor.ts_utc.to_string()),
697 anchor.source
698 );
699 }
700 if !temporal.mentions.is_empty() {
701 println!(" Mentions:");
702 for mention in &temporal.mentions {
703 let iso = mention
704 .iso_8601
705 .clone()
706 .or_else(|| format_timestamp(mention.ts_utc))
707 .unwrap_or_else(|| mention.ts_utc.to_string());
708 let mut details = format!(
709 " - {} ({:?}, confidence {})",
710 iso, mention.kind, mention.confidence
711 );
712 if let Some(text) = mention.text.as_deref() {
713 details.push_str(&format!(" — \"{}\"", text));
714 }
715 println!("{details}");
716 }
717 }
718}
719
720#[cfg(feature = "temporal_track")]
721fn resolution_bounds(resolution: &TemporalResolution) -> Result<(Option<i64>, Option<i64>)> {
722 match &resolution.value {
723 TemporalResolutionValue::Date(date) => {
724 let ts = date_to_timestamp(*date);
725 Ok((Some(ts), Some(ts)))
726 }
727 TemporalResolutionValue::DateTime(dt) => {
728 let ts = dt.unix_timestamp();
729 Ok((Some(ts), Some(ts)))
730 }
731 TemporalResolutionValue::DateRange { start, end } => Ok((
732 Some(date_to_timestamp(*start)),
733 Some(date_to_timestamp(*end)),
734 )),
735 TemporalResolutionValue::DateTimeRange { start, end } => {
736 Ok((Some(start.unix_timestamp()), Some(end.unix_timestamp())))
737 }
738 TemporalResolutionValue::Month { year, month } => {
739 let start_date = Date::from_calendar_date(*year, *month, 1)
740 .map_err(|_| anyhow!("invalid month resolution"))?;
741 let end_date = last_day_in_month(*year, *month)
742 .map_err(|_| anyhow!("invalid month resolution"))?;
743 Ok((
744 Some(date_to_timestamp(start_date)),
745 Some(date_to_timestamp(end_date)),
746 ))
747 }
748 }
749}
750
751#[cfg(feature = "temporal_track")]
752fn resolution_kind(resolution: &TemporalResolution) -> &'static str {
753 match resolution.value {
754 TemporalResolutionValue::Date(_) => "date",
755 TemporalResolutionValue::DateTime(_) => "datetime",
756 TemporalResolutionValue::DateRange { .. } => "date_range",
757 TemporalResolutionValue::DateTimeRange { .. } => "datetime_range",
758 TemporalResolutionValue::Month { .. } => "month",
759 }
760}
761
762#[cfg(feature = "temporal_track")]
763fn date_to_timestamp(date: Date) -> i64 {
764 PrimitiveDateTime::new(date, Time::MIDNIGHT)
765 .assume_offset(UtcOffset::UTC)
766 .unix_timestamp()
767}
768
769#[cfg(feature = "temporal_track")]
770fn last_day_in_month(year: i32, month: Month) -> Result<Date> {
771 let mut date = Date::from_calendar_date(year, month, 1)
772 .map_err(|_| anyhow!("invalid month resolution"))?;
773 while let Some(next) = date.next_day() {
774 if next.month() == month {
775 date = next;
776 } else {
777 break;
778 }
779 }
780 Ok(date)
781}
782
783#[cfg(feature = "temporal_track")]
784
785fn apply_model_context_fragments(response: &mut AskResponse, fragments: Vec<ModelContextFragment>) {
786 if fragments.is_empty() {
787 return;
788 }
789
790 response.context_fragments = fragments
791 .into_iter()
792 .map(|fragment| AskContextFragment {
793 rank: fragment.rank,
794 frame_id: fragment.frame_id,
795 uri: fragment.uri,
796 title: fragment.title,
797 score: fragment.score,
798 matches: fragment.matches,
799 range: Some(fragment.range),
800 chunk_range: fragment.chunk_range,
801 text: fragment.text,
802 kind: Some(match fragment.kind {
803 ModelContextFragmentKind::Full => AskContextFragmentKind::Full,
804 ModelContextFragmentKind::Summary => AskContextFragmentKind::Summary,
805 }),
806 #[cfg(feature = "temporal_track")]
807 temporal: None,
808 })
809 .collect();
810}
811
812pub fn handle_ask(config: &CliConfig, args: AskArgs) -> Result<()> {
813 if args.uri.is_some() && args.scope.is_some() {
814 warn!("--scope ignored because --uri is provided");
815 }
816
817 let mut question_tokens = Vec::new();
818 let mut file_path: Option<PathBuf> = None;
819 for token in &args.targets {
820 if file_path.is_none() && looks_like_memory(token) {
821 file_path = Some(PathBuf::from(token));
822 } else {
823 question_tokens.push(token.clone());
824 }
825 }
826
827 let positional_question = if question_tokens.is_empty() {
828 None
829 } else {
830 Some(question_tokens.join(" "))
831 };
832
833 let question = args
834 .question
835 .or(positional_question)
836 .map(|value| value.trim().to_string())
837 .filter(|value| !value.is_empty());
838
839 let question = question
840 .ok_or_else(|| anyhow!("provide a question via positional arguments or --question"))?;
841
842 let memory_path = match file_path {
843 Some(path) => path,
844 None => autodetect_memory_file()?,
845 };
846
847 let start = parse_date_boundary(args.start.as_ref(), false)?;
848 let end = parse_date_boundary(args.end.as_ref(), true)?;
849 if let (Some(start_ts), Some(end_ts)) = (start, end) {
850 if end_ts < start_ts {
851 anyhow::bail!("--end must not be earlier than --start");
852 }
853 }
854
855 let mut mem = Memvid::open(&memory_path)?;
857
858 let mv2_dimension = mem.vec_index_dimension();
860
861 let ask_mode: AskMode = args.mode.into();
862 let emb_model_override = args.query_embedding_model.as_deref();
863 let runtime = match args.mode {
864 AskModeArg::Lex => None,
865 AskModeArg::Sem => Some(load_embedding_runtime_for_mv2(
866 config,
867 emb_model_override,
868 mv2_dimension,
869 )?),
870 AskModeArg::Hybrid => {
871 try_load_embedding_runtime_for_mv2(config, emb_model_override, mv2_dimension).or_else(
873 || {
874 load_embedding_runtime_for_mv2(config, emb_model_override, mv2_dimension)
876 .ok()
877 .map(|rt| {
878 tracing::debug!("hybrid ask: loaded embedding runtime after fallback");
879 rt
880 })
881 },
882 )
883 }
884 };
885 if runtime.is_none() && matches!(args.mode, AskModeArg::Sem | AskModeArg::Hybrid) {
886 anyhow::bail!(
887 "semantic embeddings unavailable; install/cached model required for {:?} mode",
888 args.mode
889 );
890 }
891
892 let embedder = runtime.as_ref().map(|inner| inner as &dyn VecEmbedder);
893
894 let request = AskRequest {
895 question,
896 top_k: args.top_k,
897 snippet_chars: args.snippet_chars,
898 uri: args.uri.clone(),
899 scope: args.scope.clone(),
900 cursor: args.cursor.clone(),
901 start,
902 end,
903 #[cfg(feature = "temporal_track")]
904 temporal: None,
905 context_only: args.context_only,
906 mode: ask_mode,
907 as_of_frame: args.as_of_frame,
908 as_of_ts: args.as_of_ts,
909 };
910 let mut response = mem.ask(request, embedder)?;
911
912 if !args.no_rerank && !response.retrieval.hits.is_empty() && matches!(args.mode, AskModeArg::Sem | AskModeArg::Hybrid) {
917 let mut search_response = SearchResponse {
919 query: response.question.clone(),
920 hits: response.retrieval.hits.clone(),
921 total_hits: response.retrieval.hits.len(),
922 params: memvid_core::SearchParams {
923 top_k: args.top_k,
924 snippet_chars: args.snippet_chars,
925 cursor: None,
926 },
927 elapsed_ms: 0,
928 engine: memvid_core::SearchEngineKind::Hybrid,
929 next_cursor: None,
930 context: String::new(),
931 };
932
933 if let Err(e) = apply_cross_encoder_rerank(&mut search_response) {
934 warn!("Cross-encoder reranking failed: {e}");
935 } else {
936 response.retrieval.hits = search_response.hits;
938 response.retrieval.context = response
940 .retrieval
941 .hits
942 .iter()
943 .take(10) .map(|hit| hit.text.as_str())
945 .collect::<Vec<_>>()
946 .join("\n\n---\n\n");
947 }
948 }
949
950 if args.memories {
952 let memory_context = build_memory_context(&mem);
953 if !memory_context.is_empty() {
954 response.retrieval.context = format!(
956 "=== KNOWN FACTS ===\n{}\n\n=== RETRIEVED CONTEXT ===\n{}",
957 memory_context, response.retrieval.context
958 );
959 }
960 }
961
962 if args.mask_pii {
964 use memvid_core::pii::mask_pii;
965
966 response.retrieval.context = mask_pii(&response.retrieval.context);
968
969 for hit in &mut response.retrieval.hits {
971 hit.text = mask_pii(&hit.text);
972 if let Some(chunk_text) = &hit.chunk_text {
973 hit.chunk_text = Some(mask_pii(chunk_text));
974 }
975 }
976 }
977
978 let llm_context_override = resolve_llm_context_budget_override(args.llm_context_depth)?;
979
980 let mut model_result: Option<ModelAnswer> = None;
981 if response.context_only {
982 if args.use_model.is_some() {
983 warn!("--use-model ignored because --context-only disables synthesis");
984 }
985 } else if let Some(model_name) = args.use_model.as_deref() {
986 match run_model_inference(
987 model_name,
988 &response.question,
989 &response.retrieval.context,
990 &response.retrieval.hits,
991 llm_context_override,
992 None,
993 args.system_prompt.as_deref(),
994 ) {
995 Ok(inference) => {
996 let ModelInference {
997 answer,
998 context_body,
999 context_fragments,
1000 ..
1001 } = inference;
1002 response.answer = Some(answer.answer.clone());
1003 response.retrieval.context = context_body;
1004 apply_model_context_fragments(&mut response, context_fragments);
1005 model_result = Some(answer);
1006 }
1007 Err(err) => {
1008 warn!(
1009 "model inference unavailable for '{}': {err}. Falling back to default summary.",
1010 model_name
1011 );
1012 }
1013 }
1014 }
1015
1016 if args.json {
1017 if let Some(model_name) = args.use_model.as_deref() {
1018 emit_model_json(
1019 &response,
1020 model_name,
1021 model_result.as_ref(),
1022 args.sources,
1023 &mut mem,
1024 )?;
1025 } else {
1026 emit_ask_json(
1027 &response,
1028 args.mode,
1029 model_result.as_ref(),
1030 args.sources,
1031 &mut mem,
1032 )?;
1033 }
1034 } else {
1035 emit_ask_pretty(
1036 &response,
1037 args.mode,
1038 model_result.as_ref(),
1039 args.sources,
1040 &mut mem,
1041 );
1042 }
1043
1044 Ok(())
1045}
1046
1047pub fn handle_find(config: &CliConfig, args: FindArgs) -> Result<()> {
1048 let mut mem = open_read_only_mem(&args.file)?;
1049 if args.uri.is_some() && args.scope.is_some() {
1050 warn!("--scope ignored because --uri is provided");
1051 }
1052
1053 let mv2_dimension = mem.vec_index_dimension();
1055 let emb_model_override = args.query_embedding_model.as_deref();
1056
1057 let (mode_label, runtime_option) = match args.mode {
1058 SearchMode::Lex => ("Lexical (forced)".to_string(), None),
1059 SearchMode::Sem => {
1060 let runtime = load_embedding_runtime_for_mv2(config, emb_model_override, mv2_dimension)?;
1061 ("Semantic (vector search)".to_string(), Some(runtime))
1062 }
1063 SearchMode::Auto => {
1064 if let Some(runtime) = try_load_embedding_runtime_for_mv2(config, emb_model_override, mv2_dimension) {
1065 ("Hybrid (lexical + semantic)".to_string(), Some(runtime))
1066 } else {
1067 ("Lexical (semantic unavailable)".to_string(), None)
1068 }
1069 }
1070 };
1071
1072 let mode_key = match args.mode {
1073 SearchMode::Sem => "semantic",
1074 SearchMode::Lex => "text",
1075 SearchMode::Auto => {
1076 if runtime_option.is_some() {
1077 "hybrid"
1078 } else {
1079 "text"
1080 }
1081 }
1082 };
1083
1084 let (response, engine_label) = if args.mode == SearchMode::Sem {
1086 let runtime = runtime_option.as_ref().ok_or_else(|| {
1087 anyhow!("Semantic search requires an embedding runtime")
1088 })?;
1089
1090 let query_embedding = runtime.embed(&args.query)?;
1092
1093 let scope = args.scope.as_deref().or(args.uri.as_deref());
1095 match mem.vec_search_with_embedding(
1096 &args.query,
1097 &query_embedding,
1098 args.top_k,
1099 args.snippet_chars,
1100 scope,
1101 ) {
1102 Ok(mut resp) => {
1103 apply_preference_rerank(&mut resp);
1105 (resp, "semantic (HNSW vector index)".to_string())
1106 }
1107 Err(e) => {
1108 warn!("Vector search failed ({e}), falling back to lexical + rerank");
1110 let request = SearchRequest {
1111 query: args.query.clone(),
1112 top_k: args.top_k,
1113 snippet_chars: args.snippet_chars,
1114 uri: args.uri.clone(),
1115 scope: args.scope.clone(),
1116 cursor: args.cursor.clone(),
1117 #[cfg(feature = "temporal_track")]
1118 temporal: None,
1119 as_of_frame: args.as_of_frame,
1120 as_of_ts: args.as_of_ts,
1121 };
1122 let mut resp = mem.search(request)?;
1123 apply_semantic_rerank(runtime, &mut mem, &mut resp)?;
1124 (resp, "semantic (fallback rerank)".to_string())
1125 }
1126 }
1127 } else {
1128 let request = SearchRequest {
1130 query: args.query.clone(),
1131 top_k: args.top_k,
1132 snippet_chars: args.snippet_chars,
1133 uri: args.uri.clone(),
1134 scope: args.scope.clone(),
1135 cursor: args.cursor.clone(),
1136 #[cfg(feature = "temporal_track")]
1137 temporal: None,
1138 as_of_frame: args.as_of_frame,
1139 as_of_ts: args.as_of_ts,
1140 };
1141
1142 let mut resp = mem.search(request)?;
1143
1144 if matches!(resp.engine, SearchEngineKind::LexFallback) && args.mode != SearchMode::Lex {
1145 warn!("Search index unavailable; returning basic text results");
1146 }
1147
1148 let mut engine_label = match resp.engine {
1149 SearchEngineKind::Tantivy => "text (tantivy)".to_string(),
1150 SearchEngineKind::LexFallback => "text (fallback)".to_string(),
1151 SearchEngineKind::Hybrid => "hybrid".to_string(),
1152 };
1153
1154 if runtime_option.is_some() {
1155 engine_label = format!("hybrid ({engine_label} + semantic)");
1156 }
1157
1158 if let Some(ref runtime) = runtime_option {
1159 apply_semantic_rerank(runtime, &mut mem, &mut resp)?;
1160 }
1161
1162 (resp, engine_label)
1163 };
1164
1165 if args.json_legacy {
1166 warn!("--json-legacy is deprecated; use --json for mv2.search.v1 output");
1167 emit_legacy_search_json(&response)?;
1168 } else if args.json {
1169 emit_search_json(&response, mode_key)?;
1170 } else {
1171 println!(
1172 "mode: {} k={} time: {} ms",
1173 mode_label, response.params.top_k, response.elapsed_ms
1174 );
1175 println!("engine: {}", engine_label);
1176 println!(
1177 "hits: {} (showing {})",
1178 response.total_hits,
1179 response.hits.len()
1180 );
1181 emit_search_table(&response);
1182 }
1183 Ok(())
1184}
1185
1186pub fn handle_vec_search(_config: &CliConfig, args: VecSearchArgs) -> Result<()> {
1187 let mut mem = open_read_only_mem(&args.file)?;
1188 let vector = if let Some(path) = args.embedding.as_deref() {
1189 read_embedding(path)?
1190 } else if let Some(vector_string) = &args.vector {
1191 parse_vector(vector_string)?
1192 } else {
1193 anyhow::bail!("provide --vector or --embedding for search input");
1194 };
1195
1196 let hits = mem.search_vec(&vector, args.limit)?;
1197 let mut enriched = Vec::with_capacity(hits.len());
1198 for hit in hits {
1199 let preview = mem.frame_preview_by_id(hit.frame_id)?;
1200 enriched.push((hit.frame_id, hit.distance, preview));
1201 }
1202
1203 if args.json {
1204 let json_hits: Vec<_> = enriched
1205 .iter()
1206 .map(|(frame_id, distance, preview)| {
1207 json!({
1208 "frame_id": frame_id,
1209 "distance": distance,
1210 "preview": preview,
1211 })
1212 })
1213 .collect();
1214 println!("{}", serde_json::to_string_pretty(&json_hits)?);
1215 } else if enriched.is_empty() {
1216 println!("No vector matches found");
1217 } else {
1218 for (frame_id, distance, preview) in enriched {
1219 println!("frame {frame_id} (distance {distance:.6}): {preview}");
1220 }
1221 }
1222 Ok(())
1223}
1224
1225pub fn handle_audit(config: &CliConfig, args: AuditArgs) -> Result<()> {
1226 use memvid_core::AuditOptions;
1227 use std::fs::File;
1228 use std::io::Write;
1229
1230 let mut mem = Memvid::open(&args.file)?;
1231
1232 let start = parse_date_boundary(args.start.as_ref(), false)?;
1234 let end = parse_date_boundary(args.end.as_ref(), true)?;
1235 if let (Some(start_ts), Some(end_ts)) = (start, end) {
1236 if end_ts < start_ts {
1237 anyhow::bail!("--end must not be earlier than --start");
1238 }
1239 }
1240
1241 let ask_mode: AskMode = args.mode.into();
1243 let runtime = match args.mode {
1244 AskModeArg::Lex => None,
1245 AskModeArg::Sem => Some(load_embedding_runtime(config)?),
1246 AskModeArg::Hybrid => try_load_embedding_runtime(config),
1247 };
1248 let embedder = runtime.as_ref().map(|inner| inner as &dyn VecEmbedder);
1249
1250 let options = AuditOptions {
1252 top_k: Some(args.top_k),
1253 snippet_chars: Some(args.snippet_chars),
1254 mode: Some(ask_mode),
1255 scope: args.scope,
1256 start,
1257 end,
1258 include_snippets: true,
1259 };
1260
1261 let mut report = mem.audit(&args.question, Some(options), embedder)?;
1263
1264 if let Some(model_name) = args.use_model.as_deref() {
1266 let context = report
1268 .sources
1269 .iter()
1270 .filter_map(|s| s.snippet.clone())
1271 .collect::<Vec<_>>()
1272 .join("\n\n");
1273
1274 match run_model_inference(
1275 model_name,
1276 &report.question,
1277 &context,
1278 &[], None,
1280 None,
1281 None, ) {
1283 Ok(inference) => {
1284 report.answer = Some(inference.answer.answer);
1285 report.notes.push(format!(
1286 "Answer synthesized by model: {}",
1287 inference.answer.model
1288 ));
1289 }
1290 Err(err) => {
1291 warn!(
1292 "model inference unavailable for '{}': {err}. Using default answer.",
1293 model_name
1294 );
1295 }
1296 }
1297 }
1298
1299 let output = match args.format {
1301 AuditFormat::Text => report.to_text(),
1302 AuditFormat::Markdown => report.to_markdown(),
1303 AuditFormat::Json => serde_json::to_string_pretty(&report)?,
1304 };
1305
1306 if let Some(out_path) = args.out {
1308 let mut file = File::create(&out_path)?;
1309 file.write_all(output.as_bytes())?;
1310 println!("Audit report written to: {}", out_path.display());
1311 } else {
1312 println!("{}", output);
1313 }
1314
1315 Ok(())
1316}
1317
1318fn emit_search_json(response: &SearchResponse, mode: &str) -> Result<()> {
1319 let hits: Vec<_> = response.hits.iter().map(search_hit_to_json).collect();
1320
1321 let mut additional_params = serde_json::Map::new();
1322 if let Some(cursor) = &response.params.cursor {
1323 additional_params.insert("cursor".into(), json!(cursor));
1324 }
1325
1326 let mut params = serde_json::Map::new();
1327 params.insert("top_k".into(), json!(response.params.top_k));
1328 params.insert("snippet_chars".into(), json!(response.params.snippet_chars));
1329 params.insert("mode".into(), json!(mode));
1330 params.insert(
1331 "additional_params".into(),
1332 serde_json::Value::Object(additional_params),
1333 );
1334
1335 let mut metadata_json = serde_json::Map::new();
1336 metadata_json.insert("elapsed_ms".into(), json!(response.elapsed_ms));
1337 metadata_json.insert("total_hits".into(), json!(response.total_hits));
1338 metadata_json.insert(
1339 "next_cursor".into(),
1340 match &response.next_cursor {
1341 Some(cursor) => json!(cursor),
1342 None => serde_json::Value::Null,
1343 },
1344 );
1345 metadata_json.insert("engine".into(), json!(response.engine));
1346 metadata_json.insert("params".into(), serde_json::Value::Object(params));
1347
1348 let body = json!({
1349 "version": "mv2.result.v2",
1350 "query": response.query,
1351 "metadata": metadata_json,
1352 "hits": hits,
1353 "context": response.context,
1354 });
1355 println!("{}", serde_json::to_string_pretty(&body)?);
1356 Ok(())
1357}
1358
1359fn emit_ask_json(
1360 response: &AskResponse,
1361 requested_mode: AskModeArg,
1362 model: Option<&ModelAnswer>,
1363 include_sources: bool,
1364 mem: &mut Memvid,
1365) -> Result<()> {
1366 let hits: Vec<_> = response
1367 .retrieval
1368 .hits
1369 .iter()
1370 .map(search_hit_to_json)
1371 .collect();
1372
1373 let citations: Vec<_> = response
1374 .citations
1375 .iter()
1376 .map(|citation| {
1377 let mut map = serde_json::Map::new();
1378 map.insert("index".into(), json!(citation.index));
1379 map.insert("frame_id".into(), json!(citation.frame_id));
1380 map.insert("uri".into(), json!(citation.uri));
1381 if let Some(range) = citation.chunk_range {
1382 map.insert("chunk_range".into(), json!([range.0, range.1]));
1383 }
1384 if let Some(score) = citation.score {
1385 map.insert("score".into(), json!(score));
1386 }
1387 serde_json::Value::Object(map)
1388 })
1389 .collect();
1390
1391 let mut body = json!({
1392 "version": "mv2.ask.v1",
1393 "question": response.question,
1394 "answer": response.answer,
1395 "context_only": response.context_only,
1396 "mode": ask_mode_display(requested_mode),
1397 "retriever": ask_retriever_display(response.retriever),
1398 "top_k": response.retrieval.params.top_k,
1399 "results": hits,
1400 "citations": citations,
1401 "stats": {
1402 "retrieval_ms": response.stats.retrieval_ms,
1403 "synthesis_ms": response.stats.synthesis_ms,
1404 "latency_ms": response.stats.latency_ms,
1405 },
1406 "engine": search_engine_label(&response.retrieval.engine),
1407 "total_hits": response.retrieval.total_hits,
1408 "next_cursor": response.retrieval.next_cursor,
1409 "context": truncate_with_ellipsis(&response.retrieval.context, OUTPUT_CONTEXT_MAX_LEN),
1410 });
1411
1412 if let Some(model) = model {
1413 if let serde_json::Value::Object(ref mut map) = body {
1414 map.insert("model".into(), json!(model.requested));
1415 if model.model != model.requested {
1416 map.insert("model_used".into(), json!(model.model));
1417 }
1418 }
1419 }
1420
1421 if include_sources {
1423 if let serde_json::Value::Object(ref mut map) = body {
1424 let sources = build_sources_json(response, mem);
1425 map.insert("sources".into(), json!(sources));
1426 }
1427 }
1428
1429 println!("{}", serde_json::to_string_pretty(&body)?);
1430 Ok(())
1431}
1432
1433fn build_sources_json(response: &AskResponse, mem: &mut Memvid) -> Vec<serde_json::Value> {
1434 response
1435 .citations
1436 .iter()
1437 .enumerate()
1438 .map(|(idx, citation)| {
1439 let mut source = serde_json::Map::new();
1440 source.insert("index".into(), json!(idx + 1));
1441 source.insert("frame_id".into(), json!(citation.frame_id));
1442 source.insert("uri".into(), json!(citation.uri));
1443
1444 if let Some(range) = citation.chunk_range {
1445 source.insert("chunk_range".into(), json!([range.0, range.1]));
1446 }
1447 if let Some(score) = citation.score {
1448 source.insert("score".into(), json!(score));
1449 }
1450
1451 if let Ok(frame) = mem.frame_by_id(citation.frame_id) {
1453 if let Some(title) = frame.title {
1454 source.insert("title".into(), json!(title));
1455 }
1456 if !frame.tags.is_empty() {
1457 source.insert("tags".into(), json!(frame.tags));
1458 }
1459 if !frame.labels.is_empty() {
1460 source.insert("labels".into(), json!(frame.labels));
1461 }
1462 source.insert("frame_timestamp".into(), json!(frame.timestamp));
1463 if !frame.content_dates.is_empty() {
1464 source.insert("content_dates".into(), json!(frame.content_dates));
1465 }
1466 }
1467
1468 if let Some(hit) = response
1470 .retrieval
1471 .hits
1472 .iter()
1473 .find(|h| h.frame_id == citation.frame_id)
1474 {
1475 let snippet = hit.chunk_text.clone().unwrap_or_else(|| hit.text.clone());
1476 source.insert("snippet".into(), json!(snippet));
1477 }
1478
1479 serde_json::Value::Object(source)
1480 })
1481 .collect()
1482}
1483
1484fn emit_model_json(
1485 response: &AskResponse,
1486 requested_model: &str,
1487 model: Option<&ModelAnswer>,
1488 include_sources: bool,
1489 mem: &mut Memvid,
1490) -> Result<()> {
1491 let answer = response.answer.clone().unwrap_or_default();
1492 let requested_label = model
1493 .map(|m| m.requested.clone())
1494 .unwrap_or_else(|| requested_model.to_string());
1495 let used_label = model
1496 .map(|m| m.model.clone())
1497 .unwrap_or_else(|| requested_model.to_string());
1498
1499 let mut body = json!({
1500 "question": response.question,
1501 "model": requested_label,
1502 "model_used": used_label,
1503 "answer": answer,
1504 "context": truncate_with_ellipsis(&response.retrieval.context, OUTPUT_CONTEXT_MAX_LEN),
1505 });
1506
1507 if include_sources {
1509 if let serde_json::Value::Object(ref mut map) = body {
1510 let sources = build_sources_json(response, mem);
1511 map.insert("sources".into(), json!(sources));
1512 }
1513 }
1514
1515 println!("{}", serde_json::to_string_pretty(&body)?);
1516 Ok(())
1517}
1518
1519fn emit_ask_pretty(
1520 response: &AskResponse,
1521 requested_mode: AskModeArg,
1522 model: Option<&ModelAnswer>,
1523 include_sources: bool,
1524 mem: &mut Memvid,
1525) {
1526 println!(
1527 "mode: {} retriever: {} k={} latency: {} ms (retrieval {} ms)",
1528 ask_mode_pretty(requested_mode),
1529 ask_retriever_pretty(response.retriever),
1530 response.retrieval.params.top_k,
1531 response.stats.latency_ms,
1532 response.stats.retrieval_ms
1533 );
1534 if let Some(model) = model {
1535 if model.requested.trim() == model.model {
1536 println!("model: {}", model.model);
1537 } else {
1538 println!(
1539 "model requested: {} model used: {}",
1540 model.requested, model.model
1541 );
1542 }
1543 }
1544 println!(
1545 "engine: {}",
1546 search_engine_label(&response.retrieval.engine)
1547 );
1548 println!(
1549 "hits: {} (showing {})",
1550 response.retrieval.total_hits,
1551 response.retrieval.hits.len()
1552 );
1553
1554 if response.context_only {
1555 println!();
1556 println!("Context-only mode: synthesis disabled.");
1557 println!();
1558 } else if let Some(answer) = &response.answer {
1559 println!();
1560 println!("Answer:\n{answer}");
1561 println!();
1562 }
1563
1564 if !response.citations.is_empty() {
1565 println!("Citations:");
1566 for citation in &response.citations {
1567 match citation.score {
1568 Some(score) => println!(
1569 "[{}] {} (frame {}, score {:.3})",
1570 citation.index, citation.uri, citation.frame_id, score
1571 ),
1572 None => println!(
1573 "[{}] {} (frame {})",
1574 citation.index, citation.uri, citation.frame_id
1575 ),
1576 }
1577 }
1578 println!();
1579 }
1580
1581 if include_sources && !response.citations.is_empty() {
1583 println!("=== SOURCES ===");
1584 println!();
1585 for citation in &response.citations {
1586 println!("[{}] {}", citation.index, citation.uri);
1587
1588 if let Ok(frame) = mem.frame_by_id(citation.frame_id) {
1590 if let Some(title) = &frame.title {
1591 println!(" Title: {}", title);
1592 }
1593 println!(" Frame ID: {}", citation.frame_id);
1594 if let Some(score) = citation.score {
1595 println!(" Score: {:.4}", score);
1596 }
1597 if let Some((start, end)) = citation.chunk_range {
1598 println!(" Range: [{}..{})", start, end);
1599 }
1600 if !frame.tags.is_empty() {
1601 println!(" Tags: {}", frame.tags.join(", "));
1602 }
1603 if !frame.labels.is_empty() {
1604 println!(" Labels: {}", frame.labels.join(", "));
1605 }
1606 println!(" Timestamp: {}", frame.timestamp);
1607 if !frame.content_dates.is_empty() {
1608 println!(" Content Dates: {}", frame.content_dates.join(", "));
1609 }
1610 }
1611
1612 if let Some(hit) = response
1614 .retrieval
1615 .hits
1616 .iter()
1617 .find(|h| h.frame_id == citation.frame_id)
1618 {
1619 let snippet = hit.chunk_text.as_ref().unwrap_or(&hit.text);
1620 let truncated = if snippet.len() > 200 {
1621 format!("{}...", &snippet[..200])
1622 } else {
1623 snippet.clone()
1624 };
1625 println!(" Snippet: {}", truncated.replace('\n', " "));
1626 }
1627 println!();
1628 }
1629 }
1630
1631 if !include_sources {
1632 println!();
1633 emit_search_table(&response.retrieval);
1634 }
1635}
1636
1637fn emit_legacy_search_json(response: &SearchResponse) -> Result<()> {
1638 let hits: Vec<_> = response
1639 .hits
1640 .iter()
1641 .map(|hit| {
1642 json!({
1643 "frame_id": hit.frame_id,
1644 "matches": hit.matches,
1645 "snippets": [hit.text.clone()],
1646 })
1647 })
1648 .collect();
1649 println!("{}", serde_json::to_string_pretty(&hits)?);
1650 Ok(())
1651}
1652
1653fn emit_search_table(response: &SearchResponse) {
1654 if response.hits.is_empty() {
1655 println!("No results for '{}'.", response.query);
1656 return;
1657 }
1658 for hit in &response.hits {
1659 println!("#{} {} (matches {})", hit.rank, hit.uri, hit.matches);
1660 if let Some(title) = &hit.title {
1661 println!(" Title: {title}");
1662 }
1663 if let Some(score) = hit.score {
1664 println!(" Score: {score:.3}");
1665 }
1666 println!(" Range: [{}..{})", hit.range.0, hit.range.1);
1667 if let Some((chunk_start, chunk_end)) = hit.chunk_range {
1668 println!(" Chunk: [{}..{})", chunk_start, chunk_end);
1669 }
1670 if let Some(chunk_text) = &hit.chunk_text {
1671 println!(" Chunk Text: {}", chunk_text.trim());
1672 }
1673 if let Some(metadata) = &hit.metadata {
1674 if let Some(track) = &metadata.track {
1675 println!(" Track: {track}");
1676 }
1677 if !metadata.tags.is_empty() {
1678 println!(" Tags: {}", metadata.tags.join(", "));
1679 }
1680 if !metadata.labels.is_empty() {
1681 println!(" Labels: {}", metadata.labels.join(", "));
1682 }
1683 if let Some(created_at) = &metadata.created_at {
1684 println!(" Created: {created_at}");
1685 }
1686 if !metadata.content_dates.is_empty() {
1687 println!(" Content Dates: {}", metadata.content_dates.join(", "));
1688 }
1689 }
1690 println!(" Snippet: {}", hit.text.trim());
1691 println!();
1692 }
1693 if let Some(cursor) = &response.next_cursor {
1694 println!("Next cursor: {cursor}");
1695 }
1696}
1697
1698fn ask_mode_display(mode: AskModeArg) -> &'static str {
1699 match mode {
1700 AskModeArg::Lex => "lex",
1701 AskModeArg::Sem => "sem",
1702 AskModeArg::Hybrid => "hybrid",
1703 }
1704}
1705
1706fn ask_mode_pretty(mode: AskModeArg) -> &'static str {
1707 match mode {
1708 AskModeArg::Lex => "Lexical",
1709 AskModeArg::Sem => "Semantic",
1710 AskModeArg::Hybrid => "Hybrid",
1711 }
1712}
1713
1714fn ask_retriever_display(retriever: AskRetriever) -> &'static str {
1715 match retriever {
1716 AskRetriever::Lex => "lex",
1717 AskRetriever::Semantic => "semantic",
1718 AskRetriever::Hybrid => "hybrid",
1719 AskRetriever::LexFallback => "lex_fallback",
1720 AskRetriever::TimelineFallback => "timeline_fallback",
1721 }
1722}
1723
1724fn ask_retriever_pretty(retriever: AskRetriever) -> &'static str {
1725 match retriever {
1726 AskRetriever::Lex => "Lexical",
1727 AskRetriever::Semantic => "Semantic",
1728 AskRetriever::Hybrid => "Hybrid",
1729 AskRetriever::LexFallback => "Lexical (fallback)",
1730 AskRetriever::TimelineFallback => "Timeline (fallback)",
1731 }
1732}
1733
1734fn search_engine_label(engine: &SearchEngineKind) -> &'static str {
1735 match engine {
1736 SearchEngineKind::Tantivy => "text (tantivy)",
1737 SearchEngineKind::LexFallback => "text (fallback)",
1738 SearchEngineKind::Hybrid => "hybrid",
1739 }
1740}
1741
1742fn build_hit_id(uri: &str, frame_id: u64, start: usize) -> String {
1743 let digest = hash(uri.as_bytes()).to_hex().to_string();
1744 let prefix_len = digest.len().min(12);
1745 let prefix = &digest[..prefix_len];
1746 format!("mv2-hit-{prefix}-{frame_id}-{start}")
1747}
1748
1749fn truncate_with_ellipsis(text: &str, limit: usize) -> String {
1750 if text.chars().count() <= limit {
1751 return text.to_string();
1752 }
1753
1754 let truncated: String = text.chars().take(limit).collect();
1755 format!("{truncated}...")
1756}
1757
1758fn search_hit_to_json(hit: &SearchHit) -> serde_json::Value {
1759 let mut hit_json = serde_json::Map::new();
1760 hit_json.insert("rank".into(), json!(hit.rank));
1761 if let Some(score) = hit.score {
1762 hit_json.insert("score".into(), json!(score));
1763 }
1764 hit_json.insert(
1765 "id".into(),
1766 json!(build_hit_id(&hit.uri, hit.frame_id, hit.range.0)),
1767 );
1768 hit_json.insert("frame_id".into(), json!(hit.frame_id));
1769 hit_json.insert("uri".into(), json!(hit.uri));
1770 if let Some(title) = &hit.title {
1771 hit_json.insert("title".into(), json!(title));
1772 }
1773 let chunk_range = hit.chunk_range.unwrap_or(hit.range);
1774 hit_json.insert("chunk_range".into(), json!([chunk_range.0, chunk_range.1]));
1775 hit_json.insert("range".into(), json!([hit.range.0, hit.range.1]));
1776 hit_json.insert("text".into(), json!(hit.text));
1777
1778 let metadata = hit.metadata.clone().unwrap_or_else(|| SearchHitMetadata {
1779 matches: hit.matches,
1780 ..SearchHitMetadata::default()
1781 });
1782 let mut meta_json = serde_json::Map::new();
1783 meta_json.insert("matches".into(), json!(metadata.matches));
1784 if !metadata.tags.is_empty() {
1785 meta_json.insert("tags".into(), json!(metadata.tags));
1786 }
1787 if !metadata.labels.is_empty() {
1788 meta_json.insert("labels".into(), json!(metadata.labels));
1789 }
1790 if let Some(track) = metadata.track {
1791 meta_json.insert("track".into(), json!(track));
1792 }
1793 if let Some(created_at) = metadata.created_at {
1794 meta_json.insert("created_at".into(), json!(created_at));
1795 }
1796 if !metadata.content_dates.is_empty() {
1797 meta_json.insert("content_dates".into(), json!(metadata.content_dates));
1798 }
1799 hit_json.insert("metadata".into(), serde_json::Value::Object(meta_json));
1800 serde_json::Value::Object(hit_json)
1801}
1802fn apply_semantic_rerank(
1811 runtime: &EmbeddingRuntime,
1812 mem: &mut Memvid,
1813 response: &mut SearchResponse,
1814) -> Result<()> {
1815 if response.hits.is_empty() {
1816 return Ok(());
1817 }
1818
1819 let query_embedding = runtime.embed(&response.query)?;
1820 let mut semantic_scores: HashMap<u64, f32> = HashMap::new();
1821 for hit in &response.hits {
1822 if let Some(embedding) = mem.frame_embedding(hit.frame_id)? {
1823 if embedding.len() == runtime.dimension() {
1824 let score = cosine_similarity(&query_embedding, &embedding);
1825 semantic_scores.insert(hit.frame_id, score);
1826 }
1827 }
1828 }
1829
1830 if semantic_scores.is_empty() {
1831 return Ok(());
1832 }
1833
1834 let mut sorted_semantic: Vec<(u64, f32)> = semantic_scores
1836 .iter()
1837 .map(|(frame_id, score)| (*frame_id, *score))
1838 .collect();
1839 sorted_semantic.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
1840
1841 let mut semantic_rank: HashMap<u64, usize> = HashMap::new();
1842 for (idx, (frame_id, _)) in sorted_semantic.iter().enumerate() {
1843 semantic_rank.insert(*frame_id, idx + 1);
1844 }
1845
1846 let query_lower = response.query.to_lowercase();
1848 let is_preference_query = query_lower.contains("suggest")
1849 || query_lower.contains("recommend")
1850 || query_lower.contains("should i")
1851 || query_lower.contains("what should")
1852 || query_lower.contains("prefer")
1853 || query_lower.contains("favorite")
1854 || query_lower.contains("best for me");
1855
1856 const RRF_K: f32 = 60.0;
1860
1861 let mut ordering: Vec<(usize, f32, usize)> = response
1862 .hits
1863 .iter()
1864 .enumerate()
1865 .map(|(idx, hit)| {
1866 let lexical_rank = hit.rank;
1867
1868 let lexical_rrf = 1.0 / (RRF_K + lexical_rank as f32);
1870
1871 let semantic_rrf = semantic_rank
1873 .get(&hit.frame_id)
1874 .map(|rank| 1.0 / (RRF_K + *rank as f32))
1875 .unwrap_or(0.0);
1876
1877 let preference_boost = if is_preference_query {
1880 compute_preference_boost(&hit.text) * 0.01 } else {
1882 0.0
1883 };
1884
1885 let combined = lexical_rrf + semantic_rrf + preference_boost;
1887 (idx, combined, lexical_rank)
1888 })
1889 .collect();
1890
1891 ordering.sort_by(|a, b| {
1892 b.1.partial_cmp(&a.1)
1893 .unwrap_or(Ordering::Equal)
1894 .then(a.2.cmp(&b.2))
1895 });
1896
1897 let mut reordered = Vec::with_capacity(response.hits.len());
1898 for (rank_idx, (idx, _, _)) in ordering.into_iter().enumerate() {
1899 let mut hit = response.hits[idx].clone();
1900 hit.rank = rank_idx + 1;
1901 reordered.push(hit);
1902 }
1903
1904 response.hits = reordered;
1905 Ok(())
1906}
1907
1908fn apply_preference_rerank(response: &mut SearchResponse) {
1911 if response.hits.is_empty() {
1912 return;
1913 }
1914
1915 let query_lower = response.query.to_lowercase();
1917 let is_preference_query = query_lower.contains("suggest")
1918 || query_lower.contains("recommend")
1919 || query_lower.contains("should i")
1920 || query_lower.contains("what should")
1921 || query_lower.contains("prefer")
1922 || query_lower.contains("favorite")
1923 || query_lower.contains("best for me");
1924
1925 if !is_preference_query {
1926 return;
1927 }
1928
1929 let mut scored: Vec<(usize, f32, f32)> = response
1931 .hits
1932 .iter()
1933 .enumerate()
1934 .map(|(idx, hit)| {
1935 let original_score = hit.score.unwrap_or(0.0);
1936 let preference_boost = compute_preference_boost(&hit.text);
1937 let boosted_score = original_score + preference_boost;
1938 (idx, boosted_score, original_score)
1939 })
1940 .collect();
1941
1942 scored.sort_by(|a, b| {
1944 b.1.partial_cmp(&a.1)
1945 .unwrap_or(Ordering::Equal)
1946 .then_with(|| b.2.partial_cmp(&a.2).unwrap_or(Ordering::Equal))
1947 });
1948
1949 let mut reordered = Vec::with_capacity(response.hits.len());
1951 for (rank_idx, (idx, _, _)) in scored.into_iter().enumerate() {
1952 let mut hit = response.hits[idx].clone();
1953 hit.rank = rank_idx + 1;
1954 reordered.push(hit);
1955 }
1956
1957 response.hits = reordered;
1958}
1959
1960fn compute_preference_boost(text: &str) -> f32 {
1969 let text_lower = text.to_lowercase();
1970 let mut boost = 0.0f32;
1971
1972 let established_context = [
1975 "i've been",
1977 "i've had",
1978 "i've used",
1979 "i've tried",
1980 "i recently",
1981 "i just",
1982 "lately",
1983 "i started",
1984 "i bought",
1985 "i harvested",
1986 "i grew",
1987 "my garden",
1989 "my home",
1990 "my house",
1991 "my setup",
1992 "my equipment",
1993 "my camera",
1994 "my car",
1995 "my phone",
1996 "i have a",
1997 "i own",
1998 "i got a",
1999 "i prefer",
2001 "i like to",
2002 "i love to",
2003 "i enjoy",
2004 "i usually",
2005 "i always",
2006 "i typically",
2007 "my favorite",
2008 "i tend to",
2009 "i often",
2010 "i use",
2012 "i grow",
2013 "i cook",
2014 "i make",
2015 "i work on",
2016 "i'm into",
2017 "i collect",
2018 ];
2019 for pattern in established_context {
2020 if text_lower.contains(pattern) {
2021 boost += 0.15;
2022 }
2023 }
2024
2025 let first_person = [" i ", " my ", " me "];
2027 for pattern in first_person {
2028 if text_lower.contains(pattern) {
2029 boost += 0.02;
2030 }
2031 }
2032
2033 let request_patterns = [
2036 "i'm trying to",
2037 "i want to",
2038 "i need to",
2039 "looking for",
2040 "can you suggest",
2041 "can you help",
2042 ];
2043 for pattern in request_patterns {
2044 if text_lower.contains(pattern) {
2045 boost += 0.02;
2046 }
2047 }
2048
2049 boost.min(0.5)
2051}
2052
2053fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
2054 let mut dot = 0.0f32;
2055 let mut sum_a = 0.0f32;
2056 let mut sum_b = 0.0f32;
2057 for (x, y) in a.iter().zip(b.iter()) {
2058 dot += x * y;
2059 sum_a += x * x;
2060 sum_b += y * y;
2061 }
2062
2063 if sum_a <= f32::EPSILON || sum_b <= f32::EPSILON {
2064 0.0
2065 } else {
2066 dot / (sum_a.sqrt() * sum_b.sqrt())
2067 }
2068}
2069
2070fn apply_cross_encoder_rerank(response: &mut SearchResponse) -> Result<()> {
2078 if response.hits.is_empty() || response.hits.len() < 2 {
2079 return Ok(());
2080 }
2081
2082 let candidates_to_rerank = response.hits.len().min(50);
2084
2085 let options = RerankInitOptions::new(RerankerModel::JINARerankerV1TurboEn)
2088 .with_show_download_progress(true);
2089
2090 let mut reranker = match TextRerank::try_new(options) {
2091 Ok(r) => r,
2092 Err(e) => {
2093 warn!("Failed to initialize cross-encoder reranker: {e}");
2094 return Ok(());
2095 }
2096 };
2097
2098 let documents: Vec<String> = response.hits[..candidates_to_rerank]
2100 .iter()
2101 .map(|hit| hit.text.clone())
2102 .collect();
2103
2104 info!("Cross-encoder reranking {} candidates", documents.len());
2106 let rerank_results = match reranker.rerank(response.query.clone(), documents, false, None) {
2107 Ok(results) => results,
2108 Err(e) => {
2109 warn!("Cross-encoder reranking failed: {e}");
2110 return Ok(());
2111 }
2112 };
2113
2114 let mut reordered = Vec::with_capacity(response.hits.len());
2116 for (new_rank, result) in rerank_results.iter().enumerate() {
2117 let original_idx = result.index;
2118 let mut hit = response.hits[original_idx].clone();
2119 hit.rank = new_rank + 1;
2120 hit.score = Some(result.score);
2122 reordered.push(hit);
2123 }
2124
2125 for hit in response.hits.iter().skip(candidates_to_rerank) {
2127 let mut h = hit.clone();
2128 h.rank = reordered.len() + 1;
2129 reordered.push(h);
2130 }
2131
2132 response.hits = reordered;
2133 info!("Cross-encoder reranking complete");
2134 Ok(())
2135}
2136
2137fn build_memory_context(mem: &Memvid) -> String {
2140 let entities = mem.memory_entities();
2141 if entities.is_empty() {
2142 return String::new();
2143 }
2144
2145 let mut sections = Vec::new();
2146 for entity in entities {
2147 let cards = mem.get_entity_memories(&entity);
2148 if cards.is_empty() {
2149 continue;
2150 }
2151
2152 let mut entity_lines = Vec::new();
2153 for card in cards {
2154 let polarity_marker = card
2156 .polarity
2157 .as_ref()
2158 .map(|p| match p.to_string().as_str() {
2159 "Positive" => " (+)",
2160 "Negative" => " (-)",
2161 _ => "",
2162 })
2163 .unwrap_or("");
2164 entity_lines.push(format!(
2165 " - {}: {}{}",
2166 card.slot, card.value, polarity_marker
2167 ));
2168 }
2169
2170 sections.push(format!("{}:\n{}", entity, entity_lines.join("\n")));
2171 }
2172
2173 sections.join("\n\n")
2174}