Skip to main content

sqlite_graphrag/commands/enrich/
mod.rs

1// v1.0.97: modularised into queue.rs, scan.rs, postprocess.rs, extraction.rs.
2// See ADR-0056 (closes the ADR-0046 "Known Tech Debt (v1.0.89+)" item).
3// v1.1.8 Wave C1: further split into schemas/args/events/run modules (≤800 LOC).
4
5//! Handler for the `enrich` CLI subcommand (GAP-14 + GAP-18).
6//!
7//! Enriches the knowledge graph by running LLM-powered analysis over memories
8//! and entities that are missing key structural data. Operations are:
9//!
10//! - `memory-bindings`: memories without `memory_entities` rows get entity extraction
11//! - `entity-descriptions`: entities with NULL/empty descriptions get LLM descriptions
12//! - `body-enrich`: memories with short bodies get expanded by the LLM (GAP-18)
13//! - `re-embed`: memories without a vector row get re-embedded without rewriting body
14//!
15//! Architecture mirrors `ingest_claude.rs`: SCAN → JUDGE (LLM) → PERSIST, with a
16//! SQLite queue DB derived next to `--db` (GAP-SG-64) for resume/retry support.
17// Workload: Subprocess I/O-bound (claude/codex API calls with network wait)
18//!
19//! # DRY note
20//!
21//! v1.0.97: `claude_runner.rs` now hosts the shared Claude invocation helpers
22//! (`run_claude`, `parse_claude_output`, `spawn_with_memory_limit`).
23//! GAP-SG-121: enrich vs ingest queue table shapes are different products
24//! (item_key/operation vs file_path); only sidecar WAL/busy pragmas are shared
25//! via [`crate::pragmas::apply_sidecar_queue_pragmas`].
26
27mod args;
28mod drain_parallel;
29mod drain_serial;
30mod events;
31mod extraction;
32// extraction_providers is a child of extraction.rs (#[path])
33// extraction_ops_* are submodules of extraction.rs (#[path])
34mod postprocess;
35mod predicates;
36mod prompts;
37mod queue;
38mod queue_ops;
39mod run;
40mod scan;
41mod scan_ec;
42mod quality_sample;
43mod scheduler;
44mod schemas;
45mod status;
46
47pub(crate) const DEFAULT_RATE_LIMIT_WAIT: u64 = 60;
48pub(crate) const DEFAULT_BODY_ENRICH_MIN_CHARS: usize = 500;
49pub(crate) const DEFAULT_BODY_ENRICH_MAX_CHARS: usize = 2000;
50
51pub use args::{EnrichArgs, EnrichMode, EnrichOperation, ReEmbedTarget};
52pub use queue::{cleanup_queue_entry, DeadItem, DeadSummary, EnrichStatus, WaitingItem};
53pub use run::run;
54
55use crate::errors::AppError;
56use queue::{enqueue_candidate_with_priority, open_queue_db, PRIORITY_HOT};
57
58/// GAP-CLI-PRIO-02: enqueue entity-descriptions for a hot set of entity names
59/// into the enrich sidecar queue with elevated priority.
60pub fn enqueue_priority_entity_descriptions(
61    paths: &crate::paths::AppPaths,
62    namespace: &str,
63    entity_names: &[String],
64) -> Result<usize, AppError> {
65    let _ = namespace; // queue keys are entity names; namespace is scoped by DB path
66    let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
67    let queue = open_queue_db(&queue_path)?;
68    let mut n = 0usize;
69    for name in entity_names {
70        enqueue_candidate_with_priority(
71            &queue,
72            name,
73            "entity",
74            "EntityDescriptions",
75            PRIORITY_HOT,
76        );
77        n += 1;
78    }
79    Ok(n)
80}
81
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use super::events::{enrich_operation_cli_name, is_sqlite_interrupt, scan_operation_with_deadline};
87    use super::schemas::{
88        BINDINGS_SCHEMA, BODY_ENRICH_SCHEMA, ENTITY_DESCRIPTION_SCHEMA,
89    };
90    use crate::errors::AppError;
91    use rusqlite::{Connection, ErrorCode};
92    use std::time::{Duration, Instant};
93
94    #[test]
95    fn bindings_schema_is_valid_json() {
96        let _: serde_json::Value =
97            serde_json::from_str(BINDINGS_SCHEMA).expect("BINDINGS_SCHEMA must be valid JSON");
98    }
99
100    #[test]
101    fn entity_description_schema_is_valid_json() {
102        let _: serde_json::Value = serde_json::from_str(ENTITY_DESCRIPTION_SCHEMA)
103            .expect("ENTITY_DESCRIPTION_SCHEMA must be valid JSON");
104    }
105
106    #[test]
107    fn body_enrich_schema_is_valid_json() {
108        let _: serde_json::Value = serde_json::from_str(BODY_ENRICH_SCHEMA)
109            .expect("BODY_ENRICH_SCHEMA must be valid JSON");
110    }
111
112    // v1.1.06 — GAP-ENTITY-CONNECT-SCAN-CARTESIAN observability + interrupt
113
114    #[test]
115    fn enrich_operation_cli_name_pair_ops_are_kebab_case() {
116        assert_eq!(
117            enrich_operation_cli_name(&EnrichOperation::EntityConnect),
118            "entity-connect"
119        );
120        assert_eq!(
121            enrich_operation_cli_name(&EnrichOperation::CrossDomainBridges),
122            "cross-domain-bridges"
123        );
124        assert_eq!(
125            enrich_operation_cli_name(&EnrichOperation::EntityDescriptions),
126            "entity-descriptions"
127        );
128    }
129
130    #[test]
131    fn is_sqlite_interrupt_detects_operation_interrupted() {
132        let ffi_err = rusqlite::ffi::Error {
133            code: ErrorCode::OperationInterrupted,
134            extended_code: 9,
135        };
136        let err = rusqlite::Error::SqliteFailure(ffi_err, Some("interrupted".into()));
137        assert!(is_sqlite_interrupt(&err));
138
139        let busy = rusqlite::ffi::Error {
140            code: ErrorCode::DatabaseBusy,
141            extended_code: 5,
142        };
143        let busy_err = rusqlite::Error::SqliteFailure(busy, None);
144        assert!(!is_sqlite_interrupt(&busy_err));
145    }
146
147    #[test]
148    fn scan_deadline_already_elapsed_returns_timeout() {
149        // Past deadline must fail fast without running SQL (exit path → Timeout).
150        use clap::Parser;
151        let cli = crate::cli::Cli::try_parse_from([
152            "sqlite-graphrag",
153            "enrich",
154            "--operation",
155            "entity-connect",
156            "--mode",
157            "openrouter",
158            "--openrouter-model",
159            "test/model",
160            "--dry-run",
161            "--limit",
162            "1",
163        ])
164        .expect("parse enrich args");
165        let Some(crate::cli::Commands::Enrich(args)) = cli.command else {
166            panic!("expected Commands::Enrich");
167        };
168        let conn = Connection::open_in_memory().unwrap();
169        let past = Instant::now() - Duration::from_secs(1);
170        let err = scan_operation_with_deadline(&conn, "global", &args, Some(past))
171            .expect_err("elapsed deadline must Timeout");
172        match err {
173            AppError::Timeout { .. } => {}
174            other => panic!("expected Timeout, got {other:?}"),
175        }
176    }
177
178    #[test]
179    fn interrupt_handle_maps_long_query_to_sqlite_interrupt() {
180        // Live SQLite: watchdog interrupt aborts a recursive CTE (same mechanism
181        // as scan_operation_with_deadline). Confirms rusqlite InterruptHandle.
182        let conn = Connection::open_in_memory().unwrap();
183        let handle = conn.get_interrupt_handle();
184        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
185        let stop_w = std::sync::Arc::clone(&stop);
186        let watchdog = std::thread::spawn(move || {
187            std::thread::sleep(Duration::from_millis(30));
188            if !stop_w.load(std::sync::atomic::Ordering::Relaxed) {
189                handle.interrupt();
190            }
191        });
192        let result = conn.query_row(
193            "WITH RECURSIVE t(x) AS (
194                 SELECT 1
195                 UNION ALL
196                 SELECT x + 1 FROM t WHERE x < 500000000
197             )
198             SELECT COUNT(*) FROM t",
199            [],
200            |r| r.get::<_, i64>(0),
201        );
202        stop.store(true, std::sync::atomic::Ordering::Relaxed);
203        let _ = watchdog.join();
204        let err = result.expect_err("recursive CTE must be interrupted");
205        assert!(
206            is_sqlite_interrupt(&err),
207            "expected SQLITE_INTERRUPT, got {err:?}"
208        );
209    }
210}