| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| use std::path::PathBuf; |
| use std::time::Instant; |
|
|
| use crate::config::Config; |
| use crate::engine::Engine; |
| use crate::hashtags; |
| use crate::kb::{KbLookup, KnowledgeIndex}; |
| use crate::router::Router; |
| use crate::translator::{self, Translation}; |
|
|
| |
| #[derive(Debug, Clone)] |
| #[allow(dead_code)] |
| pub struct PipelineResult { |
| |
| pub response: String, |
| |
| pub expert: String, |
| |
| pub hashtags: Vec<String>, |
| |
| pub from_cache: bool, |
| |
| pub translation: Option<Translation>, |
| |
| pub timing: PipelineTiming, |
| } |
|
|
| #[derive(Debug, Clone, Default)] |
| pub struct PipelineTiming { |
| pub hashtag_ms: u64, |
| pub translate_ms: u64, |
| pub kb_lookup_ms: u64, |
| pub routing_ms: u64, |
| pub generation_ms: u64, |
| pub total_ms: u64, |
| } |
|
|
| |
| pub struct Pipeline { |
| |
| kb: Option<KnowledgeIndex>, |
| |
| router: Router, |
| |
| config: Config, |
| |
| use_cache: bool, |
| |
| use_translate: bool, |
| } |
|
|
| #[allow(dead_code)] |
| impl Pipeline { |
| |
| pub fn new(config: Config, kb_path: Option<PathBuf>) -> anyhow::Result<Self> { |
| let expert_names: Vec<String> = config.experts.iter().map(|e| e.name.clone()).collect(); |
| let router = Router::new(&expert_names); |
|
|
| let kb = if let Some(ref path) = kb_path { |
| if path.exists() { |
| Some(KnowledgeIndex::load(path)?) |
| } else { |
| tracing::warn!( |
| "Knowledge base file not found: {}. Running without cache.", |
| path.display() |
| ); |
| None |
| } |
| } else { |
| |
| let default_path = PathBuf::from("knowledge_base.json"); |
| if default_path.exists() { |
| Some(KnowledgeIndex::load(&default_path)?) |
| } else { |
| tracing::info!("No knowledge base found — running without cache."); |
| None |
| } |
| }; |
|
|
| Ok(Self { |
| kb, |
| router, |
| config, |
| use_cache: true, |
| use_translate: true, |
| }) |
| } |
|
|
| |
| pub fn set_cache(&mut self, enabled: bool) { |
| self.use_cache = enabled; |
| } |
|
|
| |
| pub fn set_translate(&mut self, enabled: bool) { |
| self.use_translate = enabled; |
| } |
|
|
| |
| pub fn has_kb(&self) -> bool { |
| self.kb.as_ref().map(|kb| !kb.is_empty()).unwrap_or(false) |
| } |
|
|
| |
| pub fn kb_len(&self) -> usize { |
| self.kb.as_ref().map(|kb| kb.len()).unwrap_or(0) |
| } |
|
|
| |
| |
| |
| pub fn preprocess(&self, query: &str) -> PreprocessResult { |
| let t0 = Instant::now(); |
|
|
| |
| let hashtags = hashtags::extract_hashtags(query); |
| let tag_names = hashtags::extract_tag_names(query); |
| let hashtag_ms = t0.elapsed().as_millis() as u64; |
|
|
| |
| let t1 = Instant::now(); |
| let translation = if self.use_translate { |
| Some(translator::translate_ru_to_en(query, false)) |
| } else { |
| None |
| }; |
| let translate_ms = t1.elapsed().as_millis() as u64; |
|
|
| |
| let effective_query = translation |
| .as_ref() |
| .map(|t| t.text.clone()) |
| .unwrap_or_else(|| query.to_string()); |
|
|
| let lang_tag = translation |
| .as_ref() |
| .map(|t| translator::language_tag(t).to_string()) |
| .unwrap_or_default(); |
|
|
| let translation_clone = translation.clone(); |
|
|
| PreprocessResult { |
| original_query: query.to_string(), |
| effective_query, |
| hashtags, |
| tag_names, |
| translation: translation_clone, |
| lang_tag, |
| timing: PreprocessTiming { |
| hashtag_ms, |
| translate_ms, |
| }, |
| } |
| } |
|
|
| |
| pub fn kb_lookup(&self, pre: &PreprocessResult) -> (KbLookup, u64) { |
| if !self.use_cache { |
| return (KbLookup::Miss, 0); |
| } |
|
|
| let t0 = Instant::now(); |
| let result = self |
| .kb |
| .as_ref() |
| .map(|kb| kb.lookup(&pre.original_query, &pre.effective_query, &pre.hashtags)) |
| .unwrap_or(KbLookup::Miss); |
| let ms = t0.elapsed().as_millis() as u64; |
|
|
| (result, ms) |
| } |
|
|
| |
| pub fn route(&self, pre: &PreprocessResult) -> (String, u64) { |
| let t0 = Instant::now(); |
| |
| let expert = self |
| .router |
| .classify_with_tags(&pre.original_query, &pre.tag_names); |
| let ms = t0.elapsed().as_millis() as u64; |
| (expert, ms) |
| } |
|
|
| |
| pub fn build_system_prompt( |
| &self, |
| expert: &str, |
| pre: &PreprocessResult, |
| kb_result: &KbLookup, |
| ) -> String { |
| let mut system = String::new(); |
|
|
| |
| if let Some(expert_cfg) = self.config.get_expert(expert) { |
| if let Some(sp) = &expert_cfg.system_prompt { |
| system.push_str(sp); |
| } |
| } |
|
|
| |
| if !pre.lang_tag.is_empty() { |
| system.push_str(&format!( |
| " [Note: user's original language is {}. Respond appropriately.]", |
| pre.lang_tag.trim_start_matches('[').trim_end_matches(']') |
| )); |
| } |
|
|
| |
| if let KbLookup::Partial { answer_hint, .. } = kb_result { |
| let truncated: String = answer_hint.chars().take(300).collect(); |
| let ellipsis = if answer_hint.len() > 300 { "..." } else { "" }; |
| system.push_str(&format!( |
| " [Reference answer (adapt and improve, don't copy verbatim): {truncated}{ellipsis}]", |
| )); |
| } |
|
|
| system |
| } |
|
|
| |
| |
| |
| |
| pub fn run( |
| &self, |
| query: &str, |
| engine: &mut Engine, |
| expert_override: Option<&str>, |
| history: &[ConversationTurn], |
| ) -> anyhow::Result<PipelineResult> { |
| let total_start = Instant::now(); |
|
|
| |
| let pre = self.preprocess(query); |
| tracing::info!( |
| "Pipeline: hashtags={:?}, lang={}, translated={}", |
| pre.hashtags, |
| pre.translation |
| .as_ref() |
| .map(|t| t.original_lang.as_str()) |
| .unwrap_or("en"), |
| pre.translation |
| .as_ref() |
| .map(|t| t.was_translated) |
| .unwrap_or(false), |
| ); |
|
|
| |
| let (kb_result, kb_lookup_ms) = self.kb_lookup(&pre); |
|
|
| |
| if let KbLookup::Hit { |
| answer, |
| entry_id, |
| score, |
| } = &kb_result |
| { |
| tracing::info!( |
| "KB cache HIT: {entry_id} (score={score:.2}). Returning cached answer ({} chars).", |
| answer.len() |
| ); |
| let total_ms = total_start.elapsed().as_millis() as u64; |
| return Ok(PipelineResult { |
| response: answer.clone(), |
| expert: "cache".to_string(), |
| hashtags: pre.hashtags, |
| from_cache: true, |
| translation: pre.translation, |
| timing: PipelineTiming { |
| hashtag_ms: pre.timing.hashtag_ms, |
| translate_ms: pre.timing.translate_ms, |
| kb_lookup_ms, |
| routing_ms: 0, |
| generation_ms: 0, |
| total_ms, |
| }, |
| }); |
| } |
|
|
| |
| let (auto_expert, routing_ms) = self.route(&pre); |
| let expert = expert_override |
| .map(|s| s.to_string()) |
| .unwrap_or(auto_expert); |
| tracing::info!("Router → expert: {expert}"); |
|
|
| |
| let system_prompt = self.build_system_prompt(&expert, &pre, &kb_result); |
| let prompt = build_chatml_prompt(&system_prompt, &pre, history); |
|
|
| |
| let t_gen = Instant::now(); |
| let adapter_name: Option<String> = self |
| .config |
| .get_expert(&expert) |
| .and_then(|cfg| cfg.adapter_file.as_ref()) |
| .map(|f| { |
| f.trim_end_matches(".gguf") |
| .trim_end_matches(".safetensors") |
| .to_string() |
| }); |
|
|
| let temperature = self.config.temperature as f32; |
| let top_p = self.config.top_p as f32; |
| let max_tokens = self.config.max_gen_tokens as u32; |
|
|
| let response = engine.generate_with_adapter( |
| &prompt, |
| max_tokens, |
| temperature, |
| top_p, |
| adapter_name.as_deref(), |
| )?; |
| let generation_ms = t_gen.elapsed().as_millis() as u64; |
|
|
| let total_ms = total_start.elapsed().as_millis() as u64; |
|
|
| tracing::info!( |
| "Pipeline complete: hashtag={}µs translate={}µs kb={}µs route={}µs gen={}ms total={}ms", |
| pre.timing.hashtag_ms * 1000, |
| pre.timing.translate_ms * 1000, |
| kb_lookup_ms * 1000, |
| routing_ms * 1000, |
| generation_ms, |
| total_ms, |
| ); |
|
|
| if let KbLookup::Partial { |
| entry_id, score, .. |
| } = &kb_result |
| { |
| tracing::info!("KB partial match: {entry_id} (score={score:.2}) — used as context."); |
| } |
|
|
| Ok(PipelineResult { |
| response, |
| expert, |
| hashtags: pre.hashtags, |
| from_cache: false, |
| translation: pre.translation, |
| timing: PipelineTiming { |
| hashtag_ms: pre.timing.hashtag_ms, |
| translate_ms: pre.timing.translate_ms, |
| kb_lookup_ms, |
| routing_ms, |
| generation_ms, |
| total_ms, |
| }, |
| }) |
| } |
|
|
| |
| pub fn routing_info(&self) -> String { |
| self.router.routing_info() |
| } |
| } |
|
|
| |
| #[derive(Debug, Clone)] |
| pub struct PreprocessResult { |
| pub original_query: String, |
| pub effective_query: String, |
| pub hashtags: Vec<String>, |
| pub tag_names: Vec<String>, |
| pub translation: Option<Translation>, |
| pub lang_tag: String, |
| pub timing: PreprocessTiming, |
| } |
|
|
| #[derive(Debug, Clone, Default)] |
| pub struct PreprocessTiming { |
| pub hashtag_ms: u64, |
| pub translate_ms: u64, |
| } |
|
|
| |
| #[derive(Debug, Clone)] |
| pub struct ConversationTurn { |
| pub user: String, |
| pub assistant: String, |
| } |
|
|
| |
| |
| |
| |
| fn build_chatml_prompt( |
| system: &str, |
| pre: &PreprocessResult, |
| history: &[ConversationTurn], |
| ) -> String { |
| let mut prompt = String::new(); |
|
|
| |
| if !system.is_empty() { |
| prompt.push_str(&format!("<|im_start|>system\n{system}<|im_end|>\n")); |
| } |
|
|
| |
| let max_turns = 6; |
| let start = history.len().saturating_sub(max_turns); |
| for turn in history[start..].iter() { |
| prompt.push_str(&format!("<|im_start|>user\n{}<|im_end|>\n", turn.user)); |
| prompt.push_str(&format!( |
| "<|im_start|>assistant\n{}<|im_end|>\n", |
| turn.assistant |
| )); |
| } |
|
|
| |
| let user_msg = if pre |
| .translation |
| .as_ref() |
| .map(|t| t.was_translated) |
| .unwrap_or(false) |
| { |
| format!( |
| "{} [Tags: {} | Original (ru): {}]", |
| pre.effective_query, |
| pre.hashtags.join(" "), |
| pre.original_query |
| ) |
| } else if !pre.hashtags.is_empty() { |
| format!( |
| "{} [Tags: {}]", |
| pre.effective_query, |
| pre.hashtags.join(" ") |
| ) |
| } else { |
| pre.effective_query.clone() |
| }; |
|
|
| prompt.push_str(&format!("<|im_start|>user\n{user_msg}<|im_end|>\n")); |
| prompt.push_str("<|im_start|>assistant\n"); |
|
|
| prompt |
| } |
|
|