1use std::path::Path;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8use skm_core::{SkillName, SkillRegistry};
9
10use crate::embedding::Embedding;
11use crate::error::EmbedError;
12use crate::multicomp::{ComponentScores, ComponentWeights, SkillEmbeddings};
13use crate::provider::EmbeddingProvider;
14
15#[derive(Debug, Clone)]
17pub struct ScoredSkill {
18 pub name: SkillName,
20
21 pub score: f32,
23
24 pub component_scores: ComponentScores,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct EmbeddingIndex {
34 entries: Vec<SkillEmbeddings>,
36
37 model_id: String,
39
40 built_at: SystemTime,
42}
43
44impl EmbeddingIndex {
45 pub async fn build(
49 registry: &SkillRegistry,
50 provider: &dyn EmbeddingProvider,
51 weights: ComponentWeights,
52 ) -> Result<Self, EmbedError> {
53 let catalog = registry.catalog().await;
54 let mut entries = Vec::with_capacity(catalog.len());
55
56 let mut texts: Vec<String> = Vec::new();
58 let mut skill_indices: Vec<(usize, SkillName)> = Vec::new();
59
60 for meta in &catalog {
61 let base_idx = texts.len();
62 skill_indices.push((base_idx, meta.name.clone()));
63
64 texts.push(meta.description.clone());
66
67 let triggers_text = if meta.triggers.is_empty() {
69 meta.description.clone() } else {
71 meta.triggers.join(", ")
72 };
73 texts.push(triggers_text);
74
75 let tags_text = if meta.tags.is_empty() {
77 meta.description.clone() } else {
79 meta.tags.join(", ")
80 };
81 texts.push(tags_text);
82
83 texts.push(meta.description.clone());
85 }
86
87 let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
89
90 let max_batch = provider.max_batch_size();
92 let mut all_embeddings = Vec::with_capacity(texts.len());
93
94 for chunk in text_refs.chunks(max_batch) {
95 let batch_embeddings = provider.embed(chunk).await?;
96 all_embeddings.extend(batch_embeddings);
97 }
98
99 for (base_idx, skill_name) in skill_indices {
101 let skill_embedding = SkillEmbeddings::new(
102 skill_name,
103 all_embeddings[base_idx].clone(), all_embeddings[base_idx + 1].clone(), all_embeddings[base_idx + 2].clone(), all_embeddings[base_idx + 3].clone(), weights.clone(),
108 );
109 entries.push(skill_embedding);
110 }
111
112 Ok(Self {
113 entries,
114 model_id: provider.model_id().to_string(),
115 built_at: SystemTime::now(),
116 })
117 }
118
119 pub fn load_cached(path: &Path, registry: &SkillRegistry) -> Result<Option<Self>, EmbedError> {
121 if !path.exists() {
122 return Ok(None);
123 }
124
125 let data = std::fs::read(path).map_err(EmbedError::Io)?;
126 let index: Self = bincode::deserialize(&data)
127 .map_err(|e| EmbedError::Serialization(e.to_string()))?;
128
129 let _ = registry; Ok(Some(index))
134 }
135
136 pub fn save(&self, path: &Path) -> Result<(), EmbedError> {
138 let data = bincode::serialize(self)
139 .map_err(|e| EmbedError::Serialization(e.to_string()))?;
140
141 if let Some(parent) = path.parent() {
143 std::fs::create_dir_all(parent)?;
144 }
145
146 std::fs::write(path, data)?;
147 Ok(())
148 }
149
150 pub fn query(&self, query_embedding: &Embedding, top_k: usize) -> Vec<ScoredSkill> {
152 let mut scored: Vec<ScoredSkill> = self
153 .entries
154 .iter()
155 .map(|e| ScoredSkill {
156 name: e.skill_name.clone(),
157 score: e.score(query_embedding),
158 component_scores: e.component_scores(query_embedding),
159 })
160 .collect();
161
162 scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
164
165 scored.truncate(top_k);
167 scored
168 }
169
170 pub fn query_adaptive(
173 &self,
174 query_embedding: &Embedding,
175 min_score: f32,
176 max_k: usize,
177 gap_threshold: f32,
178 ) -> Vec<ScoredSkill> {
179 let mut scored: Vec<ScoredSkill> = self
180 .entries
181 .iter()
182 .map(|e| ScoredSkill {
183 name: e.skill_name.clone(),
184 score: e.score(query_embedding),
185 component_scores: e.component_scores(query_embedding),
186 })
187 .filter(|s| s.score >= min_score)
188 .collect();
189
190 scored.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
192
193 let mut cutoff = scored.len().min(max_k);
195
196 for i in 1..cutoff {
197 let gap = scored[i - 1].score - scored[i].score;
198 if gap >= gap_threshold {
199 cutoff = i;
200 break;
201 }
202 }
203
204 scored.truncate(cutoff);
205 scored
206 }
207
208 pub fn model_id(&self) -> &str {
210 &self.model_id
211 }
212
213 pub fn built_at(&self) -> SystemTime {
215 self.built_at
216 }
217
218 pub fn len(&self) -> usize {
220 self.entries.len()
221 }
222
223 pub fn is_empty(&self) -> bool {
225 self.entries.is_empty()
226 }
227
228 pub fn skill_names(&self) -> Vec<&SkillName> {
230 self.entries.iter().map(|e| &e.skill_name).collect()
231 }
232
233 pub fn get(&self, name: &SkillName) -> Option<&SkillEmbeddings> {
235 self.entries.iter().find(|e| &e.skill_name == name)
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::provider::tests::MockProvider;
243 use std::collections::HashMap;
244 use std::path::PathBuf;
245 use tempfile::TempDir;
246
247 async fn setup_test_registry() -> (TempDir, SkillRegistry) {
248 let temp = TempDir::new().unwrap();
249
250 let skill1 = r#"---
252name: pdf-processing
253description: Extract text and tables from PDF files
254metadata:
255 triggers: "pdf, extract text"
256 tags: "document, extraction"
257---
258
259Instructions for PDF processing.
260"#;
261
262 let skill2 = r#"---
263name: weather-lookup
264description: Get current weather and forecasts
265metadata:
266 triggers: "weather, forecast, temperature"
267 tags: "weather, api"
268---
269
270Instructions for weather lookup.
271"#;
272
273 let skill1_dir = temp.path().join("pdf-processing");
275 std::fs::create_dir_all(&skill1_dir).unwrap();
276 std::fs::write(skill1_dir.join("SKILL.md"), skill1).unwrap();
277
278 let skill2_dir = temp.path().join("weather-lookup");
279 std::fs::create_dir_all(&skill2_dir).unwrap();
280 std::fs::write(skill2_dir.join("SKILL.md"), skill2).unwrap();
281
282 let registry = SkillRegistry::new(&[temp.path()]).await.unwrap();
283 (temp, registry)
284 }
285
286 #[tokio::test]
287 async fn test_index_build() {
288 let (_temp, registry) = setup_test_registry().await;
289 let provider = MockProvider::new(384);
290
291 let index = EmbeddingIndex::build(®istry, &provider, ComponentWeights::default())
292 .await
293 .unwrap();
294
295 assert_eq!(index.len(), 2);
296 assert_eq!(index.model_id(), "mock-embedding-model");
297 }
298
299 #[tokio::test]
300 async fn test_index_query() {
301 let (_temp, registry) = setup_test_registry().await;
302 let provider = MockProvider::new(384);
303
304 let index = EmbeddingIndex::build(®istry, &provider, ComponentWeights::default())
305 .await
306 .unwrap();
307
308 let query = provider.embed_one("extract text from pdf").await.unwrap();
309 let results = index.query(&query, 5);
310
311 assert!(!results.is_empty());
312 assert!(results[0].score >= -1.0 && results[0].score <= 1.0);
313 }
314
315 #[tokio::test]
316 async fn test_index_query_adaptive() {
317 let (_temp, registry) = setup_test_registry().await;
318 let provider = MockProvider::new(384);
319
320 let index = EmbeddingIndex::build(®istry, &provider, ComponentWeights::default())
321 .await
322 .unwrap();
323
324 let query = provider.embed_one("pdf").await.unwrap();
325 let results = index.query_adaptive(&query, 0.0, 10, 0.1);
326
327 assert!(!results.is_empty());
329 }
330
331 #[tokio::test]
332 async fn test_index_save_load() {
333 let (_temp, registry) = setup_test_registry().await;
334 let provider = MockProvider::new(384);
335
336 let index = EmbeddingIndex::build(®istry, &provider, ComponentWeights::default())
337 .await
338 .unwrap();
339
340 let cache_dir = TempDir::new().unwrap();
341 let cache_path = cache_dir.path().join("index.bin");
342
343 index.save(&cache_path).unwrap();
345 assert!(cache_path.exists());
346
347 let loaded = EmbeddingIndex::load_cached(&cache_path, ®istry)
349 .unwrap()
350 .unwrap();
351
352 assert_eq!(loaded.len(), index.len());
353 assert_eq!(loaded.model_id(), index.model_id());
354 }
355
356 #[tokio::test]
357 async fn test_index_get() {
358 let (_temp, registry) = setup_test_registry().await;
359 let provider = MockProvider::new(384);
360
361 let index = EmbeddingIndex::build(®istry, &provider, ComponentWeights::default())
362 .await
363 .unwrap();
364
365 let name = SkillName::new("pdf-processing").unwrap();
366 let embeddings = index.get(&name).unwrap();
367
368 assert_eq!(embeddings.skill_name, name);
369 }
370}