sqlite_graphrag/extract/
llm_backend.rs1use 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 {
27 Self {
28 backend: "llm".to_string(),
29 model: None,
30 timeout_secs: Some(300),
31 }
32 }
33}
34
35pub struct LlmBackend {
37 config: LlmExtractorConfig,
38}
39
40impl LlmBackend {
41 pub fn new(config: LlmExtractorConfig) -> Self {
43 Self { config }
44 }
45}
46
47#[async_trait]
48impl ExtractionBackend for LlmBackend {
49 fn kind(&self) -> BackendKind {
50 BackendKind::Llm
51 }
52
53 fn model_name(&self) -> String {
54 format!("{}-headless", self.config.backend)
55 }
56
57 async fn extract(
58 &self,
59 content: &str,
60 hints: &ExtractionHints,
61 ) -> Result<ExtractionOutput, AppError> {
62 let start = std::time::Instant::now();
63 let trimmed = content.trim();
64 if trimmed.is_empty() {
65 return Ok(ExtractionOutput {
66 backend: self.kind().as_str().to_string(),
67 elapsed_ms: start.elapsed().as_millis() as u64,
68 ..Default::default()
69 });
70 }
71 if !hints.skip_relations && !trimmed.contains(' ') {
72 return Ok(ExtractionOutput {
73 backend: self.kind().as_str().to_string(),
74 elapsed_ms: start.elapsed().as_millis() as u64,
75 ..Default::default()
76 });
77 }
78
79 let word_count = trimmed.split_whitespace().count();
80 if !hints.skip_relations && word_count < 5 {
81 return Ok(ExtractionOutput {
82 backend: self.kind().as_str().to_string(),
83 elapsed_ms: start.elapsed().as_millis() as u64,
84 ..Default::default()
85 });
86 }
87
88 let mut entities: Vec<ExtractedEntity> = Vec::new();
89 let mut relationships: Vec<ExtractedRelationship> = Vec::new();
90
91 for raw in trimmed.split(|c: char| !c.is_alphanumeric()) {
92 let word = raw.trim();
93 if word.is_empty() {
94 continue;
95 }
96 if word.len() < 3 {
97 continue;
98 }
99 let lower = word.to_ascii_lowercase();
100 if matches!(
101 lower.as_str(),
102 "the"
103 | "and"
104 | "for"
105 | "with"
106 | "from"
107 | "this"
108 | "that"
109 | "into"
110 | "sobre"
111 | "para"
112 | "como"
113 ) {
114 continue;
115 }
116 let name = lower.replace(|c: char| !c.is_alphanumeric() && c != '-', "-");
117 if name.is_empty() || name == "-" {
118 continue;
119 }
120 if !entities.iter().any(|e| e.name == name) {
121 entities.push(ExtractedEntity {
122 name,
123 entity_type: "concept".to_string(),
124 description: None,
125 confidence: Some(0.5),
126 });
127 }
128 }
129
130 if entities.len() > 1 && !hints.skip_relations {
131 for (i, source) in entities
132 .iter()
133 .enumerate()
134 .take(entities.len().saturating_sub(1))
135 {
136 for target in entities.iter().skip(i + 1) {
137 relationships.push(ExtractedRelationship {
138 source: source.name.clone(),
139 target: target.name.clone(),
140 relation: "related".to_string(),
141 strength: 0.4,
142 });
143 }
144 }
145 }
146
147 Ok(ExtractionOutput {
148 entities,
149 relationships,
150 embedding: None,
151 backend: self.kind().as_str().to_string(),
152 elapsed_ms: start.elapsed().as_millis() as u64,
153 })
154 }
155
156 async fn health(&self) -> Result<BackendHealth, AppError> {
157 Ok(BackendHealth {
158 kind: self.kind(),
159 healthy: true,
160 model_name: self.model_name(),
161 message: format!("LLM backend ({}) ready", self.config.backend),
162 })
163 }
164}