Skip to main content

sc/cli/commands/
embeddings.rs

1//! Embeddings command implementation.
2//!
3//! Provides CLI commands for managing embedding providers:
4//! - `status` - Show provider availability and configuration
5//! - `configure` - Configure embedding provider settings
6//! - `backfill` - Generate embeddings for existing context items
7//! - `test` - Test provider connectivity
8
9use crate::cli::EmbeddingsCommands;
10use crate::config::resolve_db_path;
11use crate::embeddings::{
12    chunk_text, create_embedding_provider, detect_available_providers, get_embedding_settings,
13    is_embeddings_enabled, prepare_item_text, reset_embedding_settings, save_embedding_settings,
14    ChunkConfig, EmbeddingProviderType, EmbeddingSettings,
15};
16use tracing::{debug, info, warn};
17use crate::error::{Error, Result};
18use crate::storage::SqliteStorage;
19use serde::Serialize;
20use std::path::PathBuf;
21
22/// Output for embeddings status command.
23#[derive(Serialize)]
24struct StatusOutput {
25    enabled: bool,
26    configured_provider: Option<String>,
27    available_providers: Vec<ProviderStatus>,
28    active_provider: Option<ActiveProviderInfo>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    stats: Option<EmbeddingStatsOutput>,
31}
32
33#[derive(Serialize)]
34struct EmbeddingStatsOutput {
35    items_with_embeddings: usize,
36    items_without_embeddings: usize,
37    total_items: usize,
38}
39
40#[derive(Serialize)]
41struct ProviderStatus {
42    name: String,
43    available: bool,
44    model: Option<String>,
45    dimensions: Option<usize>,
46}
47
48#[derive(Serialize)]
49struct ActiveProviderInfo {
50    name: String,
51    model: String,
52    dimensions: usize,
53    max_chars: usize,
54}
55
56/// Output for embeddings test command.
57#[derive(Serialize)]
58struct TestOutput {
59    success: bool,
60    provider: String,
61    model: String,
62    dimensions: usize,
63    input_text: String,
64    embedding_sample: Vec<f32>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    error: Option<String>,
67}
68
69/// Output for configure command.
70#[derive(Serialize)]
71struct ConfigureOutput {
72    success: bool,
73    message: String,
74    settings: EmbeddingSettings,
75}
76
77/// Output for backfill command.
78#[derive(Serialize)]
79struct BackfillOutput {
80    processed: usize,
81    skipped: usize,
82    errors: usize,
83    provider: String,
84    model: String,
85}
86
87/// Output for upgrade-quality command.
88#[derive(Serialize)]
89struct UpgradeQualityOutput {
90    upgraded: usize,
91    skipped: usize,
92    errors: usize,
93    provider: String,
94    model: String,
95    total_eligible: usize,
96}
97
98/// Execute embeddings command.
99pub fn execute(command: EmbeddingsCommands, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
100    // Create tokio runtime for async operations
101    let rt = tokio::runtime::Runtime::new()
102        .map_err(|e| Error::Other(format!("Failed to create async runtime: {e}")))?;
103
104    rt.block_on(async { execute_async(command, db_path, json).await })
105}
106
107async fn execute_async(command: EmbeddingsCommands, db_path: Option<&PathBuf>, json: bool) -> Result<()> {
108    match command {
109        EmbeddingsCommands::Status => execute_status(db_path, json).await,
110        EmbeddingsCommands::Configure {
111            provider,
112            enable,
113            disable,
114            model,
115            endpoint,
116            token,
117        } => execute_configure(db_path, provider, enable, disable, model, endpoint, token, json).await,
118        EmbeddingsCommands::Backfill {
119            limit,
120            session,
121            force,
122        } => execute_backfill(db_path, limit, session, force, json).await,
123        EmbeddingsCommands::Test { text } => execute_test(&text, json).await,
124        EmbeddingsCommands::ProcessPending { limit, quiet } => {
125            execute_process_pending(db_path, limit, quiet).await
126        }
127        EmbeddingsCommands::UpgradeQuality { limit, session } => {
128            execute_upgrade_quality(db_path, limit, session, json).await
129        }
130    }
131}
132
133/// Show embeddings status and provider availability.
134async fn execute_status(db_path: Option<&PathBuf>, json: bool) -> Result<()> {
135    let enabled = is_embeddings_enabled();
136    let settings = get_embedding_settings().unwrap_or_default();
137    let detection = detect_available_providers().await;
138
139    // Get embedding stats from database
140    let stats = if let Some(path) = resolve_db_path(db_path.map(|p| p.as_path())) {
141        if path.exists() {
142            SqliteStorage::open(&path)
143                .ok()
144                .and_then(|storage| storage.count_embedding_status(None).ok())
145                .map(|s| EmbeddingStatsOutput {
146                    items_with_embeddings: s.with_embeddings,
147                    items_without_embeddings: s.without_embeddings,
148                    total_items: s.with_embeddings + s.without_embeddings,
149                })
150        } else {
151            None
152        }
153    } else {
154        None
155    };
156
157    // Try to create the active provider
158    let active_provider = if enabled {
159        create_embedding_provider().await
160    } else {
161        None
162    };
163
164    let configured_provider = settings
165        .as_ref()
166        .and_then(|s| s.provider.as_ref())
167        .map(|p| p.to_string());
168
169    // Build provider status list
170    let mut providers = Vec::new();
171
172    // Ollama
173    let ollama_available = detection.available.contains(&"ollama".to_string());
174    providers.push(ProviderStatus {
175        name: "ollama".to_string(),
176        available: ollama_available,
177        model: if ollama_available {
178            Some(
179                settings
180                    .as_ref()
181                    .and_then(|s| s.OLLAMA_MODEL.clone())
182                    .unwrap_or_else(|| "nomic-embed-text".to_string()),
183            )
184        } else {
185            None
186        },
187        dimensions: if ollama_available { Some(768) } else { None },
188    });
189
190    // HuggingFace
191    let hf_available = detection.available.contains(&"huggingface".to_string());
192    providers.push(ProviderStatus {
193        name: "huggingface".to_string(),
194        available: hf_available,
195        model: if hf_available {
196            Some(
197                settings
198                    .as_ref()
199                    .and_then(|s| s.HF_MODEL.clone())
200                    .unwrap_or_else(|| "sentence-transformers/all-MiniLM-L6-v2".to_string()),
201            )
202        } else {
203            None
204        },
205        dimensions: if hf_available { Some(384) } else { None },
206    });
207
208    let active_info = active_provider.as_ref().map(|p| {
209        let info = p.info();
210        ActiveProviderInfo {
211            name: info.name,
212            model: info.model,
213            dimensions: info.dimensions,
214            max_chars: info.max_chars,
215        }
216    });
217
218    if json {
219        let output = StatusOutput {
220            enabled,
221            configured_provider,
222            available_providers: providers,
223            active_provider: active_info,
224            stats,
225        };
226        println!("{}", serde_json::to_string(&output)?);
227    } else {
228        println!("Embeddings Status");
229        println!("=================");
230        println!();
231        println!("Enabled: {}", if enabled { "yes" } else { "no" });
232        if let Some(ref p) = configured_provider {
233            println!("Configured Provider: {p}");
234        }
235        println!();
236
237        println!("Available Providers:");
238        for p in &providers {
239            let status = if p.available { "✓" } else { "✗" };
240            print!("  {status} {}", p.name);
241            if let Some(ref m) = p.model {
242                print!(" ({m})");
243            }
244            println!();
245        }
246        println!();
247
248        if let Some(ref active) = active_info {
249            println!("Active Provider:");
250            println!("  Name:       {}", active.name);
251            println!("  Model:      {}", active.model);
252            println!("  Dimensions: {}", active.dimensions);
253            println!("  Max Chars:  {}", active.max_chars);
254        } else if enabled {
255            println!("No embedding provider available.");
256            println!();
257            println!("To enable embeddings:");
258            println!("  - Install Ollama: https://ollama.ai");
259            println!("  - Or set HF_TOKEN environment variable");
260        }
261
262        // Display stats if available
263        if let Some(ref s) = stats {
264            println!();
265            println!("Item Statistics:");
266            println!("  With embeddings:    {}", s.items_with_embeddings);
267            println!("  Without embeddings: {}", s.items_without_embeddings);
268            println!("  Total items:        {}", s.total_items);
269            if s.items_without_embeddings > 0 {
270                println!();
271                println!("Run 'sc embeddings backfill' to generate missing embeddings.");
272            }
273        }
274    }
275
276    Ok(())
277}
278
279/// Configure embedding settings.
280#[allow(clippy::fn_params_excessive_bools)]
281async fn execute_configure(
282    db_path: Option<&PathBuf>,
283    provider: Option<String>,
284    enable: bool,
285    disable: bool,
286    model: Option<String>,
287    endpoint: Option<String>,
288    token: Option<String>,
289    json: bool,
290) -> Result<()> {
291    // Get current settings or create defaults
292    let mut settings = get_embedding_settings()
293        .unwrap_or_default()
294        .unwrap_or_default();
295
296    let mut changed = false;
297    let mut messages = Vec::new();
298
299    // Handle enable/disable
300    if enable && disable {
301        return Err(Error::InvalidArgument(
302            "Cannot specify both --enable and --disable".to_string(),
303        ));
304    }
305
306    if enable {
307        settings.enabled = Some(true);
308        messages.push("Embeddings enabled");
309        changed = true;
310    } else if disable {
311        settings.enabled = Some(false);
312        messages.push("Embeddings disabled");
313        changed = true;
314    }
315
316    // Handle provider
317    if let Some(ref p) = provider {
318        let provider_type = match p.to_lowercase().as_str() {
319            "ollama" => EmbeddingProviderType::Ollama,
320            "huggingface" | "hf" => EmbeddingProviderType::Huggingface,
321            _ => {
322                return Err(Error::InvalidArgument(format!(
323                    "Unknown provider: {p}. Valid options: ollama, huggingface"
324                )));
325            }
326        };
327        settings.provider = Some(provider_type);
328        messages.push("Provider configured");
329        changed = true;
330    }
331
332    // Handle model
333    if let Some(ref m) = model {
334        // Set model for the configured provider
335        let provider_type = settings.provider.unwrap_or(EmbeddingProviderType::Ollama);
336        match provider_type {
337            EmbeddingProviderType::Ollama => {
338                settings.OLLAMA_MODEL = Some(m.clone());
339            }
340            EmbeddingProviderType::Huggingface => {
341                settings.HF_MODEL = Some(m.clone());
342            }
343            EmbeddingProviderType::Transformers => {
344                settings.TRANSFORMERS_MODEL = Some(m.clone());
345            }
346            EmbeddingProviderType::Model2vec => {
347                // Model2Vec model is configured via tiered settings
348                // Silently ignore for now - tiered config not yet implemented
349            }
350        }
351        messages.push("Model configured");
352        changed = true;
353    }
354
355    // Handle endpoint
356    if let Some(ref e) = endpoint {
357        let provider_type = settings.provider.unwrap_or(EmbeddingProviderType::Ollama);
358        match provider_type {
359            EmbeddingProviderType::Ollama => {
360                settings.OLLAMA_ENDPOINT = Some(e.clone());
361            }
362            EmbeddingProviderType::Huggingface => {
363                settings.HF_ENDPOINT = Some(e.clone());
364            }
365            _ => {}
366        }
367        messages.push("Endpoint configured");
368        changed = true;
369    }
370
371    // Handle token
372    if let Some(ref t) = token {
373        settings.HF_TOKEN = Some(t.clone());
374        messages.push("Token configured");
375        changed = true;
376    }
377
378    if !changed {
379        // If no changes, just show current config
380        return execute_status(db_path, json).await;
381    }
382
383    // Save settings
384    save_embedding_settings(&settings)?;
385
386    let message = messages.join(", ");
387
388    if json {
389        let output = ConfigureOutput {
390            success: true,
391            message,
392            settings,
393        };
394        println!("{}", serde_json::to_string(&output)?);
395    } else {
396        println!("Configuration updated: {message}");
397        println!();
398        execute_status(db_path, false).await?;
399    }
400
401    Ok(())
402}
403
404/// Backfill embeddings for existing context items.
405///
406/// This function:
407/// 1. Queries context items without embeddings (or all if --force)
408/// 2. Chunks large items for full semantic coverage
409/// 3. Generates embeddings via the configured provider
410/// 4. Stores embeddings as BLOBs in the database
411async fn execute_backfill(
412    db_path: Option<&PathBuf>,
413    limit: Option<usize>,
414    session: Option<String>,
415    force: bool,
416    json: bool,
417) -> Result<()> {
418    // Get database path
419    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
420
421    // Create provider first to fail fast if not available
422    let provider = create_embedding_provider()
423        .await
424        .ok_or_else(|| Error::Embedding("No embedding provider available".to_string()))?;
425
426    let info = provider.info();
427    let provider_name = info.name.clone();
428    let model_name = info.model.clone();
429
430    // Get chunk config based on provider
431    let chunk_config = if provider_name.to_lowercase().contains("ollama") {
432        ChunkConfig::for_ollama()
433    } else {
434        ChunkConfig::for_minilm()
435    };
436
437    // Open storage
438    let mut storage = SqliteStorage::open(&db_path)?;
439
440    // When --force is used, first resync phantom 'complete' items that lack actual
441    // embedding data (status says complete but no rows in embedding_chunks)
442    if force {
443        debug!("Force mode: resyncing phantom embedding statuses");
444        let reset_count = storage.resync_embedding_status()?;
445        if reset_count > 0 {
446            info!(reset_count, "Reset phantom embeddings (status was 'complete' but no data)");
447            if !json {
448                println!("Reset {} phantom embeddings (status was 'complete' but no data)", reset_count);
449            }
450        }
451    }
452
453    // Get items to process (now includes any reset phantom items)
454    let items = storage.get_items_without_embeddings(session.as_deref(), Some(limit.unwrap_or(1000) as u32))?;
455    debug!(items_to_process = items.len(), "Backfill items queried");
456
457    if items.is_empty() {
458        if json {
459            let output = BackfillOutput {
460                processed: 0,
461                skipped: 0,
462                errors: 0,
463                provider: provider_name,
464                model: model_name,
465            };
466            println!("{}", serde_json::to_string(&output)?);
467        } else {
468            println!("No items to process.");
469            println!("All context items already have embeddings.");
470        }
471        return Ok(());
472    }
473
474    let total_items = items.len();
475    let mut processed = 0;
476    let mut skipped = 0;
477    let mut errors = 0;
478
479    if !json {
480        println!("Backfilling embeddings for {} items...", total_items);
481        println!("Provider: {} ({})", provider_name, model_name);
482        println!();
483    }
484
485    for item in items {
486        // Prepare text for embedding
487        let text = prepare_item_text(&item.key, &item.value, Some(&item.category));
488
489        // Chunk the text
490        let chunks = chunk_text(&text, &chunk_config);
491
492        if chunks.is_empty() {
493            skipped += 1;
494            continue;
495        }
496
497        // Generate embeddings for each chunk
498        let mut chunk_errors = 0;
499        for (chunk_idx, chunk) in chunks.iter().enumerate() {
500            match provider.generate_embedding(&chunk.text).await {
501                Ok(embedding) => {
502                    // Generate chunk ID
503                    let chunk_id = format!("emb_{}_{}", item.id, chunk_idx);
504
505                    // Store the embedding
506                    if let Err(e) = storage.store_embedding_chunk(
507                        &chunk_id,
508                        &item.id,
509                        chunk_idx as i32,
510                        &chunk.text,
511                        &embedding,
512                        &provider_name,
513                        &model_name,
514                    ) {
515                        if !json {
516                            eprintln!("  Error storing chunk {}: {}", chunk_idx, e);
517                        }
518                        chunk_errors += 1;
519                    }
520                }
521                Err(e) => {
522                    if !json {
523                        eprintln!("  Error generating embedding for {}: {}", item.key, e);
524                    }
525                    chunk_errors += 1;
526                }
527            }
528        }
529
530        if chunk_errors == 0 {
531            processed += 1;
532            if !json {
533                println!("  ✓ {} ({} chunks)", item.key, chunks.len());
534            }
535        } else if chunk_errors < chunks.len() {
536            // Partial success
537            processed += 1;
538            errors += chunk_errors;
539            if !json {
540                println!("  ⚠ {} ({}/{} chunks)", item.key, chunks.len() - chunk_errors, chunks.len());
541            }
542        } else {
543            // Complete failure
544            errors += 1;
545            if !json {
546                println!("  ✗ {}", item.key);
547            }
548        }
549    }
550
551    if json {
552        let output = BackfillOutput {
553            processed,
554            skipped,
555            errors,
556            provider: provider_name,
557            model: model_name,
558        };
559        println!("{}", serde_json::to_string(&output)?);
560    } else {
561        println!();
562        println!("Complete!");
563        println!("  Processed: {}", processed);
564        println!("  Skipped:   {}", skipped);
565        println!("  Errors:    {}", errors);
566    }
567
568    Ok(())
569}
570
571/// Test embedding provider connectivity.
572async fn execute_test(text: &str, json: bool) -> Result<()> {
573    let provider = create_embedding_provider()
574        .await
575        .ok_or_else(|| Error::Embedding("No embedding provider available".to_string()))?;
576
577    let info = provider.info();
578
579    // Generate embedding
580    let result = provider.generate_embedding(text).await;
581
582    match result {
583        Ok(embedding) => {
584            let sample: Vec<f32> = embedding.iter().take(5).copied().collect();
585
586            if json {
587                let output = TestOutput {
588                    success: true,
589                    provider: info.name,
590                    model: info.model,
591                    dimensions: embedding.len(),
592                    input_text: text.to_string(),
593                    embedding_sample: sample,
594                    error: None,
595                };
596                println!("{}", serde_json::to_string(&output)?);
597            } else {
598                println!("Embedding Test: SUCCESS");
599                println!();
600                println!("Provider:   {}", info.name);
601                println!("Model:      {}", info.model);
602                println!("Dimensions: {}", embedding.len());
603                println!("Input:      \"{text}\"");
604                println!();
605                println!("Sample (first 5 values):");
606                for (i, v) in sample.iter().enumerate() {
607                    println!("  [{i}] {v:.6}");
608                }
609            }
610        }
611        Err(e) => {
612            if json {
613                let output = TestOutput {
614                    success: false,
615                    provider: info.name,
616                    model: info.model,
617                    dimensions: 0,
618                    input_text: text.to_string(),
619                    embedding_sample: vec![],
620                    error: Some(e.to_string()),
621                };
622                println!("{}", serde_json::to_string(&output)?);
623            } else {
624                println!("Embedding Test: FAILED");
625                println!();
626                println!("Provider: {}", info.name);
627                println!("Model:    {}", info.model);
628                println!("Error:    {e}");
629            }
630            return Err(e);
631        }
632    }
633
634    Ok(())
635}
636
637/// Process pending embeddings (for background execution).
638///
639/// This is called by the spawned background process after a save operation.
640/// It processes a limited number of items to avoid long-running operations.
641async fn execute_process_pending(
642    db_path: Option<&PathBuf>,
643    limit: usize,
644    quiet: bool,
645) -> Result<()> {
646    // Get database path
647    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
648
649    if !db_path.exists() {
650        return Ok(()); // No database yet, nothing to do
651    }
652
653    // Check if embeddings are enabled
654    if !is_embeddings_enabled() {
655        return Ok(());
656    }
657
658    // Try to create provider (may not be available)
659    let provider = match create_embedding_provider().await {
660        Some(p) => p,
661        None => return Ok(()), // No provider available, skip silently
662    };
663
664    let info = provider.info();
665    let provider_name = info.name.clone();
666    let model_name = info.model.clone();
667
668    // Get chunk config based on provider
669    let chunk_config = if provider_name.to_lowercase().contains("ollama") {
670        ChunkConfig::for_ollama()
671    } else {
672        ChunkConfig::for_minilm()
673    };
674
675    // Open storage
676    let mut storage = SqliteStorage::open(&db_path)?;
677
678    // Get items to process (limited batch)
679    let items = storage.get_items_without_embeddings(None, Some(limit as u32))?;
680
681    if items.is_empty() {
682        return Ok(());
683    }
684
685    let mut processed = 0;
686
687    for item in items {
688        // Prepare text for embedding
689        let text = prepare_item_text(&item.key, &item.value, Some(&item.category));
690
691        // Chunk the text
692        let chunks = chunk_text(&text, &chunk_config);
693
694        if chunks.is_empty() {
695            continue;
696        }
697
698        // Generate embeddings for each chunk
699        let mut success = true;
700        for (chunk_idx, chunk) in chunks.iter().enumerate() {
701            match provider.generate_embedding(&chunk.text).await {
702                Ok(embedding) => {
703                    let chunk_id = format!("emb_{}_{}", item.id, chunk_idx);
704                    if storage
705                        .store_embedding_chunk(
706                            &chunk_id,
707                            &item.id,
708                            chunk_idx as i32,
709                            &chunk.text,
710                            &embedding,
711                            &provider_name,
712                            &model_name,
713                        )
714                        .is_err()
715                    {
716                        success = false;
717                        break;
718                    }
719                }
720                Err(_) => {
721                    success = false;
722                    break;
723                }
724            }
725        }
726
727        if success {
728            processed += 1;
729            if !quiet {
730                eprintln!("[bg] Embedded: {} ({} chunks)", item.key, chunks.len());
731            }
732        }
733    }
734
735    if !quiet && processed > 0 {
736        eprintln!("[bg] Processed {} pending embeddings", processed);
737    }
738
739    Ok(())
740}
741
742/// Spawn a detached background process to generate embeddings.
743///
744/// This is called after save operations to process pending embeddings
745/// without blocking the main command. The spawned process runs independently
746/// and exits when done.
747pub fn spawn_background_embedder() {
748    use std::fs;
749    use std::process::{Command, Stdio};
750
751    // Only spawn if embeddings are enabled
752    if !is_embeddings_enabled() {
753        debug!("Background embedder skipped: embeddings disabled");
754        return;
755    }
756
757    // Get the current executable path
758    let exe = match std::env::current_exe() {
759        Ok(path) => path,
760        Err(e) => {
761            warn!(error = %e, "Background embedder: can't find executable");
762            return;
763        }
764    };
765
766    // Set up log file for debugging background failures
767    let log_file = directories::BaseDirs::new()
768        .map(|b| b.home_dir().join(".savecontext").join("logs"))
769        .and_then(|log_dir| {
770            fs::create_dir_all(&log_dir).ok()?;
771            fs::File::create(log_dir.join("embedder.log")).ok()
772        });
773
774    let stderr = match log_file {
775        Some(f) => Stdio::from(f),
776        None => Stdio::null(),
777    };
778
779    // Spawn detached process with stderr going to log file
780    match Command::new(&exe)
781        .args(["embeddings", "process-pending", "--quiet"])
782        .stdin(Stdio::null())
783        .stdout(Stdio::null())
784        .stderr(stderr)
785        .spawn()
786    {
787        Ok(_) => debug!("Background embedder spawned"),
788        Err(e) => warn!(error = %e, exe = %exe.display(), "Background embedder spawn failed"),
789    }
790}
791
792/// Reset embedding settings to defaults.
793#[allow(dead_code)]
794pub fn reset_to_defaults() -> Result<()> {
795    reset_embedding_settings()?;
796    println!("Embedding settings reset to defaults.");
797    Ok(())
798}
799
800/// Upgrade items with fast embeddings to quality embeddings.
801///
802/// This command processes items that have been saved with the 2-tier system
803/// (which generates instant Model2Vec embeddings) and generates higher-quality
804/// embeddings using Ollama or HuggingFace.
805///
806/// The quality embeddings enable better semantic search accuracy while the
807/// fast embeddings continue to provide instant results.
808async fn execute_upgrade_quality(
809    db_path: Option<&PathBuf>,
810    limit: Option<usize>,
811    session: Option<String>,
812    json: bool,
813) -> Result<()> {
814    // Get database path
815    let db_path = resolve_db_path(db_path.map(|p| p.as_path())).ok_or(Error::NotInitialized)?;
816
817    if !db_path.exists() {
818        return Err(Error::NotInitialized);
819    }
820
821    // Create quality provider (Ollama or HuggingFace)
822    let provider = create_embedding_provider()
823        .await
824        .ok_or_else(|| Error::Embedding("No quality embedding provider available. Install Ollama or set HF_TOKEN.".to_string()))?;
825
826    let info = provider.info();
827    let provider_name = info.name.clone();
828    let model_name = info.model.clone();
829
830    // Get chunk config based on provider
831    let chunk_config = if provider_name.to_lowercase().contains("ollama") {
832        ChunkConfig::for_ollama()
833    } else {
834        ChunkConfig::for_minilm()
835    };
836
837    // Open storage
838    let mut storage = SqliteStorage::open(&db_path)?;
839
840    // Get items that need quality upgrade (have fast embeddings but no quality)
841    let items = storage.get_items_needing_quality_upgrade(
842        session.as_deref(),
843        limit.map(|l| l as u32),
844    )?;
845
846    let total_eligible = items.len();
847
848    if items.is_empty() {
849        if json {
850            let output = UpgradeQualityOutput {
851                upgraded: 0,
852                skipped: 0,
853                errors: 0,
854                provider: provider_name,
855                model: model_name,
856                total_eligible: 0,
857            };
858            println!("{}", serde_json::to_string(&output)?);
859        } else {
860            println!("No items need quality upgrade.");
861            println!("All items with fast embeddings already have quality embeddings.");
862        }
863        return Ok(());
864    }
865
866    if !json {
867        println!("Upgrading {} items to quality embeddings...", total_eligible);
868        println!("Provider: {} ({})", provider_name, model_name);
869        println!();
870    }
871
872    let mut upgraded = 0;
873    let mut skipped = 0;
874    let mut errors = 0;
875
876    for item in items {
877        // Prepare text for embedding
878        let text = prepare_item_text(&item.key, &item.value, Some(&item.category));
879
880        // Chunk the text
881        let chunks = chunk_text(&text, &chunk_config);
882
883        if chunks.is_empty() {
884            skipped += 1;
885            if !json {
886                println!("  - {} (no content)", item.key);
887            }
888            continue;
889        }
890
891        // Generate embeddings for each chunk
892        let mut chunk_errors = 0;
893        for (chunk_idx, chunk) in chunks.iter().enumerate() {
894            match provider.generate_embedding(&chunk.text).await {
895                Ok(embedding) => {
896                    // Generate chunk ID (for quality tier)
897                    let chunk_id = format!("emb_{}_{}", item.id, chunk_idx);
898
899                    // Store the quality embedding
900                    if let Err(e) = storage.store_embedding_chunk(
901                        &chunk_id,
902                        &item.id,
903                        chunk_idx as i32,
904                        &chunk.text,
905                        &embedding,
906                        &provider_name,
907                        &model_name,
908                    ) {
909                        if !json {
910                            eprintln!("  Error storing chunk {}: {}", chunk_idx, e);
911                        }
912                        chunk_errors += 1;
913                    }
914                }
915                Err(e) => {
916                    if !json {
917                        eprintln!("  Error generating embedding for {}: {}", item.key, e);
918                    }
919                    chunk_errors += 1;
920                }
921            }
922        }
923
924        if chunk_errors == 0 {
925            upgraded += 1;
926            if !json {
927                println!("  ✓ {} ({} chunks)", item.key, chunks.len());
928            }
929        } else if chunk_errors < chunks.len() {
930            // Partial success
931            upgraded += 1;
932            errors += chunk_errors;
933            if !json {
934                println!("  ⚠ {} ({}/{} chunks)", item.key, chunks.len() - chunk_errors, chunks.len());
935            }
936        } else {
937            // Complete failure
938            errors += 1;
939            if !json {
940                println!("  ✗ {}", item.key);
941            }
942        }
943    }
944
945    if json {
946        let output = UpgradeQualityOutput {
947            upgraded,
948            skipped,
949            errors,
950            provider: provider_name,
951            model: model_name,
952            total_eligible,
953        };
954        println!("{}", serde_json::to_string(&output)?);
955    } else {
956        println!();
957        println!("Quality upgrade complete!");
958        println!("  Upgraded: {}", upgraded);
959        println!("  Skipped:  {}", skipped);
960        println!("  Errors:   {}", errors);
961        println!();
962        println!("Items now have both fast (instant) and quality (accurate) embeddings.");
963    }
964
965    Ok(())
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    #[test]
973    fn test_provider_status_serialization() {
974        let status = ProviderStatus {
975            name: "ollama".to_string(),
976            available: true,
977            model: Some("nomic-embed-text".to_string()),
978            dimensions: Some(768),
979        };
980        let json = serde_json::to_string(&status).unwrap();
981        assert!(json.contains("ollama"));
982        assert!(json.contains("768"));
983    }
984}