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}