rectilinear_core/embedding/
mod.rs1#[cfg(feature = "local-embeddings")]
2mod local;
3
4use anyhow::{Context, Result};
5use reqwest::StatusCode;
6use serde::Deserialize;
7use sha2::{Digest, Sha256};
8
9use crate::config::{Config, EmbeddingBackend};
10
11const GEMINI_EMBEDDING_MODEL: &str = "gemini-embedding-2-preview";
12
13enum Backend {
14 Gemini(GeminiBackend),
15 #[cfg(feature = "local-embeddings")]
16 Local(local::LocalBackend),
17}
18
19pub struct Embedder {
20 backend: Backend,
21 dimensions: usize,
22}
23
24pub fn issue_content_hash(title: &str, description: Option<&str>) -> String {
26 let mut hasher = Sha256::new();
27 hasher.update(title.as_bytes());
28 hasher.update([0]);
29 hasher.update(description.unwrap_or_default().as_bytes());
30 hex::encode(hasher.finalize())
31}
32
33struct GeminiBackend {
36 client: reqwest::Client,
37 api_key: String,
38}
39
40#[derive(Deserialize)]
41struct GeminiModelsResponse {
42 models: Vec<GeminiModel>,
43}
44
45#[derive(Deserialize)]
46struct GeminiModel {
47 #[allow(dead_code)]
48 name: String,
49}
50
51#[derive(Deserialize)]
52struct GeminiErrorEnvelope {
53 error: GeminiErrorBody,
54}
55
56#[derive(Deserialize)]
57struct GeminiErrorBody {
58 #[allow(dead_code)]
59 code: Option<u16>,
60 message: Option<String>,
61 status: Option<String>,
62}
63
64impl GeminiBackend {
65 fn new(api_key: &str) -> Self {
66 Self {
67 client: reqwest::Client::new(),
68 api_key: api_key.to_string(),
69 }
70 }
71
72 fn with_http_client(client: reqwest::Client, api_key: &str) -> Self {
73 Self {
74 client,
75 api_key: api_key.to_string(),
76 }
77 }
78
79 async fn test_api_key(&self) -> Result<()> {
80 let resp = self
81 .client
82 .get("https://generativelanguage.googleapis.com/v1beta/models?pageSize=1")
83 .header("x-goog-api-key", &self.api_key)
84 .send()
85 .await
86 .context("Failed to call Gemini models API")?;
87
88 let status = resp.status();
89 let body = resp
90 .text()
91 .await
92 .context("Failed to read Gemini models response")?;
93
94 if !status.is_success() {
95 anyhow::bail!("{}", summarize_gemini_error(status, &body));
96 }
97
98 let response: GeminiModelsResponse =
99 serde_json::from_str(&body).context("Failed to parse Gemini models response")?;
100 if response.models.is_empty() {
101 anyhow::bail!("Gemini returned no models");
102 }
103
104 Ok(())
105 }
106
107 async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
108 let url = format!(
109 "https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_EMBEDDING_MODEL}:batchEmbedContents?key={}",
110 self.api_key,
111 );
112
113 let mut all_embeddings = Vec::new();
114 for batch in texts.chunks(100) {
115 let requests: Vec<_> = batch
116 .iter()
117 .map(|text| {
118 serde_json::json!({
119 "model": format!("models/{GEMINI_EMBEDDING_MODEL}"),
120 "content": {
121 "parts": [{"text": text}]
122 },
123 "outputDimensionality": 768
124 })
125 })
126 .collect();
127
128 let body = serde_json::json!({ "requests": requests });
129
130 let resp = self
131 .client
132 .post(&url)
133 .json(&body)
134 .send()
135 .await
136 .context("Failed to call Gemini embedding API")?;
137
138 let status = resp.status();
139 if !status.is_success() {
140 let text = resp.text().await.unwrap_or_default();
141 anyhow::bail!("Gemini API returned {}: {}", status, text);
142 }
143
144 let data: serde_json::Value = resp.json().await?;
145 let embeddings = data["embeddings"]
146 .as_array()
147 .context("No embeddings in response")?;
148
149 for emb in embeddings {
150 let values: Vec<f32> = emb["values"]
151 .as_array()
152 .context("No values in embedding")?
153 .iter()
154 .map(|v| v.as_f64().unwrap_or(0.0) as f32)
155 .collect();
156 all_embeddings.push(values);
157 }
158 }
159
160 Ok(all_embeddings)
161 }
162}
163
164fn summarize_gemini_error(status: StatusCode, body: &str) -> String {
165 if let Ok(error) = serde_json::from_str::<GeminiErrorEnvelope>(body) {
166 return compact_gemini_error(
167 status,
168 error.error.message.as_deref(),
169 error.error.status.as_deref(),
170 );
171 }
172
173 compact_gemini_error(status, None, None)
174}
175
176fn compact_gemini_error(
177 status: StatusCode,
178 message: Option<&str>,
179 api_status: Option<&str>,
180) -> String {
181 let normalized = message.unwrap_or("").trim().to_lowercase();
182
183 if normalized.contains("api key not valid") || normalized.contains("invalid api key") {
184 return "Invalid API key".into();
185 }
186
187 if normalized.contains("reported as leaked") || normalized.contains("disabled") {
188 return "Key blocked".into();
189 }
190
191 if normalized.contains("billing")
192 || matches!(api_status, Some("FAILED_PRECONDITION" | "SERVICE_DISABLED"))
193 {
194 return "Setup required".into();
195 }
196
197 if status == StatusCode::UNAUTHORIZED || matches!(api_status, Some("UNAUTHENTICATED")) {
198 return "Unauthorized".into();
199 }
200
201 if status == StatusCode::FORBIDDEN || matches!(api_status, Some("PERMISSION_DENIED")) {
202 return "Access denied".into();
203 }
204
205 if status == StatusCode::TOO_MANY_REQUESTS || matches!(api_status, Some("RESOURCE_EXHAUSTED")) {
206 return "Rate limited".into();
207 }
208
209 if status.is_server_error() {
210 return "Gemini unavailable".into();
211 }
212
213 if let Some(message) = message {
214 let trimmed = message.trim();
215 if !trimmed.is_empty() && trimmed.len() <= 48 {
216 return trimmed.to_string();
217 }
218 }
219
220 status
221 .canonical_reason()
222 .unwrap_or("Request failed")
223 .to_string()
224}
225
226impl Embedder {
229 pub fn new(config: &Config) -> Result<Self> {
230 let gemini_key = std::env::var("GEMINI_API_KEY")
231 .ok()
232 .or_else(|| config.embedding.gemini_api_key.clone());
233
234 match config.embedding.backend {
235 EmbeddingBackend::Api => {
236 let key = gemini_key.context(
237 "Gemini API key required for API backend. Set GEMINI_API_KEY or configure in config.",
238 )?;
239 Self::new_api(&key)
240 }
241 #[cfg(feature = "local-embeddings")]
242 EmbeddingBackend::Local => {
243 let backend = local::LocalBackend::new(config)?;
244 let dimensions = backend.dimensions();
245 Ok(Self {
246 dimensions,
247 backend: Backend::Local(backend),
248 })
249 }
250 #[cfg(not(feature = "local-embeddings"))]
251 EmbeddingBackend::Local => {
252 anyhow::bail!(
253 "Local embeddings not available — compile with `local-embeddings` feature"
254 )
255 }
256 }
257 }
258
259 pub fn new_api(api_key: &str) -> Result<Self> {
261 Ok(Self {
262 dimensions: 768,
263 backend: Backend::Gemini(GeminiBackend::new(api_key)),
264 })
265 }
266
267 pub fn new_api_with_http_client(client: reqwest::Client, api_key: &str) -> Result<Self> {
269 Ok(Self {
270 dimensions: 768,
271 backend: Backend::Gemini(GeminiBackend::with_http_client(client, api_key)),
272 })
273 }
274
275 pub async fn test_api_key(&self) -> Result<()> {
276 match &self.backend {
277 Backend::Gemini(b) => b.test_api_key().await,
278 #[cfg(feature = "local-embeddings")]
279 Backend::Local(_) => anyhow::bail!("Gemini API key not in use"),
280 }
281 }
282
283 #[cfg(feature = "local-embeddings")]
285 pub fn new_local(_models_dir: &std::path::Path) -> Result<Self> {
286 let config = Config {
288 embedding: crate::config::EmbeddingConfig {
289 backend: EmbeddingBackend::Local,
290 gemini_api_key: None,
291 },
292 ..Config::default()
293 };
294 let backend = local::LocalBackend::new(&config)?;
295 let dimensions = backend.dimensions();
296 Ok(Self {
297 dimensions,
298 backend: Backend::Local(backend),
299 })
300 }
301
302 pub async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
303 match &self.backend {
304 Backend::Gemini(b) => b.embed_batch(texts).await,
305 #[cfg(feature = "local-embeddings")]
306 Backend::Local(b) => b.embed_batch(texts),
307 }
308 }
309
310 pub async fn embed_single(&self, text: &str) -> Result<Vec<f32>> {
311 let results: Vec<Vec<f32>> = self.embed_batch(&[text.to_string()]).await?;
312 results.into_iter().next().context("No embedding returned")
313 }
314
315 pub fn dimensions(&self) -> usize {
316 self.dimensions
317 }
318
319 pub fn backend_name(&self) -> &str {
320 match &self.backend {
321 Backend::Gemini(_) => GEMINI_EMBEDDING_MODEL,
322 #[cfg(feature = "local-embeddings")]
323 Backend::Local(_) => "embeddinggemma-300m-qat-q8_0",
324 }
325 }
326}
327
328pub fn chunk_text(title: &str, text: &str, max_tokens: usize, overlap: usize) -> Vec<String> {
332 let prefix = format!("title: {}\n\n", title);
333
334 if text.is_empty() {
335 return vec![format!("{}(no description)", prefix)];
336 }
337
338 let max_chars = max_tokens * 4;
339 let overlap_chars = overlap * 4;
340
341 if text.len() <= max_chars {
342 return vec![format!("{}{}", prefix, text)];
343 }
344
345 let floor_char = |s: &str, pos: usize| {
347 let pos = pos.min(s.len());
348 let mut i = pos;
349 while i > 0 && !s.is_char_boundary(i) {
350 i -= 1;
351 }
352 i
353 };
354
355 let mut chunks = Vec::new();
356 let mut start = 0;
357
358 while start < text.len() {
359 let end = floor_char(text, start + max_chars);
360
361 let chunk_slice = &text[start..end];
362 let break_at = if end < text.len() {
363 chunk_slice
364 .rfind("\n\n")
365 .or_else(|| chunk_slice.rfind('\n'))
366 .or_else(|| chunk_slice.rfind(". "))
367 .or_else(|| chunk_slice.rfind(' '))
368 .map(|p| start + p + 1)
369 .unwrap_or(end)
370 } else {
371 end
372 };
373
374 chunks.push(format!("{}{}", prefix, &text[start..break_at]));
375
376 if break_at >= text.len() {
377 break;
378 }
379
380 let new_start = floor_char(
381 text,
382 if break_at > overlap_chars {
383 break_at - overlap_chars
384 } else {
385 break_at
386 },
387 );
388 start = if new_start <= start {
390 break_at
391 } else {
392 new_start
393 };
394 }
395
396 chunks
397}
398
399pub fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
401 embedding.iter().flat_map(|f| f.to_le_bytes()).collect()
402}
403
404pub fn bytes_to_embedding(bytes: &[u8]) -> Vec<f32> {
406 bytes
407 .chunks_exact(4)
408 .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
409 .collect()
410}
411
412pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
414 if a.len() != b.len() || a.is_empty() {
415 return 0.0;
416 }
417
418 let mut dot = 0.0f32;
419 let mut norm_a = 0.0f32;
420 let mut norm_b = 0.0f32;
421
422 for (x, y) in a.iter().zip(b.iter()) {
423 dot += x * y;
424 norm_a += x * x;
425 norm_b += y * y;
426 }
427
428 let denom = norm_a.sqrt() * norm_b.sqrt();
429 if denom == 0.0 {
430 0.0
431 } else {
432 dot / denom
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn compacts_invalid_api_key_errors() {
442 let body = r#"{
443 "error": {
444 "code": 400,
445 "message": "API key not valid. Please pass a valid API key.",
446 "status": "INVALID_ARGUMENT"
447 }
448 }"#;
449
450 assert_eq!(
451 summarize_gemini_error(StatusCode::BAD_REQUEST, body),
452 "Invalid API key"
453 );
454 }
455
456 #[test]
457 fn compacts_rate_limit_errors() {
458 let body = r#"{
459 "error": {
460 "code": 429,
461 "message": "Quota exceeded.",
462 "status": "RESOURCE_EXHAUSTED"
463 }
464 }"#;
465
466 assert_eq!(
467 summarize_gemini_error(StatusCode::TOO_MANY_REQUESTS, body),
468 "Rate limited"
469 );
470 }
471
472 #[test]
473 fn falls_back_to_status_for_unknown_errors() {
474 assert_eq!(
475 summarize_gemini_error(StatusCode::SERVICE_UNAVAILABLE, "not-json"),
476 "Gemini unavailable"
477 );
478 }
479}