Skip to main content

context_forge/
builder.rs

1//! [`ContextForgeBuilder`] — opinionated construction path for [`crate::ContextForge`].
2
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::config::Config;
7use crate::engine::ContextEngine;
8use crate::lexicon::{CompositeLexiconScorer, DefaultEnglishScorer, LexiconScorer};
9use crate::scrub::ScrubConfig;
10use crate::storage::open_storage;
11use crate::traits::Result;
12use crate::ContextForge;
13
14/// Builder for [`ContextForge`].
15///
16/// The builder always pre-seeds [`DefaultEnglishScorer`] so plain-English
17/// importance signals ("confirmed", "we decided", "never mind", etc.) are
18/// active without any additional configuration. A caller-provided persona
19/// scorer is optional and stacks additively on top via
20/// [`CompositeLexiconScorer`].
21///
22/// # Example
23///
24/// ```no_run
25/// use context_forge::{Config, ContextForge, ConfigLexiconScorer};
26/// use std::path::PathBuf;
27///
28/// #[tokio::main]
29/// async fn main() -> Result<(), context_forge::Error> {
30///     let mut config = Config::default();
31///     config.db_path = PathBuf::from("memory.db");
32///
33///     // English scorer only (most common case):
34///     let cf = ContextForge::builder(config.clone()).build().await?;
35///
36///     // English + persona scorer loaded from a TOML string:
37///     let toml = "[affirmations]\npatterns = [\"for the emperor\"]";
38///     let persona: ConfigLexiconScorer = toml.parse()?;
39///     let cf = ContextForge::builder(config).with_persona_scorer(persona).build().await?;
40///
41///     Ok(())
42/// }
43/// ```
44pub struct ContextForgeBuilder {
45    config: Config,
46    persona_scorer: Option<Arc<dyn LexiconScorer>>,
47    #[cfg(feature = "semantic")]
48    embedding_cache_dir: Option<std::path::PathBuf>,
49}
50
51impl ContextForgeBuilder {
52    /// Create a new builder with the given config.
53    ///
54    /// Prefer [`ContextForge::builder`] over calling this directly.
55    #[must_use]
56    pub fn new(config: Config) -> Self {
57        Self {
58            config,
59            persona_scorer: None,
60            #[cfg(feature = "semantic")]
61            embedding_cache_dir: None,
62        }
63    }
64
65    /// Stack a persona scorer on top of the always-on [`DefaultEnglishScorer`].
66    ///
67    /// The persona scorer is typically a [`crate::ConfigLexiconScorer`] loaded
68    /// from a domain-specific TOML file. Its boosts are summed with the English
69    /// layer; the engine applies a `-1.0` floor after fusion.
70    #[must_use]
71    pub fn with_persona_scorer(mut self, scorer: impl LexiconScorer + 'static) -> Self {
72        self.persona_scorer = Some(Arc::new(scorer));
73        self
74    }
75
76    /// Enable semantic search using the all-MiniLM-L6-v2 model.
77    ///
78    /// `cache_dir` is where fastembed stores the downloaded ONNX weights
79    /// (~22 MB). The model is downloaded automatically on first use; subsequent
80    /// starts load from the local cache.
81    ///
82    /// Requires the `semantic` Cargo feature.
83    #[cfg(feature = "semantic")]
84    #[must_use]
85    pub fn with_embedding_model(mut self, cache_dir: impl AsRef<std::path::Path>) -> Self {
86        self.embedding_cache_dir = Some(cache_dir.as_ref().to_path_buf());
87        self
88    }
89
90    /// Open the database and build a [`ContextForge`] with the configured scorer.
91    ///
92    /// The [`DefaultEnglishScorer`] is always included. If
93    /// [`Self::with_persona_scorer`] was called, both scorers are composed via
94    /// [`CompositeLexiconScorer`].
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the database cannot be opened or migrations fail.
99    pub async fn build(self) -> Result<ContextForge> {
100        let db_path = self.config.db_path.clone();
101        let max_entries = self.config.max_entries;
102        let scrub_config: ScrubConfig = self.config.scrub.clone();
103
104        let (storage, searcher) = open_storage(Path::new(&db_path), max_entries).await?;
105
106        let english: Arc<dyn LexiconScorer> = Arc::new(DefaultEnglishScorer::default());
107        let scorer: Arc<dyn LexiconScorer> = match self.persona_scorer {
108            Some(persona) => Arc::new(CompositeLexiconScorer::new(vec![english, persona])),
109            None => english,
110        };
111
112        #[cfg_attr(not(feature = "semantic"), allow(unused_mut))]
113        let mut engine = ContextEngine::new(Box::new(storage), Box::new(searcher), self.config)
114            .with_scorer(scorer);
115
116        #[cfg(feature = "semantic")]
117        if let Some(cache_dir) = self.embedding_cache_dir {
118            let embedder = crate::semantic::FasEmbedder::new(&cache_dir)?;
119            engine = engine.with_embedder(Arc::new(embedder));
120        }
121
122        Ok(ContextForge::from_parts(engine, scrub_config))
123    }
124}