use crate::{CommerceError, Customer, InventoryItem, Order, Product, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::Duration;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Debug)]
pub struct EmbeddingService {
client: reqwest::blocking::Client,
api_key: String,
model: String,
dimensions: usize,
}
#[derive(Serialize)]
struct OpenAIEmbeddingRequest {
model: String,
input: Vec<String>,
dimensions: Option<usize>,
}
#[derive(Deserialize)]
struct OpenAIEmbeddingResponse {
data: Vec<EmbeddingData>,
#[allow(dead_code)]
model: String,
usage: EmbeddingUsage,
}
#[derive(Deserialize)]
struct EmbeddingData {
embedding: Vec<f32>,
index: usize,
}
#[derive(Deserialize)]
struct EmbeddingUsage {
#[allow(dead_code)]
prompt_tokens: u32,
total_tokens: u32,
}
#[derive(Debug, Clone)]
pub struct EmbeddingResult {
pub embedding: Vec<f32>,
pub text_hash: String,
pub tokens_used: u32,
}
impl EmbeddingService {
fn openai_error_from_body_result<E: std::fmt::Display>(
status: reqwest::StatusCode,
body_result: std::result::Result<String, E>,
) -> CommerceError {
match body_result {
Ok(body) if body.trim().is_empty() => CommerceError::ExternalServiceError(format!(
"OpenAI API error ({}) with empty response body",
status
)),
Ok(body) => CommerceError::ExternalServiceError(format!(
"OpenAI API error ({}): {}",
status, body
)),
Err(err) => CommerceError::ExternalServiceError(format!(
"OpenAI API error ({}); failed to read error body: {}",
status, err
)),
}
}
fn build_client(
timeout_secs: u64,
) -> std::result::Result<reqwest::blocking::Client, reqwest::Error> {
reqwest::blocking::Client::builder().timeout(Duration::from_secs(timeout_secs)).build()
}
fn build_client_or_default(timeout_secs: u64) -> reqwest::blocking::Client {
Self::build_client(timeout_secs).unwrap_or_else(|_| reqwest::blocking::Client::new())
}
pub fn new(api_key: String) -> Self {
Self {
client: Self::build_client_or_default(DEFAULT_TIMEOUT_SECS),
api_key,
model: "text-embedding-3-small".to_string(),
dimensions: 1536,
}
}
pub fn try_new(api_key: String) -> Result<Self> {
let client = Self::build_client(DEFAULT_TIMEOUT_SECS).map_err(|e| {
CommerceError::ExternalServiceError(format!("Failed to build HTTP client: {}", e))
})?;
Ok(Self { client, api_key, model: "text-embedding-3-small".to_string(), dimensions: 1536 })
}
pub fn with_model(api_key: String, model: String, dimensions: usize) -> Self {
Self {
client: Self::build_client_or_default(DEFAULT_TIMEOUT_SECS),
api_key,
model,
dimensions,
}
}
pub fn try_with_model(api_key: String, model: String, dimensions: usize) -> Result<Self> {
let client = Self::build_client(DEFAULT_TIMEOUT_SECS).map_err(|e| {
CommerceError::ExternalServiceError(format!("Failed to build HTTP client: {}", e))
})?;
Ok(Self { client, api_key, model, dimensions })
}
pub fn embed_batch(&self, texts: &[String]) -> Result<Vec<EmbeddingResult>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let hashes: Vec<String> = texts.iter().map(|t| Self::hash_text(t)).collect();
let request = OpenAIEmbeddingRequest {
model: self.model.clone(),
input: texts.to_vec(),
dimensions: Some(self.dimensions),
};
let response = self
.client
.post("https://api.openai.com/v1/embeddings")
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.map_err(|e| {
CommerceError::ExternalServiceError(format!("OpenAI API request failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
return Err(Self::openai_error_from_body_result(status, response.text()));
}
let result: OpenAIEmbeddingResponse = response.json().map_err(|e| {
CommerceError::ExternalServiceError(format!("Failed to parse OpenAI response: {}", e))
})?;
let tokens_per = result.usage.total_tokens / texts.len() as u32;
let mut ordered: Vec<Option<EmbeddingResult>> = vec![None; texts.len()];
for d in result.data {
if d.index >= texts.len() {
return Err(CommerceError::ExternalServiceError(format!(
"OpenAI response index out of bounds: {}",
d.index
)));
}
ordered[d.index] = Some(EmbeddingResult {
embedding: d.embedding,
text_hash: hashes[d.index].clone(),
tokens_used: tokens_per,
});
}
ordered
.into_iter()
.map(|maybe| {
maybe.ok_or_else(|| {
CommerceError::ExternalServiceError(
"OpenAI response missing embeddings".to_string(),
)
})
})
.collect()
}
pub fn embed(&self, text: &str) -> Result<EmbeddingResult> {
let results = self.embed_batch(&[text.to_string()])?;
results
.into_iter()
.next()
.ok_or_else(|| CommerceError::ExternalServiceError("No embedding returned".to_string()))
}
pub fn hash_text(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
hex::encode(hasher.finalize())
}
pub fn product_text(product: &Product) -> String {
format!("{} {} {}", product.name, product.description, product.slug)
}
pub fn customer_text(customer: &Customer) -> String {
format!("{} {} {}", customer.first_name, customer.last_name, customer.email)
}
pub fn order_text(order: &Order) -> String {
let mut text = format!("{} {}", order.order_number, order.status);
if let Some(notes) = &order.notes {
text.push(' ');
text.push_str(notes);
}
text
}
pub fn inventory_item_text(item: &InventoryItem) -> String {
let mut text = format!("{} {}", item.sku, item.name);
if let Some(desc) = &item.description {
text.push(' ');
text.push_str(desc);
}
text
}
pub fn model(&self) -> &str {
&self.model
}
pub const fn dimensions(&self) -> usize {
self.dimensions
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_text() {
let hash1 = EmbeddingService::hash_text("hello world");
let hash2 = EmbeddingService::hash_text("hello world");
let hash3 = EmbeddingService::hash_text("different text");
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
assert_eq!(hash1.len(), 64); }
#[test]
fn test_product_text() {
let product = Product {
id: stateset_primitives::ProductId::new(),
name: "Test Product".to_string(),
slug: "test-product".to_string(),
description: "A great product".to_string(),
status: crate::ProductStatus::Active,
product_type: crate::ProductType::Simple,
attributes: vec![],
seo: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let text = EmbeddingService::product_text(&product);
assert!(text.contains("Test Product"));
assert!(text.contains("A great product"));
assert!(text.contains("test-product"));
}
#[test]
fn test_openai_error_from_body_result_with_body() {
let err = EmbeddingService::openai_error_from_body_result::<&str>(
reqwest::StatusCode::BAD_REQUEST,
Ok("{\"error\":\"bad request\"}".to_string()),
);
let msg = err.to_string();
assert!(msg.contains("OpenAI API error (400 Bad Request)"));
assert!(msg.contains("bad request"));
}
#[test]
fn test_openai_error_from_body_result_with_empty_body() {
let err = EmbeddingService::openai_error_from_body_result::<&str>(
reqwest::StatusCode::TOO_MANY_REQUESTS,
Ok(String::new()),
);
let msg = err.to_string();
assert!(msg.contains("OpenAI API error (429 Too Many Requests) with empty response body"));
}
#[test]
fn test_openai_error_from_body_result_when_body_read_fails() {
let err = EmbeddingService::openai_error_from_body_result(
reqwest::StatusCode::INTERNAL_SERVER_ERROR,
Err("body read failed"),
);
let msg = err.to_string();
assert!(msg.contains("OpenAI API error (500 Internal Server Error)"));
assert!(msg.contains("failed to read error body: body read failed"));
}
}