memvid_cli/commands/
enrich.rs

1//! Enrichment command handler for extracting memory cards from frames.
2//!
3//! The `enrich` command runs enrichment engines over MV2 frames to extract
4//! structured memory cards (facts, preferences, events, etc.).
5
6use std::path::PathBuf;
7
8#[cfg(feature = "llama-cpp")]
9use anyhow::bail;
10use anyhow::Result;
11use clap::{Args, ValueEnum};
12use memvid_core::{EnrichmentEngine, Memvid, RulesEngine};
13use serde::Serialize;
14
15#[cfg(feature = "llama-cpp")]
16use crate::commands::{default_enrichment_model, get_installed_model_path, LlmModel};
17use crate::config::CliConfig;
18#[cfg(feature = "candle-llm")]
19use crate::enrich::CandlePhiEngine;
20#[cfg(feature = "llama-cpp")]
21use crate::enrich::LlmEngine;
22use crate::enrich::OpenAiEngine;
23
24/// Engine type for enrichment
25#[derive(Debug, Clone, Copy, ValueEnum, Default)]
26pub enum EnrichEngine {
27    /// Rules-based extraction using regex patterns (fast, no models)
28    #[default]
29    Rules,
30    /// LLM-based extraction with Phi-3.5 Mini via llama.cpp (requires model installation)
31    #[cfg(feature = "llama-cpp")]
32    Llm,
33    /// Candle-based Phi-3 extraction (downloads from Hugging Face)
34    #[cfg(feature = "candle-llm")]
35    Candle,
36    /// OpenAI API-based extraction with GPT-4o-mini (requires OPENAI_API_KEY)
37    Openai,
38}
39
40/// Arguments for the `enrich` subcommand
41#[derive(Args)]
42pub struct EnrichArgs {
43    /// Path to the `.mv2` file
44    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
45    pub file: PathBuf,
46
47    /// Enrichment engine to use
48    #[arg(long, value_enum, default_value_t = EnrichEngine::Rules)]
49    pub engine: EnrichEngine,
50
51    /// Only process frames that haven't been enriched yet (default)
52    #[arg(long, default_value_t = true)]
53    pub incremental: bool,
54
55    /// Re-enrich all frames, ignoring previous enrichment records
56    #[arg(long, conflicts_with = "incremental")]
57    pub force: bool,
58
59    /// Output results as JSON
60    #[arg(long)]
61    pub json: bool,
62
63    /// Show extracted memory cards
64    #[arg(long)]
65    pub verbose: bool,
66}
67
68/// Result of enrichment for JSON output
69#[derive(Debug, Serialize)]
70pub struct EnrichResult {
71    pub engine: String,
72    pub version: String,
73    pub frames_processed: usize,
74    pub cards_extracted: usize,
75    pub total_cards: usize,
76    pub total_entities: usize,
77}
78
79/// Handle the `enrich` command
80#[allow(unused_variables)]
81pub fn handle_enrich(config: &CliConfig, args: EnrichArgs) -> Result<()> {
82    let mut mem = Memvid::open(&args.file)?;
83
84    // Get initial stats
85    let initial_stats = mem.memories_stats();
86
87    // If force mode, clear existing memories first
88    if args.force {
89        mem.clear_memories();
90    }
91
92    // Run the selected engine
93    let (engine_kind, engine_version, frames, cards) = match args.engine {
94        EnrichEngine::Rules => {
95            let engine = RulesEngine::new();
96            let kind = engine.kind().to_string();
97            let version = engine.version().to_string();
98            let (frames, cards) = mem.run_enrichment(&engine)?;
99            (kind, version, frames, cards)
100        }
101        #[cfg(feature = "llama-cpp")]
102        EnrichEngine::Llm => {
103            // Check if model is installed
104            let model = default_enrichment_model();
105            let model_path = match get_installed_model_path(config, model) {
106                Some(path) => path,
107                None => {
108                    bail!(
109                        "LLM model not installed. Run `memvid models install {}` first.",
110                        match model {
111                            LlmModel::Phi35Mini => "phi-3.5-mini",
112                            LlmModel::Phi35MiniQ8 => "phi-3.5-mini-q8",
113                        }
114                    );
115                }
116            };
117
118            // Create and initialize the LLM engine
119            let mut engine = LlmEngine::new(model_path);
120            eprintln!("Loading LLM model...");
121            engine.init()?;
122
123            let kind = engine.kind().to_string();
124            let version = engine.version().to_string();
125            let (frames, cards) = mem.run_enrichment(&engine)?;
126            (kind, version, frames, cards)
127        }
128        #[cfg(feature = "candle-llm")]
129        EnrichEngine::Candle => {
130            // Create and initialize the Candle Phi-3 engine
131            // Uses Q4 quantized GGUF (~2.4GB) stored in ~/.memvid/models/llm/phi-3-mini-q4/
132            eprintln!("Loading Phi-3-mini Q4 model via Candle (first run downloads ~2.4GB to ~/.memvid/models/llm/)...");
133            let mut engine = CandlePhiEngine::from_memvid_models(config.models_dir.clone());
134            engine.init()?;
135
136            let kind = engine.kind().to_string();
137            let version = engine.version().to_string();
138            let (frames, cards) = mem.run_enrichment(&engine)?;
139            (kind, version, frames, cards)
140        }
141        EnrichEngine::Openai => {
142            // Create and initialize the OpenAI engine with parallel batch support
143            eprintln!("Using OpenAI GPT-4o-mini for enrichment (parallel mode)...");
144            let mut engine = OpenAiEngine::new();
145            engine.init()?;
146
147            let kind = engine.kind().to_string();
148            let version = engine.version().to_string();
149
150            // Use parallel batch processing for OpenAI
151            let (frames, cards) = run_openai_parallel(&mut mem, &engine)?;
152            (kind, version, frames, cards)
153        }
154    };
155
156    // Commit changes
157    mem.commit()?;
158
159    // Get final stats
160    let final_stats = mem.memories_stats();
161
162    if args.json {
163        let result = EnrichResult {
164            engine: engine_kind,
165            version: engine_version,
166            frames_processed: frames,
167            cards_extracted: cards,
168            total_cards: final_stats.card_count,
169            total_entities: final_stats.entity_count,
170        };
171        println!("{}", serde_json::to_string_pretty(&result)?);
172    } else {
173        println!("Enrichment complete:");
174        println!("  Engine: {} v{}", engine_kind, engine_version);
175        println!("  Frames processed: {}", frames);
176        println!("  Cards extracted: {}", cards);
177        println!(
178            "  Total cards: {} (+{})",
179            final_stats.card_count,
180            final_stats
181                .card_count
182                .saturating_sub(initial_stats.card_count)
183        );
184        println!("  Entities: {}", final_stats.entity_count);
185
186        if args.verbose && cards > 0 {
187            println!("\nExtracted memory cards:");
188            for entity in mem.memory_entities() {
189                println!("  {}:", entity);
190                for card in mem.get_entity_memories(&entity) {
191                    println!("    - {}: {} = \"{}\"", card.kind, card.slot, card.value);
192                }
193            }
194        }
195    }
196
197    Ok(())
198}
199
200/// Run OpenAI enrichment with parallel batch processing.
201///
202/// This gathers all unenriched frames, sends them to OpenAI in parallel (20 concurrent requests),
203/// and stores the resulting memory cards. This is ~20x faster than sequential processing.
204fn run_openai_parallel(
205    mem: &mut memvid_core::Memvid,
206    engine: &OpenAiEngine,
207) -> Result<(usize, usize)> {
208    use memvid_core::enrich::EnrichmentContext;
209    use memvid_core::EnrichmentEngine;
210
211    let kind = engine.kind();
212    let version = engine.version();
213
214    // Get all unenriched frames
215    let unenriched = mem.get_unenriched_frames(kind, version);
216    let total_frames = unenriched.len();
217
218    if total_frames == 0 {
219        eprintln!("No unenriched frames found.");
220        return Ok((0, 0));
221    }
222
223    eprintln!(
224        "Gathering {} frames for parallel enrichment...",
225        total_frames
226    );
227
228    // Build enrichment contexts for all frames
229    let mut contexts = Vec::with_capacity(total_frames);
230    for frame_id in &unenriched {
231        let frame = match mem.frame_by_id(*frame_id) {
232            Ok(f) => f,
233            Err(_) => continue,
234        };
235
236        // Get frame content via preview (text extraction)
237        let text = match mem.frame_preview_by_id(*frame_id) {
238            Ok(t) => t,
239            Err(_) => continue,
240        };
241
242        let uri = frame
243            .uri
244            .clone()
245            .unwrap_or_else(|| format!("mv2://frame/{}", frame_id));
246        let metadata_json = frame
247            .metadata
248            .as_ref()
249            .and_then(|m| serde_json::to_string(m).ok());
250
251        let ctx = EnrichmentContext::new(
252            *frame_id,
253            uri,
254            text,
255            frame.title.clone(),
256            frame.timestamp,
257            metadata_json,
258        );
259
260        contexts.push(ctx);
261    }
262
263    eprintln!(
264        "Starting parallel enrichment of {} frames with 20 workers...",
265        contexts.len()
266    );
267
268    // Run parallel batch enrichment
269    let results = engine.enrich_batch(contexts);
270
271    // Store results back to MV2
272    let mut total_cards = 0;
273    for (frame_id, cards) in results {
274        let card_count = cards.len();
275
276        // Store cards
277        let card_ids = if !cards.is_empty() {
278            mem.put_memory_cards(cards)?
279        } else {
280            Vec::new()
281        };
282
283        // Record enrichment
284        mem.record_enrichment(frame_id, kind, version, card_ids)?;
285
286        total_cards += card_count;
287    }
288
289    Ok((total_frames, total_cards))
290}
291
292/// Handle the `memories` subcommand (view memory cards)
293#[derive(Args)]
294pub struct MemoriesArgs {
295    /// Path to the `.mv2` file
296    #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
297    pub file: PathBuf,
298
299    /// Filter by entity
300    #[arg(long)]
301    pub entity: Option<String>,
302
303    /// Filter by slot
304    #[arg(long)]
305    pub slot: Option<String>,
306
307    /// Output as JSON
308    #[arg(long)]
309    pub json: bool,
310}
311
312/// Memory card output for JSON serialization
313#[derive(Debug, Serialize)]
314pub struct MemoryOutput {
315    pub id: u64,
316    pub kind: String,
317    pub entity: String,
318    pub slot: String,
319    pub value: String,
320    pub polarity: Option<String>,
321    pub document_date: Option<i64>,
322    pub source_frame_id: u64,
323}
324
325pub fn handle_memories(_config: &CliConfig, args: MemoriesArgs) -> Result<()> {
326    let mem = Memvid::open(&args.file)?;
327
328    let stats = mem.memories_stats();
329
330    if args.json {
331        let mut cards: Vec<MemoryOutput> = Vec::new();
332
333        if let Some(entity) = &args.entity {
334            if let Some(slot) = &args.slot {
335                // Specific entity:slot
336                if let Some(card) = mem.get_current_memory(entity, slot) {
337                    cards.push(card_to_output(card));
338                }
339            } else {
340                // All cards for entity
341                for card in mem.get_entity_memories(entity) {
342                    cards.push(card_to_output(card));
343                }
344            }
345        } else {
346            // All entities
347            for entity in mem.memory_entities() {
348                for card in mem.get_entity_memories(&entity) {
349                    cards.push(card_to_output(card));
350                }
351            }
352        }
353
354        println!("{}", serde_json::to_string_pretty(&cards)?);
355    } else {
356        println!(
357            "Memory cards: {} total, {} entities",
358            stats.card_count, stats.entity_count
359        );
360        println!();
361
362        if let Some(entity) = &args.entity {
363            if let Some(slot) = &args.slot {
364                // Specific entity:slot
365                if let Some(card) = mem.get_current_memory(entity, slot) {
366                    println!("{}:{} = \"{}\"", entity, slot, card.value);
367                } else {
368                    println!("No memory found for {}:{}", entity, slot);
369                }
370            } else {
371                // All cards for entity
372                println!("{}:", entity);
373                for card in mem.get_entity_memories(entity) {
374                    println!("  {}: {} = \"{}\"", card.kind, card.slot, card.value);
375                }
376            }
377        } else {
378            // All entities
379            for entity in mem.memory_entities() {
380                println!("{}:", entity);
381                for card in mem.get_entity_memories(&entity) {
382                    let polarity = card
383                        .polarity
384                        .as_ref()
385                        .map(|p| format!(" [{}]", p))
386                        .unwrap_or_default();
387                    println!(
388                        "  {}: {} = \"{}\"{}",
389                        card.kind, card.slot, card.value, polarity
390                    );
391                }
392                println!();
393            }
394        }
395    }
396
397    Ok(())
398}
399
400fn card_to_output(card: &memvid_core::MemoryCard) -> MemoryOutput {
401    MemoryOutput {
402        id: card.id,
403        kind: card.kind.to_string(),
404        entity: card.entity.clone(),
405        slot: card.slot.clone(),
406        value: card.value.clone(),
407        polarity: card.polarity.as_ref().map(|p| p.to_string()),
408        document_date: card.document_date,
409        source_frame_id: card.source_frame_id,
410    }
411}