use anyhow::Result; use searchi_core::SearchIndex; use searchi_service::{HybridSearch, SearchOptions}; use std::fs; #[test] #[ignore = "Requires explicitly provisioned Google model and CPU worker"] fn real_google_search_persists_and_suppresses_changed_deleted_sources() -> Result<()> { assert_eq!( std::env::var("SEARCHI_FORCE_CPU").unwrap(), "1", "VM tests require CPU only" ); let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set SEARCHI_GEMMA_MODEL_DIR"); let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set SEARCHI_GEMMA_WORKER"); let temp = tempfile::tempdir()?; let root = temp.path().join("documents"); fs::create_dir(&root)?; let db = temp.path().join("index.db"); fs::write( root.join("agreement.txt"), "The supplier partnership may be ended with sixty days written notice.", )?; fs::write( root.join("garden.txt"), "Tomatoes need regular watering and lots of sunlight.", )?; let mut lexical = SearchIndex::open(&db)?; lexical.index_folder(&root)?; let mut hybrid = HybridSearch::open(&db, &model, &worker, 1)?; let report = hybrid.index_embeddings()?; assert_eq!(report.passages, 2); let hits = hybrid.search( "Wie beenden wir die Zusammenarbeit mit dem Lieferanten?", 10, &SearchOptions::default(), )?; assert_eq!(hits[0].filename, "agreement.txt"); assert!(hits[0].source.is_some()); assert!(hits[0].snippet.contains("sixty days")); let exact = hybrid.search("garden.txt", 10, &SearchOptions::default())?; assert_eq!(exact[0].match_kind, "exact_filename"); let restricted = hybrid.search( "Wie beenden wir die Zusammenarbeit mit dem Lieferanten?", 10, &SearchOptions { extension: Some("pdf".into()), ..Default::default() }, )?; assert!(restricted.is_empty()); drop(hybrid); let mut hybrid = HybridSearch::open(&db, &model, &worker, 1)?; assert_eq!( hybrid.search( "How can we stop working with the vendor?", 10, &SearchOptions::default() )?[0] .filename, "agreement.txt" ); fs::write(root.join("agreement.txt"),"Entirely unrelated replacement content about repainting a kitchen and changing cupboard handles.")?; lexical.index_folder(&root)?; assert!(hybrid .search_semantic( "How can we stop working with the vendor?", 10, &SearchOptions::default() )? .iter() .all(|hit| hit.filename != "agreement.txt")); fs::remove_file(root.join("garden.txt"))?; lexical.index_folder(&root)?; assert!(hybrid .search_semantic("How do we water vegetables?", 10, &SearchOptions::default())? .iter() .all(|hit| hit.filename != "garden.txt")); Ok(()) } #[test] #[ignore = "Requires explicitly provisioned Google model and CPU worker"] fn real_google_failed_policy_rebuild_preserves_live_reader_and_retry() -> Result<()> { use fs2::FileExt; use rusqlite::Connection; use searchi_service::{FfmpegTools, MediaLimits}; use std::fs::OpenOptions; assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1"); let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set SEARCHI_GEMMA_MODEL_DIR"); let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set SEARCHI_GEMMA_WORKER"); let temp = tempfile::tempdir()?; let root = temp.path().join("documents"); fs::create_dir(&root)?; let db = temp.path().join("index.db"); fs::write( root.join("agreement.txt"), "The supplier partnership may be ended with sixty days written notice.", )?; let mut lexical = SearchIndex::open(&db)?; lexical.index_folder(&root)?; let mut service = HybridSearch::open(&db, model, worker, 1)?; service.index_embeddings()?; let connection = Connection::open(&db)?; let active = |connection: &Connection| -> Result<(i64, String)> { Ok(connection.query_row( "SELECT g.id,g.fingerprint FROM semantic_generations g JOIN semantic_meta m ON m.active_generation=g.id", [], |row| Ok((row.get(0)?, row.get(1)?)), )?) }; let assert_reader = |service: &mut HybridSearch| -> Result<()> { let hits = service.search_semantic( "How can we stop working with the vendor?", 10, &SearchOptions::default(), )?; assert_eq!(hits.len(), 1); assert_eq!(hits[0].filename, "agreement.txt"); assert!(hits[0].snippet.contains("sixty days")); Ok(()) }; assert_reader(&mut service)?; let original = active(&connection)?; // No media files are present: exercise the production media-policy rebuild // with genuine document inference, without invoking any decoder fixture. let tools = FfmpegTools { ffmpeg: std::env::current_exe()?, ffprobe: std::env::current_exe()?, }; let limits = MediaLimits::default(); let lock_path = format!("{}.semantic.lock", db.canonicalize()?.to_string_lossy()); let lock = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(lock_path)?; lock.try_lock_exclusive()?; let failure = service .index_embeddings_with_media(&tools, &limits) .unwrap_err(); assert!(format!("{failure:#}").contains("Another semantic rebuild")); FileExt::unlock(&lock)?; assert_eq!(active(&connection)?, original); assert_reader(&mut service)?; connection.execute_batch("CREATE TRIGGER reject_generation_begin BEFORE INSERT ON semantic_generations BEGIN SELECT RAISE(ABORT,'rejected generation begin'); END;")?; assert!(service .index_embeddings_with_media(&tools, &limits) .is_err()); assert_eq!(active(&connection)?, original); assert_reader(&mut service)?; connection.execute_batch("DROP TRIGGER reject_generation_begin; CREATE TRIGGER reject_generation_activation BEFORE UPDATE OF active_generation ON semantic_meta BEGIN SELECT RAISE(ABORT,'rejected generation activation'); END;")?; let failure = service .index_embeddings_with_media(&tools, &limits) .unwrap_err(); assert!(format!("{failure:#}").contains("rejected generation activation")); assert_eq!(active(&connection)?, original); assert_reader(&mut service)?; let generations: i64 = connection.query_row("SELECT count(*) FROM semantic_generations", [], |row| { row.get(0) })?; assert_eq!( generations, 2, "Failed activation must retain resumable staging" ); // A repeated publication failure must preserve the same completed staging // generation and the live reader; it must not create or abandon another one. let failure = service .index_embeddings_with_media(&tools, &limits) .unwrap_err(); assert!(format!("{failure:#}").contains("rejected generation activation")); assert_eq!(active(&connection)?, original); assert_reader(&mut service)?; assert_eq!( connection.query_row("SELECT count(*) FROM semantic_generations", [], |row| row .get::<_, i64>( 0 ))?, 2 ); connection.execute_batch("DROP TRIGGER reject_generation_activation;")?; let retry = service.index_embeddings_with_media(&tools, &limits)?; assert_eq!(retry.passages, 1); assert!(retry.resumed); assert_eq!(retry.reused_sources, 1); assert_eq!(retry.document_inference_calls, 0); assert_reader(&mut service)?; let media_active = active(&connection)?; assert_ne!(media_active.1, original.1); // Exercise the reverse media-to-text policy change through the same helper. lock.try_lock_exclusive()?; assert!(service.index_embeddings().is_err()); FileExt::unlock(&lock)?; assert_eq!(active(&connection)?, media_active); assert_reader(&mut service)?; connection.execute_batch("CREATE TRIGGER reject_generation_activation BEFORE UPDATE OF active_generation ON semantic_meta BEGIN SELECT RAISE(ABORT,'rejected generation activation'); END;")?; assert!(service.index_embeddings().is_err()); assert_eq!(active(&connection)?, media_active); assert_reader(&mut service)?; connection.execute_batch("DROP TRIGGER reject_generation_activation;")?; let reverse_retry = service.index_embeddings()?; assert!(reverse_retry.resumed); assert_eq!(reverse_retry.document_inference_calls, 0); assert_eq!(active(&connection)?.1, original.1); assert_reader(&mut service)?; Ok(()) }