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}