mirror of
https://github.com/0xMassi/webclaw.git
synced 2026-07-21 07:01:01 +02:00
feat(noxa-68r.1): crate scaffold, traits, and core types
- Bootstrap crates/noxa-rag with lib+binary layout - EmbedProvider trait (1 method: embed) - VectorStore trait (4 methods: upsert, delete_by_url, search, name) - RagError enum (#[non_exhaustive], thiserror, From impls) - Types: Chunk, Point, PointPayload, SearchResult - Config: RagConfig with serde tagged enums for all providers - load_config() with embed_concurrency > 0 validation - Tokenizer Sync compile-time assertion in lib.rs - Stub implementations for all modules (noxa-68r.2-.8)
This commit is contained in:
parent
adf4b6ba55
commit
62554b8f12
13 changed files with 489 additions and 0 deletions
66
crates/noxa-rag/Cargo.toml
Normal file
66
crates/noxa-rag/Cargo.toml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
[package]
|
||||
name = "noxa-rag"
|
||||
description = "RAG pipeline for noxa — TEI embeddings + Qdrant vector store"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "noxa-rag-daemon"
|
||||
path = "src/bin/noxa-rag-daemon.rs"
|
||||
|
||||
[dependencies]
|
||||
noxa-core = { workspace = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = "0.8"
|
||||
|
||||
# Error handling
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Tracing
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# Async traits
|
||||
async-trait = "0.1"
|
||||
|
||||
# HTTP client (plain reqwest — no primp patches needed for TEI/Qdrant)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
# Vector store — REST transport only (reqwest feature, no protoc required)
|
||||
qdrant-client = { version = "1", default-features = false, features = ["reqwest"] }
|
||||
|
||||
# Chunking
|
||||
text-splitter = { version = "0.25", features = ["markdown", "tokenizers"] }
|
||||
tokenizers = "0.21"
|
||||
|
||||
# UUID v5 for deterministic point IDs
|
||||
uuid = { version = "1", features = ["v5", "serde"] }
|
||||
|
||||
# Filesystem watcher
|
||||
notify = "6"
|
||||
notify-debouncer-mini = "0.4"
|
||||
|
||||
# Concurrent data structures
|
||||
dashmap = "6"
|
||||
|
||||
# URL parsing
|
||||
url = "2"
|
||||
|
||||
# CLI args
|
||||
clap = { workspace = true }
|
||||
|
||||
# Date/time for failed-jobs log
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# CancellationToken for coordinated shutdown
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
5
crates/noxa-rag/src/bin/noxa-rag-daemon.rs
Normal file
5
crates/noxa-rag/src/bin/noxa-rag-daemon.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// noxa-rag-daemon — implemented in noxa-68r.8
|
||||
fn main() {
|
||||
eprintln!("[noxa-rag] daemon stub — not yet implemented (noxa-68r.8)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
13
crates/noxa-rag/src/chunker.rs
Normal file
13
crates/noxa-rag/src/chunker.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Chunker — implemented in noxa-68r.2
|
||||
use noxa_core::types::ExtractionResult;
|
||||
use crate::config::ChunkerConfig;
|
||||
use crate::types::Chunk;
|
||||
|
||||
pub fn chunk(
|
||||
_result: &ExtractionResult,
|
||||
_config: &ChunkerConfig,
|
||||
_tokenizer: &tokenizers::Tokenizer,
|
||||
) -> Vec<Chunk> {
|
||||
// Full implementation in noxa-68r.2
|
||||
vec![]
|
||||
}
|
||||
145
crates/noxa-rag/src/config.rs
Normal file
145
crates/noxa-rag/src/config.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
use serde::Deserialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::RagError;
|
||||
|
||||
/// Top-level configuration deserialized from noxa-rag.toml.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct RagConfig {
|
||||
pub source: SourceConfig,
|
||||
pub embed_provider: EmbedProviderConfig,
|
||||
pub vector_store: VectorStoreConfig,
|
||||
pub chunker: ChunkerConfig,
|
||||
pub pipeline: PipelineConfig,
|
||||
/// UUID namespace for deterministic point IDs.
|
||||
/// Default: 6ba7b810-9dad-11d1-80b4-00c04fd430c8
|
||||
#[serde(default = "default_uuid_namespace")]
|
||||
pub uuid_namespace: uuid::Uuid,
|
||||
}
|
||||
|
||||
fn default_uuid_namespace() -> uuid::Uuid {
|
||||
uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SourceConfig {
|
||||
FsWatcher {
|
||||
watch_dir: PathBuf,
|
||||
#[serde(default = "default_debounce_ms")]
|
||||
debounce_ms: u64,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_debounce_ms() -> u64 {
|
||||
500
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum EmbedProviderConfig {
|
||||
Tei {
|
||||
url: String,
|
||||
model: String,
|
||||
/// Optional: load tokenizer from local path (avoids HF Hub at startup).
|
||||
local_path: Option<PathBuf>,
|
||||
},
|
||||
OpenAi {
|
||||
api_key: String,
|
||||
model: String,
|
||||
},
|
||||
VoyageAi {
|
||||
api_key: String,
|
||||
model: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum VectorStoreConfig {
|
||||
Qdrant {
|
||||
/// REST URL — port 6333, NOT 6334 (gRPC).
|
||||
url: String,
|
||||
collection: String,
|
||||
/// Optional API key. Override with NOXA_RAG_QDRANT_API_KEY env var.
|
||||
api_key: Option<String>,
|
||||
},
|
||||
/// Dev/test only — factory returns RagError::Config("not implemented").
|
||||
InMemory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ChunkerConfig {
|
||||
#[serde(default = "default_target_tokens")]
|
||||
pub target_tokens: usize,
|
||||
#[serde(default = "default_overlap_tokens")]
|
||||
pub overlap_tokens: usize,
|
||||
#[serde(default = "default_min_words")]
|
||||
pub min_words: usize,
|
||||
#[serde(default = "default_max_chunks_per_page")]
|
||||
pub max_chunks_per_page: usize,
|
||||
}
|
||||
|
||||
impl Default for ChunkerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target_tokens: default_target_tokens(),
|
||||
overlap_tokens: default_overlap_tokens(),
|
||||
min_words: default_min_words(),
|
||||
max_chunks_per_page: default_max_chunks_per_page(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_target_tokens() -> usize { 512 }
|
||||
fn default_overlap_tokens() -> usize { 64 }
|
||||
fn default_min_words() -> usize { 50 }
|
||||
fn default_max_chunks_per_page() -> usize { 100 }
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct PipelineConfig {
|
||||
#[serde(default = "default_embed_concurrency")]
|
||||
pub embed_concurrency: usize,
|
||||
/// MUST be an absolute path — systemd daemon runs with CWD = /.
|
||||
pub failed_jobs_log: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for PipelineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
embed_concurrency: default_embed_concurrency(),
|
||||
failed_jobs_log: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_embed_concurrency() -> usize { 4 }
|
||||
|
||||
/// Load and validate config from a TOML file.
|
||||
pub fn load_config(path: &Path) -> Result<RagConfig, RagError> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| RagError::Config(format!("cannot read config file {}: {}", path.display(), e)))?;
|
||||
|
||||
let config: RagConfig = toml::from_str(&content)
|
||||
.map_err(|e| RagError::Config(format!("config parse error: {}", e)))?;
|
||||
|
||||
// Validate embed_concurrency > 0
|
||||
if config.pipeline.embed_concurrency == 0 {
|
||||
return Err(RagError::Config(
|
||||
"pipeline.embed_concurrency must be > 0 (0 causes Semaphore deadlock)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate failed_jobs_log is absolute if set
|
||||
if let Some(ref log_path) = config.pipeline.failed_jobs_log {
|
||||
if !log_path.is_absolute() {
|
||||
return Err(RagError::Config(format!(
|
||||
"pipeline.failed_jobs_log must be an absolute path (got: {}). \
|
||||
systemd daemon runs with CWD = / and relative paths resolve there.",
|
||||
log_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
19
crates/noxa-rag/src/embed/mod.rs
Normal file
19
crates/noxa-rag/src/embed/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::RagError;
|
||||
|
||||
/// Pluggable embedding provider.
|
||||
///
|
||||
/// Trait surface is minimal by design — only what ALL impls share.
|
||||
/// `is_available()` and `dimensions()` are concrete methods on each provider struct,
|
||||
/// called during factory startup probes (not via dyn dispatch).
|
||||
#[async_trait]
|
||||
pub trait EmbedProvider: Send + Sync {
|
||||
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, RagError>;
|
||||
}
|
||||
|
||||
pub type DynEmbedProvider = Arc<dyn EmbedProvider + Send + Sync>;
|
||||
|
||||
pub mod tei;
|
||||
pub use tei::TeiProvider;
|
||||
39
crates/noxa-rag/src/embed/tei.rs
Normal file
39
crates/noxa-rag/src/embed/tei.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// TeiProvider — implemented in noxa-68r.3
|
||||
use async_trait::async_trait;
|
||||
use crate::embed::EmbedProvider;
|
||||
use crate::error::RagError;
|
||||
|
||||
pub struct TeiProvider {
|
||||
pub(crate) client: reqwest::Client,
|
||||
pub(crate) url: String,
|
||||
pub(crate) model: String,
|
||||
pub(crate) dimensions: usize,
|
||||
}
|
||||
|
||||
impl TeiProvider {
|
||||
pub async fn is_available(&self) -> bool {
|
||||
self.client
|
||||
.get(format!("{}/health", self.url))
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn dimensions(&self) -> usize {
|
||||
self.dimensions
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
"tei"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedProvider for TeiProvider {
|
||||
async fn embed(&self, _texts: &[String]) -> Result<Vec<Vec<f32>>, RagError> {
|
||||
// Full implementation in noxa-68r.3
|
||||
Err(RagError::Embed("TeiProvider not yet implemented".to_string()))
|
||||
}
|
||||
}
|
||||
22
crates/noxa-rag/src/error.rs
Normal file
22
crates/noxa-rag/src/error.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use thiserror::Error;
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RagError {
|
||||
#[error("embed error: {0}")]
|
||||
Embed(String),
|
||||
#[error("store error: {0}")]
|
||||
Store(String),
|
||||
#[error("chunk error: {0}")]
|
||||
Chunk(String),
|
||||
#[error("config error: {0}")]
|
||||
Config(String),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("http error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("error: {0}")]
|
||||
Generic(String),
|
||||
}
|
||||
18
crates/noxa-rag/src/factory.rs
Normal file
18
crates/noxa-rag/src/factory.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Factory — implemented in noxa-68r.6
|
||||
use crate::config::RagConfig;
|
||||
use crate::embed::DynEmbedProvider;
|
||||
use crate::error::RagError;
|
||||
use crate::store::DynVectorStore;
|
||||
|
||||
pub async fn build_embed_provider(_config: &RagConfig) -> Result<DynEmbedProvider, RagError> {
|
||||
// Full implementation in noxa-68r.6
|
||||
Err(RagError::Config("factory not yet implemented".to_string()))
|
||||
}
|
||||
|
||||
pub async fn build_vector_store(
|
||||
_config: &RagConfig,
|
||||
_embed_dims: usize,
|
||||
) -> Result<DynVectorStore, RagError> {
|
||||
// Full implementation in noxa-68r.6
|
||||
Err(RagError::Config("factory not yet implemented".to_string()))
|
||||
}
|
||||
40
crates/noxa-rag/src/lib.rs
Normal file
40
crates/noxa-rag/src/lib.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/// noxa-rag — RAG pipeline crate.
|
||||
///
|
||||
/// Watches noxa output directory for ExtractionResult JSON files,
|
||||
/// chunks them, embeds via TEI, and upserts to Qdrant.
|
||||
///
|
||||
/// # Crate structure
|
||||
/// - `embed` — EmbedProvider trait + TeiProvider impl
|
||||
/// - `store` — VectorStore trait + QdrantStore impl
|
||||
/// - `chunker` — ExtractionResult → Vec<Chunk>
|
||||
/// - `config` — RagConfig (TOML deserialization)
|
||||
/// - `factory` — build_embed_provider / build_vector_store
|
||||
/// - `pipeline` — filesystem watcher orchestration
|
||||
/// - `error` — RagError enum
|
||||
|
||||
// Tokenizer Sync compile-time assertion.
|
||||
// tokenizers::Tokenizer must be Sync to be used across tokio workers.
|
||||
// If this fails to compile, workers cannot safely share the tokenizer.
|
||||
const _: () = {
|
||||
fn assert_sync<T: Sync>() {}
|
||||
fn _check() {
|
||||
assert_sync::<tokenizers::Tokenizer>();
|
||||
}
|
||||
};
|
||||
|
||||
pub mod chunker;
|
||||
pub mod config;
|
||||
pub mod embed;
|
||||
pub mod error;
|
||||
pub mod factory;
|
||||
pub mod pipeline;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
// Re-export most-used types at crate root
|
||||
pub use config::{load_config, RagConfig};
|
||||
pub use embed::{DynEmbedProvider, EmbedProvider};
|
||||
pub use error::RagError;
|
||||
pub use factory::{build_embed_provider, build_vector_store};
|
||||
pub use store::{DynVectorStore, VectorStore};
|
||||
pub use types::{Chunk, Point, PointPayload, SearchResult};
|
||||
24
crates/noxa-rag/src/pipeline.rs
Normal file
24
crates/noxa-rag/src/pipeline.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Pipeline — implemented in noxa-68r.7
|
||||
use std::sync::Arc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::RagConfig;
|
||||
use crate::embed::DynEmbedProvider;
|
||||
use crate::error::RagError;
|
||||
use crate::store::DynVectorStore;
|
||||
|
||||
pub struct Pipeline {
|
||||
pub config: RagConfig,
|
||||
pub embed: DynEmbedProvider,
|
||||
pub store: DynVectorStore,
|
||||
pub tokenizer: Arc<tokenizers::Tokenizer>,
|
||||
pub shutdown: CancellationToken,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
pub async fn run(&self) -> Result<(), RagError> {
|
||||
// Full implementation in noxa-68r.7
|
||||
self.shutdown.cancelled().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
23
crates/noxa-rag/src/store/mod.rs
Normal file
23
crates/noxa-rag/src/store/mod.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::error::RagError;
|
||||
use crate::types::{Point, SearchResult};
|
||||
|
||||
/// Pluggable vector store backend.
|
||||
///
|
||||
/// Trait surface is minimal — only what ALL impls share.
|
||||
/// Collection lifecycle (create_collection, collection_exists) lives in factory.rs
|
||||
/// as concrete methods on each store struct, called during startup probes.
|
||||
#[async_trait]
|
||||
pub trait VectorStore: Send + Sync {
|
||||
async fn upsert(&self, points: Vec<Point>) -> Result<(), RagError>;
|
||||
async fn delete_by_url(&self, url: &str) -> Result<(), RagError>;
|
||||
async fn search(&self, vector: &[f32], limit: usize) -> Result<Vec<SearchResult>, RagError>;
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
|
||||
pub type DynVectorStore = Arc<dyn VectorStore + Send + Sync>;
|
||||
|
||||
pub mod qdrant;
|
||||
pub use qdrant::QdrantStore;
|
||||
30
crates/noxa-rag/src/store/qdrant.rs
Normal file
30
crates/noxa-rag/src/store/qdrant.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// QdrantStore — implemented in noxa-68r.4
|
||||
use async_trait::async_trait;
|
||||
use crate::error::RagError;
|
||||
use crate::store::VectorStore;
|
||||
use crate::types::{Point, SearchResult};
|
||||
|
||||
pub struct QdrantStore {
|
||||
pub(crate) client: qdrant_client::Qdrant,
|
||||
pub(crate) collection: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VectorStore for QdrantStore {
|
||||
async fn upsert(&self, _points: Vec<Point>) -> Result<(), RagError> {
|
||||
// Full implementation in noxa-68r.4
|
||||
Err(RagError::Store("QdrantStore not yet implemented".to_string()))
|
||||
}
|
||||
|
||||
async fn delete_by_url(&self, _url: &str) -> Result<(), RagError> {
|
||||
Err(RagError::Store("QdrantStore not yet implemented".to_string()))
|
||||
}
|
||||
|
||||
async fn search(&self, _vector: &[f32], _limit: usize) -> Result<Vec<SearchResult>, RagError> {
|
||||
Err(RagError::Store("QdrantStore not yet implemented".to_string()))
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"qdrant"
|
||||
}
|
||||
}
|
||||
45
crates/noxa-rag/src/types.rs
Normal file
45
crates/noxa-rag/src/types.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A chunk produced from an ExtractionResult.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Chunk {
|
||||
pub text: String,
|
||||
pub source_url: String,
|
||||
pub domain: String,
|
||||
pub chunk_index: usize,
|
||||
pub total_chunks: usize,
|
||||
pub char_offset: usize,
|
||||
pub token_estimate: usize,
|
||||
}
|
||||
|
||||
/// A point ready for upsert into the vector store.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Point {
|
||||
/// UUID v5 deterministic ID: url#chunkN
|
||||
pub id: Uuid,
|
||||
pub vector: Vec<f32>,
|
||||
pub payload: PointPayload,
|
||||
}
|
||||
|
||||
/// Payload stored alongside each vector in the store.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PointPayload {
|
||||
pub text: String,
|
||||
/// Normalized URL (strip fragment, trailing slash, lowercase scheme+host).
|
||||
pub url: String,
|
||||
pub domain: String,
|
||||
pub chunk_index: usize,
|
||||
pub total_chunks: usize,
|
||||
pub token_estimate: usize,
|
||||
}
|
||||
|
||||
/// A search result returned by VectorStore::search().
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
pub text: String,
|
||||
pub url: String,
|
||||
pub score: f32,
|
||||
pub chunk_index: usize,
|
||||
pub token_estimate: usize,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue