feat(core): implement full memory pipeline (#11)

This commit is contained in:
2026-08-24 01:37:16 +00:00
parent 46d382e6cc
commit af6f22217d
19 changed files with 2235 additions and 259 deletions
+2
View File
@@ -14,3 +14,5 @@ thiserror = { workspace = true }
reqwest = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
pgvector = { workspace = true }
uuid = { workspace = true }
+7 -4
View File
@@ -144,10 +144,13 @@ impl ChatClient {
let mut last_error: Option<anyhow::Error> = None;
for attempt in 0..self.max_retries {
let response = self
.http
.post(&url)
.header("apikey", &self.api_key)
let mut req = self.http.post(&url);
// Only add apikey header if it's not empty (for backward compatibility)
if !self.api_key.is_empty() && !self.api_key.starts_with("http") {
req = req.header("apikey", &self.api_key);
}
let response = req
.header("Content-Type", "application/json")
.body(body.clone())
.timeout(self.timeout)
+64
View File
@@ -0,0 +1,64 @@
use anyhow::{anyhow, Result};
use pgvector::Vector;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
/// Embeddings client for Ollama
#[derive(Clone)]
pub struct EmbeddingsClient {
base_url: String,
model: String,
#[allow(dead_code)]
http: Client,
}
#[derive(Debug, Serialize)]
struct EmbeddingRequest {
model: String,
input: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct EmbeddingResponse {
embeddings: Vec<Vec<f32>>,
model: String,
}
impl EmbeddingsClient {
/// Create from environment
/// Uses api.riotpiao.com gateway (nomic-ai/nomic-embed-text-v2-moe model)
pub fn from_env() -> Result<Self> {
let base_url = env::var("LLM_API_BASE").unwrap_or_else(|_| "https://api.riotpiao.com".to_string());
let model = "nomic-ai/nomic-embed-text-v2-moe".to_string();
Ok(Self {
base_url,
model,
http: Client::new(),
})
}
/// Embed a single text string
pub async fn embed(&self, text: &str) -> Result<Vector> {
let embeddings = self.embed_batch(&[text.to_string()]).await?;
Ok(embeddings.into_iter().next().ok_or_else(|| anyhow::anyhow!("empty embedding response"))?)
}
/// Embed multiple texts in a batch using api.riotpiao.com gateway
pub async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vector>> {
let req = EmbeddingRequest {
model: self.model.clone(),
input: texts.to_vec(),
};
let url = format!("{}/v1/embeddings", self.base_url);
let resp: EmbeddingResponse = self.http.post(&url).json(&req).send().await?.json().await?;
Ok(resp
.embeddings
.into_iter()
.map(Vector::from)
.collect())
}
}
+2
View File
@@ -1,5 +1,7 @@
pub mod chat;
pub mod rerank;
pub mod embeddings;
pub use chat::{ChatClient, Completion, Usage};
pub use rerank::RerankClient;
pub use embeddings::EmbeddingsClient;
+38 -18
View File
@@ -1,33 +1,51 @@
use anyhow::Result;
use reqwest::Client;
use serde_json::json;
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Rerank response item (bare array, not OpenAI envelope).
#[derive(serde::Deserialize, Debug)]
/// Rerank score result
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RerankScore {
pub index: usize,
pub score: f32,
}
/// Rerank client (BAAI/bge-reranker-base via TEI).
/// Rerank response from gateway
#[derive(Deserialize)]
struct RerankResponse {
results: Vec<RerankScore>,
}
/// Rerank client using api.riotpiao.com gateway (BAAI/bge-reranker-base model)
pub struct RerankClient {
base_url: String,
api_key: String,
model: String,
timeout_secs: u64,
}
impl RerankClient {
/// Create rerank client.
pub fn new(base_url: &str, api_key: &str, model: &str) -> Result<Self> {
/// Create rerank client pointing to gateway
pub fn new(base_url: &str, _api_key: &str, model: &str) -> Result<Self> {
Ok(Self {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
timeout_secs: 300,
})
}
/// Create from environment (uses api.riotpiao.com)
pub fn from_env() -> Result<Self> {
let base_url = std::env::var("LLM_API_BASE")
.unwrap_or_else(|_| "https://api.riotpiao.com".to_string());
let model = "BAAI/bge-reranker-base".to_string();
Ok(Self {
base_url,
model,
timeout_secs: 300,
})
}
/// Rerank query against texts, return scored items in score order.
/// Returns Vec<(index, score)> mapping back to input positions.
pub async fn rerank(&self, query: &str, texts: &[&str]) -> Result<Vec<(usize, f32)>> {
@@ -36,39 +54,41 @@ impl RerankClient {
return Ok(vec![]);
}
let url = format!("{}/rerank", self.base_url);
let url = format!("{}/v1/rerank", self.base_url);
let client = Client::builder()
.timeout(std::time::Duration::from_secs(self.timeout_secs))
.timeout(Duration::from_secs(self.timeout_secs))
.build()?;
let payload = json!({
let payload = serde_json::json!({
"model": self.model,
"query": query,
"texts": texts,
"top_k": texts.len(),
});
let response = client
.post(&url)
.header("apikey", &self.api_key)
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Rerank failed: {}", response.status()));
let error_text = response.text().await.unwrap_or_default();
return Err(anyhow::anyhow!("Rerank failed: {}", error_text));
}
// Parse bare array (not OpenAI envelope)
let scores: Vec<RerankScore> = response.json().await?;
// Parse gateway response (OpenAI format with results field)
let resp: RerankResponse = response.json().await?;
// Map back to input positions and scores
let mut results: Vec<(usize, f32)> = scores
// Map to (index, score) and sort by score descending
let mut results: Vec<(usize, f32)> = resp
.results
.into_iter()
.map(|s| (s.index, s.score))
.collect();
// Sort by score descending (highest first)
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
Ok(results)