Skip to main content

terraphim_service/
lib.rs

1//! Main service layer for Terraphim AI.
2//!
3//! Provides document search, indexing, and AI-assisted summarisation across
4//! multiple haystack backends. Integrates the knowledge graph, thesaurus,
5//! and relevance-scoring pipeline into a single async service facade.
6use terraphim_config::ConfigState;
7use terraphim_persistence::Persistable;
8use terraphim_types::SearchQuery;
9mod document;
10mod score;
11mod search;
12mod summary;
13mod thesaurus;
14
15pub mod auto_route;
16pub use auto_route::{
17    AutoRouteContext, AutoRouteReason, AutoRouteResult, JMAP_MISSING_TOKEN_PENALTY,
18    auto_select_role,
19};
20
21#[cfg(feature = "openrouter")]
22pub mod openrouter;
23
24// Generic LLM layer for multiple providers (OpenRouter, Ollama, etc.)
25pub mod llm;
26
27// LLM proxy service for unified provider management
28
29// LLM Proxy service\npub mod proxy_client;
30// LLM Router configuration integration\n
31
32pub mod llm_proxy;
33
34// LLM Router configuration integration\n
35
36// Centralized HTTP client creation and configuration
37pub mod http_client;
38
39// Standardized logging initialization utilities
40pub mod logging;
41
42// Summarization queue system for production-ready async processing
43pub mod conversation_service;
44pub mod rate_limiter;
45pub mod summarization_manager;
46pub mod summarization_queue;
47pub mod summarization_worker;
48
49// Centralized error handling patterns and utilities
50pub mod error;
51
52// Context management for LLM conversations
53pub mod context;
54
55#[cfg(test)]
56mod context_tests;
57
58/// Normalize a filename to be used as a document ID
59///
60/// This ensures consistent ID generation between server startup and edit API
61pub(crate) fn normalize_filename_to_id(filename: &str) -> String {
62    let re = regex::Regex::new(r"[^a-zA-Z0-9]+").expect("Failed to create regex");
63    re.replace_all(filename, "").to_lowercase()
64}
65
66/// Top-level error type for the Terraphim service layer.
67#[derive(thiserror::Error, Debug)]
68pub enum ServiceError {
69    #[error("Middleware error: {0}")]
70    Middleware(#[from] terraphim_middleware::Error),
71
72    #[error("OpenDal error: {0}")]
73    OpenDal(Box<opendal::Error>),
74
75    #[error("Persistence error: {0}")]
76    Persistence(#[from] terraphim_persistence::Error),
77
78    #[error("Config error: {0}")]
79    Config(String),
80
81    #[cfg(feature = "openrouter")]
82    #[error("OpenRouter error: {0}")]
83    OpenRouter(#[from] crate::openrouter::OpenRouterError),
84
85    #[error("Common error: {0}")]
86    Common(#[from] crate::error::CommonError),
87}
88
89impl From<opendal::Error> for ServiceError {
90    fn from(err: opendal::Error) -> Self {
91        ServiceError::OpenDal(Box::new(err))
92    }
93}
94
95impl crate::error::TerraphimError for ServiceError {
96    fn category(&self) -> crate::error::ErrorCategory {
97        use crate::error::ErrorCategory;
98        match self {
99            ServiceError::Middleware(_) => ErrorCategory::Integration,
100            ServiceError::OpenDal(_) => ErrorCategory::Storage,
101            ServiceError::Persistence(_) => ErrorCategory::Storage,
102            ServiceError::Config(_) => ErrorCategory::Configuration,
103            #[cfg(feature = "openrouter")]
104            ServiceError::OpenRouter(_) => ErrorCategory::Integration,
105            ServiceError::Common(err) => err.category(),
106        }
107    }
108
109    fn is_recoverable(&self) -> bool {
110        match self {
111            ServiceError::Middleware(_) => true,
112            ServiceError::OpenDal(_) => false,
113            ServiceError::Persistence(_) => false,
114            ServiceError::Config(_) => false,
115            #[cfg(feature = "openrouter")]
116            ServiceError::OpenRouter(_) => true,
117            ServiceError::Common(err) => err.is_recoverable(),
118        }
119    }
120}
121
122pub type Result<T> = std::result::Result<T, ServiceError>;
123
124/// Main entry point for search, indexing, and AI operations in Terraphim.
125pub struct TerraphimService {
126    config_state: ConfigState,
127}
128
129impl TerraphimService {
130    /// Create a new TerraphimService
131    pub fn new(config_state: ConfigState) -> Self {
132        Self { config_state }
133    }
134
135    /// Fetch the current config
136    pub async fn fetch_config(&self) -> terraphim_config::Config {
137        let current_config = self.config_state.config.lock().await;
138        current_config.clone()
139    }
140
141    // Test helper methods
142    #[cfg(test)]
143    pub async fn get_role(
144        &self,
145        role_name: &terraphim_types::RoleName,
146    ) -> Result<terraphim_config::Role> {
147        let config = self.config_state.config.lock().await;
148        config
149            .roles
150            .get(role_name)
151            .cloned()
152            .ok_or_else(|| ServiceError::Config(format!("Role '{}' not found", role_name)))
153    }
154
155    /// Update the config
156    ///
157    /// Overwrites the config in the config state and returns the updated
158    /// config.
159    pub async fn update_config(
160        &self,
161        config: terraphim_config::Config,
162    ) -> Result<terraphim_config::Config> {
163        // Lock briefly to swap in the new config, then drop before save so
164        // the disk write doesn't block other /config endpoints.
165        {
166            let mut current_config = self.config_state.config.lock().await;
167            *current_config = config.clone();
168        }
169        config.save().await?;
170        log::info!("Config updated");
171        Ok(config)
172    }
173
174    /// Update only the `selected_role` in the config without mutating the rest of the
175    /// configuration. Returns the up-to-date `Config` object.
176    pub async fn update_selected_role(
177        &self,
178        role_name: terraphim_types::RoleName,
179    ) -> Result<terraphim_config::Config> {
180        // Lock briefly: validate, mutate in-memory state, snapshot. Drop the
181        // lock BEFORE the disk save -- holding the config mutex across an
182        // async I/O write blocks every other endpoint that touches /config
183        // (e.g. concurrent search, get_config) for the duration of the save.
184        let snapshot = {
185            let mut current_config = self.config_state.config.lock().await;
186
187            if !current_config.roles.contains_key(&role_name) {
188                return Err(ServiceError::Config(format!(
189                    "Role `{}` not found in config",
190                    role_name
191                )));
192            }
193
194            current_config.selected_role = role_name.clone();
195            current_config.clone()
196        };
197        // Persist asynchronously: in-memory update is the source of truth for
198        // subsequent reads; disk save is best-effort and must not delay the
199        // HTTP response. save_to_all() can take many seconds depending on the
200        // configured persistence profiles (sled WAL flush, S3 PUT, etc.) and
201        // should never block role selection.
202        let snapshot_for_save = snapshot.clone();
203        let role_for_log = role_name.clone();
204        tokio::spawn(async move {
205            if let Err(e) = snapshot_for_save.save().await {
206                log::warn!(
207                    "background persist of selected_role={} failed: {}",
208                    role_for_log,
209                    e
210                );
211            }
212        });
213        // Log role selection from the snapshot (no need to re-lock).
214        if let Some(role) = snapshot.roles.get(&role_name) {
215            if role.terraphim_it {
216                log::info!(
217                    "🎯 Selected role '{}' → terraphim_it: ENABLED (KG preprocessing will be applied)",
218                    role_name
219                );
220            } else {
221                log::info!("🎯 Selected role '{}' → terraphim_it: DISABLED", role_name);
222            }
223        }
224
225        Ok(snapshot)
226    }
227
228    /// Highlight search terms in the given text content
229    ///
230    /// This method wraps matching search terms with HTML-style highlighting tags
231    /// to make them visually distinct in the frontend.
232    pub(crate) fn highlight_search_terms(content: &str, search_query: &SearchQuery) -> String {
233        let mut highlighted_content = content.to_string();
234
235        // Get all terms from the search query
236        let terms = search_query.get_all_terms();
237
238        // Sort terms by length (longest first) to avoid partial replacements
239        let mut sorted_terms: Vec<&str> = terms.iter().map(|t| t.as_str()).collect();
240        sorted_terms.sort_by_key(|term| std::cmp::Reverse(term.len()));
241
242        for term in sorted_terms {
243            if term.trim().is_empty() {
244                continue;
245            }
246
247            // Create case-insensitive regex for the term
248            // Escape special regex characters in the search term
249            let escaped_term = regex::escape(term);
250
251            if let Ok(regex) = regex::RegexBuilder::new(&escaped_term)
252                .case_insensitive(true)
253                .build()
254            {
255                // Replace all matches with highlighted version
256                // Use a unique delimiter to avoid conflicts with existing HTML
257                let highlight_open = "<mark class=\"search-highlight\">";
258                let highlight_close = "</mark>";
259
260                highlighted_content = regex
261                    .replace_all(
262                        &highlighted_content,
263                        format!("{}{}{}", highlight_open, "$0", highlight_close),
264                    )
265                    .to_string();
266            }
267        }
268
269        highlighted_content
270    }
271}
272
273pub(crate) fn snippet_around(s: &str, marker: &str, before: usize, after: usize) -> String {
274    let Some(marker_byte) = s.find(marker) else {
275        return String::new();
276    };
277    let marker_char_index = s[..marker_byte].chars().count();
278    let total_chars = s.chars().count();
279
280    let start_char_index = marker_char_index.saturating_sub(before);
281    let end_char_index = (marker_char_index + marker.len() + after).min(total_chars);
282
283    if start_char_index >= end_char_index {
284        return String::new();
285    }
286
287    s.chars()
288        .skip(start_char_index)
289        .take(end_char_index - start_char_index)
290        .collect()
291}
292
293#[cfg(test)]
294mod lib_tests;