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 is SCAN → JUDGE (LLM) → PERSIST, with a SQLite queue DB derived
16//! next to `--db` (GAP-SG-64) for resume/retry support. The shape was inherited
17//! from the retired `ingest_claude.rs`, which v1.2.0 deleted along with every
18//! headless-subprocess frontend.
19// Workload: network-bound. JUDGE issues OpenRouter chat-completions requests
20// over HTTP in-process; `--rest-concurrency` is the only fan-out knob.
21//!
22//! # DRY note
23//!
24//! GAP-SG-121: enrich vs ingest queue table shapes are different products
25//! (item_key/operation vs file_path); only sidecar WAL/busy pragmas are shared
26//! via [`crate::pragmas::apply_sidecar_queue_pragmas`].
27
28mod args;
29mod drain_parallel;
30mod drain_serial;
31mod events;
32mod extraction;
33// extraction_providers is a child of extraction.rs (#[path])
34// extraction_ops_* are submodules of extraction.rs (#[path])
35mod postprocess;
36mod predicates;
37mod prompts;
38mod quality_sample;
39mod queue;
40mod queue_ops;
41mod reembed;
42mod run;
43mod scan;
44mod scan_ec;
45mod scheduler;
46mod schemas;
47mod status;
48
49pub(crate) const DEFAULT_RATE_LIMIT_WAIT: u64 = 60;
50pub(crate) const DEFAULT_BODY_ENRICH_MIN_CHARS: usize = 500;
51pub(crate) const DEFAULT_BODY_ENRICH_MAX_CHARS: usize = 2000;
52
53// GAP-SG-149: single source of truth for the enrich knobs that both the clap
54// surface (`default_value_t`) and the synthesized `EnrichArgs` of
55// `ingest --enrich-after` must agree on. Before this, `enrich_after.rs`
56// carried its own literals and two of them DIVERGED from the documented
57// default, so the auto-pass silently ran under a different contract than the
58// one `enrich --help` advertises.
59/// Attempts before a queue row is dead-lettered.
60pub(crate) const DEFAULT_ENRICH_MAX_ATTEMPTS: u32 = 8;
61/// Seconds after which a `processing` claim is considered abandoned.
62pub(crate) const DEFAULT_ENRICH_STALE_CLAIM_SECS: u64 = 1800;
63/// Seconds of headroom kept below a provider rate-limit reset.
64pub(crate) const DEFAULT_ENRICH_RATE_LIMIT_BUFFER_SECS: u64 = 300;
65/// Consecutive hard failures that trip the circuit breaker.
66pub(crate) const DEFAULT_ENRICH_CIRCUIT_BREAKER_THRESHOLD: u32 = 5;
67/// Minimum similarity for an enriched body to be accepted as preserving.
68pub(crate) const DEFAULT_ENRICH_PRESERVE_THRESHOLD: f64 = 0.7;
69/// Minimum grounding score for a generated entity description.
70///
71/// `grounding_coverage` is
72/// `|trigrams(candidate) ∩ trigrams(evidence)| / |trigrams(candidate)|`, not
73/// symmetric Jaccard, because a description is short (10–20 words) against
74/// multi-sentence bodies.
75///
76/// Raised from 0.12 to 0.30 (G-PR-7): 0.12 accepted a description whose
77/// trigrams were 88% absent from the very evidence it claimed to summarise.
78///
79/// This is the SINGLE definition. A second constant carrying the same meaning
80/// lived in `extraction_descriptions` and diverged to 0.30 while this one still
81/// read 0.12 — and because clap supplied this value through `default_value_t`,
82/// the other was unreachable.
83///
84/// CALIBRATED, not guessed. Measured 2026-08-13 over a 400-entity sample of a
85/// 106k-entity graph: p10 0.344, p25 0.404, p50 0.484, p75 0.604, p90 0.712.
86/// 0.30 therefore sits BELOW the tenth percentile — it rejects the worst decile
87/// and leaves the bulk of the distribution untouched, which is what a
88/// second-line filter should do now that the pre-LLM corpus gate stops
89/// evidence-free descriptions from being generated at all. Moving it to p25
90/// would reject a QUARTER of what the corpus can support.
91///
92/// Re-measure with `--status --quality-sample N`, which reports the same
93/// percentiles under `grounding_percentiles`; never adjust this by intuition.
94/// Override per host with XDG `enrich.entity_description.grounding_threshold`.
95pub(crate) const DEFAULT_ENRICH_GROUNDING_THRESHOLD: f64 = 0.30;
96
97pub use args::{EnrichArgs, EnrichMode, EnrichOperation, ReEmbedTarget};
98pub use queue::{cleanup_queue_entry, DeadItem, DeadSummary, EnrichStatus, WaitingItem};
99pub use run::run;
100
101use crate::errors::AppError;
102use queue::{enqueue_candidate_with_priority, open_queue_db, PRIORITY_HOT};
103
104/// GAP-CLI-PRIO-02: enqueue entity-descriptions for a hot set of entity names
105/// into the enrich sidecar queue with elevated priority.
106pub fn enqueue_priority_entity_descriptions(
107    paths: &crate::paths::AppPaths,
108    namespace: &str,
109    entity_names: &[String],
110) -> Result<usize, AppError> {
111    let _ = namespace; // queue keys are entity names; namespace is scoped by DB path
112    let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
113    let queue = open_queue_db(&queue_path)?;
114    let mut n = 0usize;
115    for name in entity_names {
116        enqueue_candidate_with_priority(&queue, name, "entity", "EntityDescriptions", PRIORITY_HOT);
117        n += 1;
118    }
119    Ok(n)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::events::{
125        enrich_operation_cli_name, is_sqlite_interrupt, scan_operation_with_deadline,
126    };
127    use super::schemas::{BINDINGS_SCHEMA, BODY_ENRICH_SCHEMA, ENTITY_DESCRIPTION_SCHEMA};
128    use super::*;
129    use crate::errors::AppError;
130    use rusqlite::{Connection, ErrorCode};
131    use std::time::{Duration, Instant};
132
133    /// Every response schema must be valid JSON and obey OpenAI strict mode.
134    ///
135    /// GAP-SG-279 widened this from three hand-picked constants to all twelve.
136    /// The three that were covered were the three someone happened to touch;
137    /// `ENTITY_TYPE_VALIDATE_SCHEMA` — the one this gap edits — was not among
138    /// them, so a malformed edit to it would have reached the provider and come
139    /// back as a runtime rejection per item rather than a red test.
140    ///
141    /// The strict-mode rules are the transport's, not this crate's:
142    /// `chat_api/client.rs` sends every schema under `strict: true`, and that
143    /// mode requires EVERY key in `properties` to also appear in `required`,
144    /// plus `additionalProperties: false`. A schema that marks a field optional
145    /// is not leniently handled — the request is refused. Checking it here
146    /// turns a per-item provider error into a compile-time-adjacent failure.
147    #[test]
148    fn every_response_schema_is_valid_json_and_strict_mode_clean() {
149        let schemas: [(&str, &str); 12] = [
150            ("BINDINGS_SCHEMA", BINDINGS_SCHEMA),
151            ("BODY_ENRICH_SCHEMA", BODY_ENRICH_SCHEMA),
152            ("BODY_EXTRACT_SCHEMA", super::schemas::BODY_EXTRACT_SCHEMA),
153            (
154                "DEEP_RESEARCH_SYNTH_SCHEMA",
155                super::schemas::DEEP_RESEARCH_SYNTH_SCHEMA,
156            ),
157            (
158                "DESCRIPTION_ENRICH_SCHEMA",
159                super::schemas::DESCRIPTION_ENRICH_SCHEMA,
160            ),
161            (
162                "DOMAIN_CLASSIFY_SCHEMA",
163                super::schemas::DOMAIN_CLASSIFY_SCHEMA,
164            ),
165            (
166                "ENTITY_CONNECT_SCHEMA",
167                super::schemas::ENTITY_CONNECT_SCHEMA,
168            ),
169            ("ENTITY_DESCRIPTION_SCHEMA", ENTITY_DESCRIPTION_SCHEMA),
170            (
171                "ENTITY_TYPE_VALIDATE_SCHEMA",
172                super::schemas::ENTITY_TYPE_VALIDATE_SCHEMA,
173            ),
174            ("GRAPH_AUDIT_SCHEMA", super::schemas::GRAPH_AUDIT_SCHEMA),
175            (
176                "RELATION_RECLASSIFY_SCHEMA",
177                super::schemas::RELATION_RECLASSIFY_SCHEMA,
178            ),
179            (
180                "WEIGHT_CALIBRATE_SCHEMA",
181                super::schemas::WEIGHT_CALIBRATE_SCHEMA,
182            ),
183        ];
184
185        for (name, text) in schemas {
186            let parsed: serde_json::Value = serde_json::from_str(text)
187                .unwrap_or_else(|e| panic!("{name} must be valid JSON: {e}"));
188
189            assert_eq!(
190                parsed.get("additionalProperties"),
191                Some(&serde_json::Value::Bool(false)),
192                "{name} must set additionalProperties to false; strict mode refuses anything else"
193            );
194
195            let properties = parsed
196                .get("properties")
197                .and_then(|p| p.as_object())
198                .unwrap_or_else(|| panic!("{name} must declare an object of properties"));
199            let required: Vec<&str> = parsed
200                .get("required")
201                .and_then(|r| r.as_array())
202                .unwrap_or_else(|| panic!("{name} must declare a required array"))
203                .iter()
204                .filter_map(|v| v.as_str())
205                .collect();
206
207            let missing: Vec<&String> = properties
208                .keys()
209                .filter(|k| !required.contains(&k.as_str()))
210                .collect();
211            assert!(
212                missing.is_empty(),
213                "{name} declares propertie(s) absent from `required`: {missing:?}. \
214                 Under strict mode that is a REFUSED request, not an optional field; \
215                 model an optional value as a nullable type inside `required` instead."
216            );
217        }
218    }
219
220    // v1.1.06 — GAP-ENTITY-CONNECT-SCAN-CARTESIAN observability + interrupt
221
222    #[test]
223    fn enrich_operation_cli_name_pair_ops_are_kebab_case() {
224        assert_eq!(
225            enrich_operation_cli_name(&EnrichOperation::EntityConnect),
226            "entity-connect"
227        );
228        assert_eq!(
229            enrich_operation_cli_name(&EnrichOperation::CrossDomainBridges),
230            "cross-domain-bridges"
231        );
232        assert_eq!(
233            enrich_operation_cli_name(&EnrichOperation::EntityDescriptions),
234            "entity-descriptions"
235        );
236    }
237
238    #[test]
239    fn is_sqlite_interrupt_detects_operation_interrupted() {
240        let ffi_err = rusqlite::ffi::Error {
241            code: ErrorCode::OperationInterrupted,
242            extended_code: 9,
243        };
244        let err = rusqlite::Error::SqliteFailure(ffi_err, Some("interrupted".into()));
245        assert!(is_sqlite_interrupt(&err));
246
247        let busy = rusqlite::ffi::Error {
248            code: ErrorCode::DatabaseBusy,
249            extended_code: 5,
250        };
251        let busy_err = rusqlite::Error::SqliteFailure(busy, None);
252        assert!(!is_sqlite_interrupt(&busy_err));
253    }
254
255    #[test]
256    fn scan_deadline_already_elapsed_returns_timeout() {
257        // Past deadline must fail fast without running SQL (exit path → Timeout).
258        use clap::Parser;
259        let cli = crate::cli::Cli::try_parse_from([
260            "sqlite-graphrag",
261            "enrich",
262            "--operation",
263            "entity-connect",
264            "--mode",
265            "openrouter",
266            "--openrouter-model",
267            "test/model",
268            "--dry-run",
269            "--limit",
270            "1",
271        ])
272        .expect("parse enrich args");
273        let Some(crate::cli::Commands::Enrich(args)) = cli.command else {
274            panic!("expected Commands::Enrich");
275        };
276        let conn = Connection::open_in_memory().unwrap();
277        let past = Instant::now() - Duration::from_secs(1);
278        let err = scan_operation_with_deadline(&conn, "global", &args, Some(past))
279            .expect_err("elapsed deadline must Timeout");
280        match err {
281            AppError::Timeout { .. } => {}
282            other => panic!("expected Timeout, got {other:?}"),
283        }
284    }
285
286    #[test]
287    fn interrupt_handle_maps_long_query_to_sqlite_interrupt() {
288        // Live SQLite: watchdog interrupt aborts a recursive CTE (same mechanism
289        // as scan_operation_with_deadline). Confirms rusqlite InterruptHandle.
290        let conn = Connection::open_in_memory().unwrap();
291        let handle = conn.get_interrupt_handle();
292        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
293        let stop_w = std::sync::Arc::clone(&stop);
294        let watchdog = std::thread::spawn(move || {
295            std::thread::sleep(Duration::from_millis(30));
296            if !stop_w.load(std::sync::atomic::Ordering::Relaxed) {
297                handle.interrupt();
298            }
299        });
300        let result = conn.query_row(
301            "WITH RECURSIVE t(x) AS (
302                 SELECT 1
303                 UNION ALL
304                 SELECT x + 1 FROM t WHERE x < 500000000
305             )
306             SELECT COUNT(*) FROM t",
307            [],
308            |r| r.get::<_, i64>(0),
309        );
310        stop.store(true, std::sync::atomic::Ordering::Relaxed);
311        let _ = watchdog.join();
312        let err = result.expect_err("recursive CTE must be interrupted");
313        assert!(
314            is_sqlite_interrupt(&err),
315            "expected SQLITE_INTERRUPT, got {err:?}"
316        );
317    }
318}