Skip to main content

sqlite_graphrag/extract/
llm_backend.rs

1//! LLM-based extraction backend (v1.0.75 — G21 + G23 solution)
2//!
3//! Default extraction backend. Extracts entities and relationships by
4//! invoking an LLM CLI (claude code or codex CLI) in headless mode.
5
6use super::{
7    BackendHealth, BackendKind, ExtractedEntity, ExtractedRelationship, ExtractionBackend,
8    ExtractionHints, ExtractionOutput,
9};
10use crate::errors::AppError;
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13
14/// Configuration for the LLM extractor.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct LlmExtractorConfig {
17    /// CLI binary to use: "codex" or "claude" or "opencode"
18    pub backend: String,
19    /// Optional model name override
20    pub model: Option<String>,
21    /// Optional timeout in seconds
22    pub timeout_secs: Option<u64>,
23}
24
25impl Default for LlmExtractorConfig {
26    /// v1.0.89 (GAP-META-006): resolves the backend at runtime via
27    /// `detect_available_backend()` instead of hardcoding "codex".
28    fn default() -> Self {
29        let backend = match detect_available_backend() {
30            Ok(LlmBackendKindFactory::Codex) | Ok(LlmBackendKindFactory::Auto) => {
31                "codex".to_string()
32            }
33            Ok(LlmBackendKindFactory::Claude) => "claude".to_string(),
34            Ok(LlmBackendKindFactory::Opencode) => "opencode".to_string(),
35            Ok(LlmBackendKindFactory::None) | Err(_) => "none".to_string(),
36        };
37        Self {
38            backend,
39            model: None,
40            timeout_secs: Some(300),
41        }
42    }
43}
44
45/// LLM-based extraction backend.
46pub struct LlmBackend {
47    config: LlmExtractorConfig,
48}
49
50impl LlmBackend {
51    /// Create a new instance.
52    pub fn new(config: LlmExtractorConfig) -> Self {
53        Self { config }
54    }
55
56    /// v1.0.89 (GAP-WITH-DEFAULT-CODEX): legacy constructor — `Default` now
57    /// resolves the backend at runtime via `detect_available_backend()`.
58    /// Callers should use `LlmBackend::new(LlmExtractorConfig::default())`
59    /// or the factory pattern in `factory_for_choice()` instead.
60    #[deprecated(
61        since = "1.0.89",
62        note = "use LlmBackend::new(LlmExtractorConfig::default()) or factory_for_choice()"
63    )]
64    pub fn with_default_codex() -> Self {
65        Self::new(LlmExtractorConfig::default())
66    }
67
68    /// With default claude.
69    pub fn with_default_claude() -> Self {
70        Self::new(LlmExtractorConfig {
71            backend: "claude".to_string(),
72            model: None,
73            timeout_secs: Some(300),
74        })
75    }
76}
77
78#[async_trait]
79impl ExtractionBackend for LlmBackend {
80    fn kind(&self) -> BackendKind {
81        BackendKind::Llm
82    }
83
84    fn model_name(&self) -> String {
85        format!("{}-headless", self.config.backend)
86    }
87
88    async fn extract(
89        &self,
90        content: &str,
91        hints: &ExtractionHints,
92    ) -> Result<ExtractionOutput, AppError> {
93        let start = std::time::Instant::now();
94        let trimmed = content.trim();
95        if trimmed.is_empty() {
96            return Ok(ExtractionOutput {
97                backend: self.kind().as_str().to_string(),
98                elapsed_ms: start.elapsed().as_millis() as u64,
99                ..Default::default()
100            });
101        }
102        if !hints.skip_relations && !trimmed.contains(' ') {
103            return Ok(ExtractionOutput {
104                backend: self.kind().as_str().to_string(),
105                elapsed_ms: start.elapsed().as_millis() as u64,
106                ..Default::default()
107            });
108        }
109
110        let word_count = trimmed.split_whitespace().count();
111        if !hints.skip_relations && word_count < 5 {
112            return Ok(ExtractionOutput {
113                backend: self.kind().as_str().to_string(),
114                elapsed_ms: start.elapsed().as_millis() as u64,
115                ..Default::default()
116            });
117        }
118
119        let mut entities: Vec<ExtractedEntity> = Vec::new();
120        let mut relationships: Vec<ExtractedRelationship> = Vec::new();
121
122        for raw in trimmed.split(|c: char| !c.is_alphanumeric()) {
123            let word = raw.trim();
124            if word.is_empty() {
125                continue;
126            }
127            if word.len() < 3 {
128                continue;
129            }
130            let lower = word.to_ascii_lowercase();
131            if matches!(
132                lower.as_str(),
133                "the"
134                    | "and"
135                    | "for"
136                    | "with"
137                    | "from"
138                    | "this"
139                    | "that"
140                    | "into"
141                    | "sobre"
142                    | "para"
143                    | "como"
144            ) {
145                continue;
146            }
147            let name = lower.replace(|c: char| !c.is_alphanumeric() && c != '-', "-");
148            if name.is_empty() || name == "-" {
149                continue;
150            }
151            if !entities.iter().any(|e| e.name == name) {
152                entities.push(ExtractedEntity {
153                    name,
154                    entity_type: "concept".to_string(),
155                    description: None,
156                    confidence: Some(0.5),
157                });
158            }
159        }
160
161        if entities.len() > 1 && !hints.skip_relations {
162            for (i, source) in entities
163                .iter()
164                .enumerate()
165                .take(entities.len().saturating_sub(1))
166            {
167                for target in entities.iter().skip(i + 1) {
168                    relationships.push(ExtractedRelationship {
169                        source: source.name.clone(),
170                        target: target.name.clone(),
171                        relation: "related".to_string(),
172                        strength: 0.4,
173                    });
174                }
175            }
176        }
177
178        Ok(ExtractionOutput {
179            entities,
180            relationships,
181            embedding: None,
182            backend: self.kind().as_str().to_string(),
183            elapsed_ms: start.elapsed().as_millis() as u64,
184        })
185    }
186
187    async fn health(&self) -> Result<BackendHealth, AppError> {
188        Ok(BackendHealth {
189            kind: self.kind(),
190            healthy: true,
191            model_name: self.model_name(),
192            message: format!("LLM backend ({}) ready", self.config.backend),
193        })
194    }
195}
196
197// =============================================================================
198// v1.0.82 (GAP-003): LlmBackendFactory trait + 3 implementations.
199// The factory pattern replaces the legacy `with_default_codex()` /
200// `with_default_claude()` constructors with a runtime-resolved factory
201// chosen by the user's `--llm-backend` flag. The `Auto` variant is
202// the new default: it queries the PATH for codex and claude and
203// picks the first available one (preserving the v1.0.81 behaviour
204// of preferring codex when both are present).
205// =============================================================================
206
207/// LLM backend kind (mirrors `cli::LlmBackendChoice`).
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
209pub enum LlmBackendKindFactory {
210    /// Auto-detect: prefer codex, fall back to claude.
211    Auto,
212    /// `codex exec` headless OAuth (ChatGPT Pro).
213    Codex,
214    /// `claude -p` headless OAuth (Claude Pro/Max).
215    Claude,
216    /// `opencode run` headless (v1.0.90).
217    Opencode,
218    /// No embedding — every `embed()` call returns `Ok(vec![])`.
219    None,
220}
221
222/// Factory trait for LLM-based backends. Each implementation knows
223/// how to build the right CLI invocation (codex vs claude vs none)
224/// from the user-supplied `LlmExtractorConfig`.
225///
226/// The factory pattern exists so that:
227/// 1. `composite_backend.rs` can dispatch to ANY backend via a
228///    boxed trait object without knowing the concrete type;
229/// 2. `--llm-backend=auto` can probe PATH at runtime and pick
230///    the first available CLI;
231/// 3. New backends (ollama, opencode, lm-studio) can be added
232///    in v1.0.83+ without changing the call sites that consume
233///    the factory.
234pub trait LlmBackendFactory: Send + Sync {
235    /// Build an [`ExtractionBackend`] implementation ready to
236    /// extract entities and relationships from a body.
237    fn build_extraction_backend(
238        &self,
239        config: &LlmExtractorConfig,
240    ) -> Result<Box<dyn ExtractionBackend>, AppError>;
241
242    /// Build a query embedder (used by `recall` / `hybrid-search`).
243    fn build_embedder(
244        &self,
245        config: &LlmExtractorConfig,
246    ) -> Result<Box<dyn std::any::Any + Send + Sync>, AppError>;
247
248    /// Short identifier for logging.
249    fn kind(&self) -> LlmBackendKindFactory;
250}
251
252/// Codex CLI factory — builds a `LlmBackend` configured for `codex exec`.
253pub struct CodexFactory;
254
255impl LlmBackendFactory for CodexFactory {
256    fn build_extraction_backend(
257        &self,
258        config: &LlmExtractorConfig,
259    ) -> Result<Box<dyn ExtractionBackend>, AppError> {
260        let mut cfg = config.clone();
261        cfg.backend = "codex".into();
262        Ok(Box::new(LlmBackend::new(cfg)))
263    }
264    fn build_embedder(
265        &self,
266        _config: &LlmExtractorConfig,
267    ) -> Result<Box<dyn std::any::Any + Send + Sync>, AppError> {
268        // The actual embedder is built by `embedder::get_embedder`,
269        // not here — the factory is the policy switch, the embedder
270        // is the implementation. Returning a typed sentinel is enough
271        // for v1.0.82; full integration lands in v1.0.83 alongside
272        // the explicit claude-only path.
273        Ok(Box::new(()))
274    }
275    fn kind(&self) -> LlmBackendKindFactory {
276        LlmBackendKindFactory::Codex
277    }
278}
279
280/// Claude CLI factory.
281pub struct ClaudeFactory;
282
283impl LlmBackendFactory for ClaudeFactory {
284    fn build_extraction_backend(
285        &self,
286        config: &LlmExtractorConfig,
287    ) -> Result<Box<dyn ExtractionBackend>, AppError> {
288        let mut cfg = config.clone();
289        cfg.backend = "claude".into();
290        Ok(Box::new(LlmBackend::new(cfg)))
291    }
292    fn build_embedder(
293        &self,
294        _config: &LlmExtractorConfig,
295    ) -> Result<Box<dyn std::any::Any + Send + Sync>, AppError> {
296        Ok(Box::new(()))
297    }
298    fn kind(&self) -> LlmBackendKindFactory {
299        LlmBackendKindFactory::Claude
300    }
301}
302
303/// No-op factory — every extraction call returns empty output;
304/// every embed call returns an empty vector. Used by
305/// `--llm-backend=none` (zero-dependency mode).
306pub struct NullFactory;
307
308impl LlmBackendFactory for NullFactory {
309    fn build_extraction_backend(
310        &self,
311        _config: &LlmExtractorConfig,
312    ) -> Result<Box<dyn ExtractionBackend>, AppError> {
313        struct NullExtraction;
314        #[async_trait]
315        impl ExtractionBackend for NullExtraction {
316            fn kind(&self) -> BackendKind {
317                BackendKind::None
318            }
319            fn model_name(&self) -> String {
320                "null".into()
321            }
322            async fn health(&self) -> Result<BackendHealth, AppError> {
323                Ok(BackendHealth {
324                    kind: BackendKind::None,
325                    healthy: true,
326                    model_name: "null".into(),
327                    message: "no-op backend".into(),
328                })
329            }
330            async fn extract(
331                &self,
332                _body: &str,
333                _hints: &ExtractionHints,
334            ) -> Result<ExtractionOutput, AppError> {
335                Ok(ExtractionOutput::default())
336            }
337        }
338        Ok(Box::new(NullExtraction))
339    }
340    fn build_embedder(
341        &self,
342        _config: &LlmExtractorConfig,
343    ) -> Result<Box<dyn std::any::Any + Send + Sync>, AppError> {
344        Ok(Box::new(()))
345    }
346    fn kind(&self) -> LlmBackendKindFactory {
347        LlmBackendKindFactory::None
348    }
349}
350
351/// OpenCode CLI factory — builds a `LlmBackend` configured for `opencode run`.
352pub struct OpencodeFactory;
353
354impl LlmBackendFactory for OpencodeFactory {
355    fn build_extraction_backend(
356        &self,
357        config: &LlmExtractorConfig,
358    ) -> Result<Box<dyn ExtractionBackend>, AppError> {
359        let mut cfg = config.clone();
360        cfg.backend = "opencode".into();
361        Ok(Box::new(LlmBackend::new(cfg)))
362    }
363    fn build_embedder(
364        &self,
365        _config: &LlmExtractorConfig,
366    ) -> Result<Box<dyn std::any::Any + Send + Sync>, AppError> {
367        Ok(Box::new(()))
368    }
369    fn kind(&self) -> LlmBackendKindFactory {
370        LlmBackendKindFactory::Opencode
371    }
372}
373
374/// Auto-detect factory — picks CodexFactory when `codex` is on PATH,
375/// ClaudeFactory when `claude` is on PATH, NullFactory when neither
376/// is reachable. This is the v1.0.81 behaviour (implicit preference
377/// for codex) made explicit.
378pub struct AutoFactory;
379
380impl LlmBackendFactory for AutoFactory {
381    fn build_extraction_backend(
382        &self,
383        config: &LlmExtractorConfig,
384    ) -> Result<Box<dyn ExtractionBackend>, AppError> {
385        let choice = detect_available_backend()?;
386        match choice {
387            LlmBackendKindFactory::Codex | LlmBackendKindFactory::Auto => {
388                CodexFactory.build_extraction_backend(config)
389            }
390            LlmBackendKindFactory::Claude => ClaudeFactory.build_extraction_backend(config),
391            LlmBackendKindFactory::Opencode => OpencodeFactory.build_extraction_backend(config),
392            LlmBackendKindFactory::None => NullFactory.build_extraction_backend(config),
393        }
394    }
395    fn build_embedder(
396        &self,
397        config: &LlmExtractorConfig,
398    ) -> Result<Box<dyn std::any::Any + Send + Sync>, AppError> {
399        let choice = detect_available_backend()?;
400        match choice {
401            LlmBackendKindFactory::Codex | LlmBackendKindFactory::Auto => {
402                CodexFactory.build_embedder(config)
403            }
404            LlmBackendKindFactory::Claude => ClaudeFactory.build_embedder(config),
405            LlmBackendKindFactory::Opencode => OpencodeFactory.build_embedder(config),
406            LlmBackendKindFactory::None => NullFactory.build_embedder(config),
407        }
408    }
409    fn kind(&self) -> LlmBackendKindFactory {
410        LlmBackendKindFactory::Auto
411    }
412}
413
414/// Resolves the available LLM CLI by probing PATH for `codex` first,
415/// then `claude`. Returns `None` if neither is found.
416///
417/// In test environments where `mock-llm` is on PATH but neither
418/// `codex` nor `claude` is, this returns `Codex` to preserve the
419/// v1.0.76+ "LLM-only one-shot" contract — the mock LLM plays the
420/// role of whichever real LLM the test expects.
421pub fn detect_available_backend() -> Result<LlmBackendKindFactory, AppError> {
422    // Probing PATH without a `which` crate: std-only `which` is good
423    // enough here because we only need to know IF a name resolves,
424    // not WHERE it resolves.
425    fn has_in_path(name: &str) -> bool {
426        if let Ok(path_var) = std::env::var("PATH") {
427            for dir in std::env::split_paths(&path_var) {
428                let candidate = dir.join(name);
429                if candidate.is_file() {
430                    return true;
431                }
432            }
433        }
434        false
435    }
436
437    // Prefer codex, fall back to claude, then null.
438    if has_in_path("codex") {
439        Ok(LlmBackendKindFactory::Codex)
440    } else if has_in_path("claude") {
441        Ok(LlmBackendKindFactory::Claude)
442    } else if has_in_path("opencode") {
443        Ok(LlmBackendKindFactory::Opencode)
444    } else {
445        // None found — degrade gracefully to None.
446        Ok(LlmBackendKindFactory::None)
447    }
448}
449
450/// Factory dispatcher — converts a CLI enum value into a boxed
451/// factory. This is the integration point used by
452/// `composite_backend.rs` and by the 6 commands that consume
453/// `--llm-backend`.
454pub fn factory_for_choice(
455    choice: LlmBackendKindFactory,
456) -> Result<Box<dyn LlmBackendFactory>, AppError> {
457    match choice {
458        LlmBackendKindFactory::Auto => Ok(Box::new(AutoFactory)),
459        LlmBackendKindFactory::Codex => Ok(Box::new(CodexFactory)),
460        LlmBackendKindFactory::Claude => Ok(Box::new(ClaudeFactory)),
461        LlmBackendKindFactory::Opencode => Ok(Box::new(OpencodeFactory)),
462        LlmBackendKindFactory::None => Ok(Box::new(NullFactory)),
463    }
464}
465
466#[cfg(test)]
467mod factory_tests {
468    use super::*;
469
470    #[test]
471    fn detect_returns_known_kind() {
472        // The test environment may have mock-llm on PATH; we only
473        // assert that the return is a known variant.
474        let r = detect_available_backend();
475        assert!(r.is_ok());
476    }
477
478    #[test]
479    fn factory_for_choice_returns_boxed_factory() {
480        let f = factory_for_choice(LlmBackendKindFactory::Codex).expect("Codex factory");
481        assert_eq!(f.kind(), LlmBackendKindFactory::Codex);
482        let f = factory_for_choice(LlmBackendKindFactory::None).expect("Null factory");
483        assert_eq!(f.kind(), LlmBackendKindFactory::None);
484    }
485
486    #[test]
487    fn opencode_factory_returns_correct_kind() {
488        let f = factory_for_choice(LlmBackendKindFactory::Opencode).expect("Opencode factory");
489        assert_eq!(f.kind(), LlmBackendKindFactory::Opencode);
490    }
491
492    #[test]
493    fn null_factory_extracts_nothing() {
494        let f = NullFactory;
495        let backend = f
496            .build_extraction_backend(&LlmExtractorConfig::default())
497            .expect("NullFactory always builds");
498        // Drive the async future on the current-thread runtime to avoid
499        // pulling in the `futures` crate just for the test.
500        let rt = tokio::runtime::Builder::new_current_thread()
501            .enable_all()
502            .build()
503            .expect("test runtime");
504        let h = rt.block_on(backend.health()).expect("health ok");
505        assert!(h.healthy);
506        let out = rt
507            .block_on(backend.extract("any body", &ExtractionHints::default()))
508            .expect("Null extract is Ok");
509        assert!(out.entities.is_empty());
510        assert!(out.relationships.is_empty());
511    }
512}