feat: resumable sha256 embedding cache + cost estimate

This commit is contained in:
2026-05-18 18:41:27 +02:00
parent ff41d53155
commit 2686edab91
2 changed files with 112 additions and 1 deletions
+84 -1
View File
@@ -1 +1,84 @@
// implemented in a later task
use crate::ionos::IonosClient;
use crate::model::AppError;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
pub struct EmbeddingStore {
dir: PathBuf, // <index_dir>/embeddings/<model-sanitized>/
model: String,
}
pub struct Estimate { pub items: usize, pub chars: usize }
pub fn estimate(pending: &[String]) -> Estimate {
Estimate { items: pending.len(), chars: pending.iter().map(|s| s.len()).sum() }
}
impl EmbeddingStore {
pub fn key(model: &str, text: &str) -> String {
let mut h = Sha256::new();
h.update(model.as_bytes());
h.update([0u8]);
h.update(text.as_bytes());
hex(&h.finalize())
}
pub fn open(index_dir: &str, model: &str) -> Result<Self, AppError> {
let safe = model.replace('/', "_");
let dir = PathBuf::from(index_dir).join("embeddings").join(safe);
fs::create_dir_all(&dir)
.map_err(|e| AppError::Io(format!("{}: {e}", dir.display())))?;
Ok(Self { dir, model: model.to_string() })
}
fn path(&self, text: &str) -> PathBuf {
self.dir.join(format!("{}.f32", Self::key(&self.model, text)))
}
pub fn has(&self, text: &str) -> bool { self.path(text).exists() }
pub fn get(&self, text: &str) -> Option<Vec<f32>> {
let bytes = fs::read(self.path(text)).ok()?;
Some(bytes.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect())
}
/// Atomic write (temp + rename) so an interrupted run never leaves
/// a half-written vector — the next run simply re-embeds that key.
pub fn put(&self, text: &str, v: &[f32]) -> Result<(), AppError> {
let mut bytes = Vec::with_capacity(v.len() * 4);
for f in v { bytes.extend_from_slice(&f.to_le_bytes()); }
let final_path = self.path(text);
let tmp = final_path.with_extension("tmp");
fs::write(&tmp, &bytes).map_err(|e| AppError::Io(format!("{e}")))?;
fs::rename(&tmp, &final_path).map_err(|e| AppError::Io(format!("{e}")))?;
Ok(())
}
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{:02x}", x)).collect()
}
/// Embed a batch via IONOS, persisting each result. Only `texts` not
/// already cached should be passed (caller filters via `has`).
pub fn embed_batch(
client: &IonosClient, store: &EmbeddingStore, model: &str, texts: &[String],
) -> Result<usize, AppError> {
if texts.is_empty() { return Ok(0); }
let body = serde_json::json!({ "model": model, "input": texts });
let resp: serde_json::Value = client.post_json("/embeddings", &body)?;
let data = resp["data"].as_array()
.ok_or_else(|| AppError::Ionos("embeddings: no data[]".into()))?;
let mut n = 0;
for (i, item) in data.iter().enumerate() {
let v: Vec<f32> = item["embedding"].as_array()
.ok_or_else(|| AppError::Ionos("embeddings: no embedding[]".into()))?
.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
store.put(&texts[i], &v)?;
n += 1;
}
Ok(n)
}