1use 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
24pub mod llm;
26
27pub mod llm_proxy;
33
34pub mod http_client;
38
39pub mod logging;
41
42pub mod conversation_service;
44pub mod rate_limiter;
45pub mod summarization_manager;
46pub mod summarization_queue;
47pub mod summarization_worker;
48
49pub mod error;
51
52pub mod context;
54
55#[cfg(test)]
56mod context_tests;
57
58pub(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#[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
124pub struct TerraphimService {
126 config_state: ConfigState,
127}
128
129impl TerraphimService {
130 pub fn new(config_state: ConfigState) -> Self {
132 Self { config_state }
133 }
134
135 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 #[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 pub async fn update_config(
160 &self,
161 config: terraphim_config::Config,
162 ) -> Result<terraphim_config::Config> {
163 {
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 pub async fn update_selected_role(
177 &self,
178 role_name: terraphim_types::RoleName,
179 ) -> Result<terraphim_config::Config> {
180 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 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 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 pub(crate) fn highlight_search_terms(content: &str, search_query: &SearchQuery) -> String {
233 let mut highlighted_content = content.to_string();
234
235 let terms = search_query.get_all_terms();
237
238 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 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 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;