1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct LlmExtractorConfig {
17 pub backend: String,
19 pub model: Option<String>,
21 pub timeout_secs: Option<u64>,
23}
24
25impl Default for LlmExtractorConfig {
26 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
45pub struct LlmBackend {
47 config: LlmExtractorConfig,
48}
49
50impl LlmBackend {
51 pub fn new(config: LlmExtractorConfig) -> Self {
53 Self { config }
54 }
55
56 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
209pub enum LlmBackendKindFactory {
210 Auto,
212 Codex,
214 Claude,
216 Opencode,
218 None,
220}
221
222pub trait LlmBackendFactory: Send + Sync {
235 fn build_extraction_backend(
238 &self,
239 config: &LlmExtractorConfig,
240 ) -> Result<Box<dyn ExtractionBackend>, AppError>;
241
242 fn build_embedder(
244 &self,
245 config: &LlmExtractorConfig,
246 ) -> Result<Box<dyn std::any::Any + Send + Sync>, AppError>;
247
248 fn kind(&self) -> LlmBackendKindFactory;
250}
251
252pub 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 Ok(Box::new(()))
274 }
275 fn kind(&self) -> LlmBackendKindFactory {
276 LlmBackendKindFactory::Codex
277 }
278}
279
280pub 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
303pub 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
351pub 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
374pub 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
414pub fn detect_available_backend() -> Result<LlmBackendKindFactory, AppError> {
422 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 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 Ok(LlmBackendKindFactory::None)
447 }
448}
449
450pub 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 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 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}