Skip to main content

sz_orm_ai/rag/
mod.rs

1use crate::embedding::EmbeddingModel;
2use crate::error::AiError;
3use crate::vector::VectorStore;
4
5pub struct RagConfig {
6    pub collection_name: String,
7    pub chunk_size: usize,
8    pub chunk_overlap: usize,
9    pub top_k: usize,
10}
11
12impl Default for RagConfig {
13    fn default() -> Self {
14        Self {
15            collection_name: "rag_documents".to_string(),
16            chunk_size: 512,
17            chunk_overlap: 50,
18            top_k: 3,
19        }
20    }
21}
22
23impl RagConfig {
24    pub fn new(collection_name: impl Into<String>) -> Self {
25        Self {
26            collection_name: collection_name.into(),
27            ..Default::default()
28        }
29    }
30
31    pub fn with_chunk_size(mut self, size: usize) -> Self {
32        self.chunk_size = size;
33        self
34    }
35
36    pub fn with_chunk_overlap(mut self, overlap: usize) -> Self {
37        self.chunk_overlap = overlap;
38        self
39    }
40
41    pub fn with_top_k(mut self, k: usize) -> Self {
42        self.top_k = k;
43        self
44    }
45}
46
47#[derive(Debug, Clone)]
48pub struct Document {
49    pub id: String,
50    pub content: String,
51    pub source: Option<String>,
52    pub metadata: std::collections::HashMap<String, serde_json::Value>,
53}
54
55impl Document {
56    pub fn new(id: impl Into<String>, content: impl Into<String>) -> Self {
57        Self {
58            id: id.into(),
59            content: content.into(),
60            source: None,
61            metadata: std::collections::HashMap::new(),
62        }
63    }
64
65    pub fn with_source(mut self, source: impl Into<String>) -> Self {
66        self.source = Some(source.into());
67        self
68    }
69
70    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
71        self.metadata.insert(key.into(), value);
72        self
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct Chunk {
78    pub id: String,
79    pub document_id: String,
80    pub content: String,
81    pub index: usize,
82    pub start_char: usize,
83    pub end_char: usize,
84    pub metadata: std::collections::HashMap<String, serde_json::Value>,
85}
86
87impl Chunk {
88    pub fn new(
89        id: impl Into<String>,
90        document_id: impl Into<String>,
91        content: impl Into<String>,
92        index: usize,
93        start_char: usize,
94        end_char: usize,
95    ) -> Self {
96        Self {
97            id: id.into(),
98            document_id: document_id.into(),
99            content: content.into(),
100            index,
101            start_char,
102            end_char,
103            metadata: std::collections::HashMap::new(),
104        }
105    }
106
107    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
108        self.metadata.insert(key.into(), value);
109        self
110    }
111}
112
113pub struct RagEngine<E, V>
114where
115    E: EmbeddingModel,
116    V: VectorStore,
117{
118    embedding_model: E,
119    vector_store: V,
120    config: RagConfig,
121}
122
123impl<E, V> RagEngine<E, V>
124where
125    E: EmbeddingModel,
126    V: VectorStore,
127{
128    pub fn new(embedding_model: E, vector_store: V, config: RagConfig) -> Self {
129        Self {
130            embedding_model,
131            vector_store,
132            config,
133        }
134    }
135
136    pub fn with_config(mut self, config: RagConfig) -> Self {
137        self.config = config;
138        self
139    }
140
141    pub async fn index_documents(&self, documents: Vec<Document>) -> Result<usize, AiError> {
142        let collection = &self.config.collection_name;
143        let dimension = self.embedding_model.dimension();
144
145        self.vector_store
146            .create_collection(collection, dimension, None)
147            .await
148            .map_err(|e| AiError::Vector(e.to_string()))?;
149
150        let chunks = self.split_documents(documents);
151        let mut total_indexed = 0;
152
153        for chunk in chunks {
154            let vector = self.embedding_model.embed(&chunk.content).await?;
155
156            let record = crate::vector::VectorRecord::new(chunk.id.clone(), vector)
157                .with_metadata(chunk.metadata);
158
159            self.vector_store
160                .insert(collection, vec![record])
161                .await
162                .map_err(|e| AiError::Vector(e.to_string()))?;
163
164            total_indexed += 1;
165        }
166
167        Ok(total_indexed)
168    }
169
170    fn split_documents(&self, documents: Vec<Document>) -> Vec<Chunk> {
171        let mut chunks = Vec::new();
172
173        for document in documents {
174            let content = document.content.as_str();
175            let chars: Vec<char> = content.chars().collect();
176            let chunk_size = self.config.chunk_size;
177            let overlap = self.config.chunk_overlap;
178
179            let mut start = 0;
180            let mut index = 0;
181
182            while start < chars.len() {
183                let end = (start + chunk_size).min(chars.len());
184                let chunk_content: String = chars[start..end].iter().collect();
185
186                if !chunk_content.trim().is_empty() {
187                    let chunk = Chunk::new(
188                        format!("{}_{}", document.id, index),
189                        document.id.clone(),
190                        chunk_content,
191                        index,
192                        start,
193                        end,
194                    )
195                    .with_metadata("source", document.source.clone().unwrap_or_default().into());
196
197                    chunks.push(chunk);
198                }
199
200                if end == chars.len() {
201                    break;
202                }
203
204                start = end - overlap.min(end);
205                index += 1;
206            }
207        }
208
209        chunks
210    }
211
212    pub async fn search(
213        &self,
214        query: &str,
215        filter: Option<&str>,
216    ) -> Result<Vec<RagSearchResult>, AiError> {
217        let query_vector = self.embedding_model.embed(query).await?;
218
219        let results = self
220            .vector_store
221            .search(
222                &self.config.collection_name,
223                &query_vector,
224                self.config.top_k,
225                filter,
226            )
227            .await
228            .map_err(|e| AiError::Vector(e.to_string()))?;
229
230        Ok(results
231            .into_iter()
232            .map(|r| RagSearchResult {
233                id: r.id,
234                score: r.score,
235                content: r.text.unwrap_or_default(),
236                metadata: r.metadata.unwrap_or_default(),
237            })
238            .collect())
239    }
240
241    pub async fn delete_document(&self, document_id: &str) -> Result<u64, AiError> {
242        let count = self
243            .vector_store
244            .count(&self.config.collection_name)
245            .await
246            .map_err(|e| AiError::Vector(e.to_string()))?;
247
248        if count == 0 {
249            return Ok(0);
250        }
251
252        let dimension = self.embedding_model.dimension();
253        let dummy_vector = vec![0.0; dimension];
254
255        let all_results = self
256            .vector_store
257            .search(&self.config.collection_name, &dummy_vector, count, None)
258            .await
259            .map_err(|e| AiError::Vector(e.to_string()))?;
260
261        let to_delete: Vec<String> = all_results
262            .into_iter()
263            .filter(|r| r.id.starts_with(document_id))
264            .map(|r| r.id)
265            .collect();
266
267        if to_delete.is_empty() {
268            return Ok(0);
269        }
270
271        self.vector_store
272            .delete(&self.config.collection_name, to_delete)
273            .await
274            .map_err(|e| AiError::Vector(e.to_string()))
275    }
276}
277
278#[derive(Debug, Clone)]
279pub struct RagSearchResult {
280    pub id: String,
281    pub score: f32,
282    pub content: String,
283    pub metadata: std::collections::HashMap<String, serde_json::Value>,
284}
285
286// ==================== 上下文窗口管理 ====================
287
288/// 上下文截断策略
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
290pub enum TruncationStrategy {
291    /// 保留得分最高的片段(从头截断)
292    #[default]
293    BestFirst,
294    /// 保留最早的片段(按顺序截断)
295    FirstFirst,
296    /// 均匀截断:每个片段截断相同比例
297    Uniform,
298}
299
300impl TruncationStrategy {
301    /// 转换为字符串
302    pub fn as_str(&self) -> &'static str {
303        match self {
304            TruncationStrategy::BestFirst => "best_first",
305            TruncationStrategy::FirstFirst => "first_first",
306            TruncationStrategy::Uniform => "uniform",
307        }
308    }
309}
310
311/// Token 计数策略(简化版)
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
313pub enum TokenCountStrategy {
314    /// 按字符数估算(中文1字≈1.5 token,英文约4字符≈1 token)
315    #[default]
316    CharApprox,
317    /// 按空格分词估算(粗略)
318    WhitespaceSplit,
319    /// 简单字符数 / 4
320    CharDiv4,
321}
322
323/// 上下文窗口配置
324#[derive(Debug, Clone)]
325pub struct ContextWindowConfig {
326    /// 最大 token 数(上下文窗口大小)
327    pub max_tokens: usize,
328    /// 系统提示预留的 token 数
329    pub system_prompt_tokens: usize,
330    /// 查询预留的 token 数
331    pub query_tokens: usize,
332    /// 回答预留的 token 数
333    pub answer_tokens: usize,
334    /// 截断策略
335    pub strategy: TruncationStrategy,
336    /// token 计数策略
337    pub token_strategy: TokenCountStrategy,
338    /// 片段之间的分隔符
339    pub separator: String,
340    /// 是否包含来源信息
341    pub include_source: bool,
342}
343
344impl Default for ContextWindowConfig {
345    fn default() -> Self {
346        Self {
347            max_tokens: 4096,
348            system_prompt_tokens: 500,
349            query_tokens: 200,
350            answer_tokens: 1000,
351            strategy: TruncationStrategy::BestFirst,
352            token_strategy: TokenCountStrategy::CharApprox,
353            separator: "\n\n---\n\n".to_string(),
354            include_source: true,
355        }
356    }
357}
358
359impl ContextWindowConfig {
360    /// 创建新的上下文窗口配置
361    pub fn new(max_tokens: usize) -> Self {
362        Self {
363            max_tokens,
364            ..Default::default()
365        }
366    }
367
368    /// 设置系统提示预留 token 数
369    pub fn with_system_prompt_tokens(mut self, tokens: usize) -> Self {
370        self.system_prompt_tokens = tokens;
371        self
372    }
373
374    /// 设置查询预留 token 数
375    pub fn with_query_tokens(mut self, tokens: usize) -> Self {
376        self.query_tokens = tokens;
377        self
378    }
379
380    /// 设置回答预留 token 数
381    pub fn with_answer_tokens(mut self, tokens: usize) -> Self {
382        self.answer_tokens = tokens;
383        self
384    }
385
386    /// 设置截断策略
387    pub fn with_strategy(mut self, strategy: TruncationStrategy) -> Self {
388        self.strategy = strategy;
389        self
390    }
391
392    /// 设置 token 计数策略
393    pub fn with_token_strategy(mut self, strategy: TokenCountStrategy) -> Self {
394        self.token_strategy = strategy;
395        self
396    }
397
398    /// 设置分隔符
399    pub fn with_separator(mut self, separator: impl Into<String>) -> Self {
400        self.separator = separator.into();
401        self
402    }
403
404    /// 是否包含来源信息
405    pub fn with_source(mut self, include: bool) -> Self {
406        self.include_source = include;
407        self
408    }
409
410    /// 计算可用于上下文的 token 数
411    pub fn available_context_tokens(&self) -> usize {
412        let reserved = self.system_prompt_tokens + self.query_tokens + self.answer_tokens;
413        self.max_tokens.saturating_sub(reserved)
414    }
415}
416
417/// 上下文窗口组装结果
418#[derive(Debug, Clone)]
419pub struct ContextWindowResult {
420    /// 组装后的上下文文本
421    pub context: String,
422    /// 实际使用的 token 数
423    pub used_tokens: usize,
424    /// 包含的片段数量
425    pub included_chunks: usize,
426    /// 被截断的片段数量
427    pub truncated_chunks: usize,
428    /// 被丢弃的片段数量
429    pub dropped_chunks: usize,
430    /// 每个片段的 token 数
431    pub chunk_tokens: Vec<usize>,
432}
433
434impl ContextWindowResult {
435    /// 是否有任何片段被截断
436    pub fn has_truncation(&self) -> bool {
437        self.truncated_chunks > 0
438    }
439
440    /// 是否有任何片段被丢弃
441    pub fn has_drops(&self) -> bool {
442        self.dropped_chunks > 0
443    }
444
445    /// 利用率(已用 token / 可用 token)
446    pub fn utilization(&self, available: usize) -> f32 {
447        if available == 0 {
448            return 0.0;
449        }
450        self.used_tokens as f32 / available as f32
451    }
452}
453
454/// 上下文窗口管理器
455///
456/// 负责将检索到的文档片段组装到 LLM 上下文窗口中,
457/// 根据 token 预算进行截断和丢弃,确保总 token 数不超过限制。
458pub struct ContextWindowManager {
459    /// 配置
460    config: ContextWindowConfig,
461}
462
463impl ContextWindowManager {
464    /// 创建新的上下文窗口管理器
465    pub fn new(config: ContextWindowConfig) -> Self {
466        Self { config }
467    }
468
469    /// 使用默认配置创建
470    pub fn with_defaults() -> Self {
471        Self::new(ContextWindowConfig::default())
472    }
473
474    /// 获取配置引用
475    pub fn config(&self) -> &ContextWindowConfig {
476        &self.config
477    }
478
479    /// 估算文本的 token 数
480    pub fn count_tokens(&self, text: &str) -> usize {
481        match self.config.token_strategy {
482            TokenCountStrategy::CharApprox => {
483                // 简化估算:中文字符按 1.5 token,ASCII 按 4 字符 1 token
484                let chinese_count = text.chars().filter(|c| !c.is_ascii()).count();
485                let ascii_count = text.chars().filter(|c| c.is_ascii()).count();
486                (chinese_count as f32 * 1.5).ceil() as usize + (ascii_count / 4)
487            }
488            TokenCountStrategy::WhitespaceSplit => text.split_whitespace().count(),
489            TokenCountStrategy::CharDiv4 => text.chars().count() / 4,
490        }
491    }
492
493    /// 估算分隔符的 token 数
494    fn separator_tokens(&self) -> usize {
495        self.count_tokens(&self.config.separator)
496    }
497
498    /// 为单个片段添加来源前缀(如果配置启用)
499    fn format_chunk(&self, result: &RagSearchResult) -> String {
500        if self.config.include_source {
501            let source = result
502                .metadata
503                .get("source")
504                .and_then(|v| v.as_str())
505                .unwrap_or("unknown");
506            format!("[source: {}]\n{}", source, result.content)
507        } else {
508            result.content.clone()
509        }
510    }
511
512    /// 组装上下文窗口
513    ///
514    /// 将检索结果按得分排序,根据 token 预算组装上下文。
515    /// # 参数
516    /// - `results`: 检索结果列表(将按 score 降序排序)
517    pub fn assemble(&self, mut results: Vec<RagSearchResult>) -> ContextWindowResult {
518        let available = self.config.available_context_tokens();
519        let separator_tokens = self.separator_tokens();
520
521        // 按得分降序排序(BestFirst 策略)
522        if self.config.strategy == TruncationStrategy::BestFirst {
523            results.sort_by(|a, b| {
524                b.score
525                    .partial_cmp(&a.score)
526                    .unwrap_or(std::cmp::Ordering::Equal)
527            });
528        }
529
530        let total_chunks = results.len();
531        let mut context_parts: Vec<String> = Vec::new();
532        let mut chunk_tokens: Vec<usize> = Vec::new();
533        let mut used_tokens = 0usize;
534        let mut included_chunks = 0usize;
535        let mut truncated_chunks = 0usize;
536
537        for (idx, result) in results.iter().enumerate() {
538            let formatted = self.format_chunk(result);
539            let chunk_token_count = self.count_tokens(&formatted);
540
541            // 计算添加此片段后的总 token 数(包括分隔符)
542            let additional = if idx > 0 {
543                chunk_token_count + separator_tokens
544            } else {
545                chunk_token_count
546            };
547
548            if used_tokens + additional <= available {
549                // 完整放入
550                context_parts.push(formatted.clone());
551                chunk_tokens.push(chunk_token_count);
552                used_tokens += additional;
553                included_chunks += 1;
554            } else {
555                // 尝试截断后放入
556                let remaining = available.saturating_sub(used_tokens);
557                if remaining > separator_tokens + 10 {
558                    // 至少保留 10 token 的内容
559                    let target_content_tokens = remaining.saturating_sub(separator_tokens);
560                    let truncated = self.truncate_text(&formatted, target_content_tokens);
561                    let truncated_tokens = self.count_tokens(&truncated);
562
563                    if truncated_tokens > 0 {
564                        context_parts.push(truncated);
565                        chunk_tokens.push(truncated_tokens);
566                        used_tokens +=
567                            truncated_tokens + if idx > 0 { separator_tokens } else { 0 };
568                        included_chunks += 1;
569                        truncated_chunks += 1;
570                    }
571                    // 截断后通常没有空间了
572                    break;
573                } else {
574                    break;
575                }
576            }
577        }
578
579        let dropped_chunks = total_chunks.saturating_sub(included_chunks);
580        let context = context_parts.join(&self.config.separator);
581
582        ContextWindowResult {
583            context,
584            used_tokens,
585            included_chunks,
586            truncated_chunks,
587            dropped_chunks,
588            chunk_tokens,
589        }
590    }
591
592    /// 按 Uniform 策略组装(每个片段截断相同比例)
593    pub fn assemble_uniform(&self, mut results: Vec<RagSearchResult>) -> ContextWindowResult {
594        if self.config.strategy != TruncationStrategy::Uniform {
595            // 如果配置不是 Uniform,仍然按 Uniform 处理
596        }
597
598        let available = self.config.available_context_tokens();
599        let separator_tokens = self.separator_tokens();
600
601        results.sort_by(|a, b| {
602            b.score
603                .partial_cmp(&a.score)
604                .unwrap_or(std::cmp::Ordering::Equal)
605        });
606
607        let total_chunks = results.len();
608        if total_chunks == 0 {
609            return ContextWindowResult {
610                context: String::new(),
611                used_tokens: 0,
612                included_chunks: 0,
613                truncated_chunks: 0,
614                dropped_chunks: 0,
615                chunk_tokens: Vec::new(),
616            };
617        }
618
619        // 计算所有片段的总 token 数
620        let formatted: Vec<String> = results.iter().map(|r| self.format_chunk(r)).collect();
621        let token_counts: Vec<usize> = formatted.iter().map(|f| self.count_tokens(f)).collect();
622        let total_content_tokens: usize = token_counts.iter().sum();
623        let total_separator_tokens = separator_tokens * (total_chunks.saturating_sub(1));
624        let total_tokens = total_content_tokens + total_separator_tokens;
625
626        if total_tokens <= available {
627            // 全部放入,无需截断
628            let context = formatted.join(&self.config.separator);
629            return ContextWindowResult {
630                context,
631                used_tokens: total_tokens,
632                included_chunks: total_chunks,
633                truncated_chunks: 0,
634                dropped_chunks: 0,
635                chunk_tokens: token_counts,
636            };
637        }
638
639        // 计算每个片段的目标 token 数(均匀分配)
640        let available_per_chunk = available / total_chunks;
641        let mut context_parts: Vec<String> = Vec::new();
642        let mut chunk_tokens_result: Vec<usize> = Vec::new();
643        let mut used_tokens = 0usize;
644        let mut truncated_chunks = 0usize;
645
646        for (idx, formatted_text) in formatted.iter().enumerate() {
647            let target =
648                available_per_chunk.saturating_sub(if idx > 0 { separator_tokens } else { 0 });
649            let original_tokens = token_counts[idx];
650
651            if original_tokens <= target {
652                context_parts.push(formatted_text.clone());
653                chunk_tokens_result.push(original_tokens);
654                used_tokens += original_tokens + if idx > 0 { separator_tokens } else { 0 };
655            } else {
656                let truncated = self.truncate_text(formatted_text, target);
657                let truncated_tokens = self.count_tokens(&truncated);
658                context_parts.push(truncated);
659                chunk_tokens_result.push(truncated_tokens);
660                used_tokens += truncated_tokens + if idx > 0 { separator_tokens } else { 0 };
661                truncated_chunks += 1;
662            }
663        }
664
665        let context = context_parts.join(&self.config.separator);
666        let included_chunks = total_chunks;
667
668        ContextWindowResult {
669            context,
670            used_tokens,
671            included_chunks,
672            truncated_chunks,
673            dropped_chunks: 0,
674            chunk_tokens: chunk_tokens_result,
675        }
676    }
677
678    /// 截断文本到指定 token 数
679    fn truncate_text(&self, text: &str, target_tokens: usize) -> String {
680        if target_tokens == 0 {
681            return String::new();
682        }
683
684        match self.config.token_strategy {
685            TokenCountStrategy::CharApprox => {
686                // 逆向计算:估算需要的字符数
687                let chars: Vec<char> = text.chars().collect();
688                let mut result = String::new();
689                let mut token_count: f64 = 0.0;
690                let target = target_tokens as f64;
691                for ch in chars {
692                    let char_tokens = if ch.is_ascii() { 0.25 } else { 1.5 };
693                    if token_count + char_tokens > target {
694                        break;
695                    }
696                    result.push(ch);
697                    token_count += char_tokens;
698                }
699                if result.len() < text.len() {
700                    result.push_str("...");
701                }
702                result
703            }
704            TokenCountStrategy::WhitespaceSplit => {
705                let words: Vec<&str> = text.split_whitespace().collect();
706                let truncated: Vec<&str> = words.into_iter().take(target_tokens).collect();
707                let mut result = truncated.join(" ");
708                if result.len() < text.len() {
709                    result.push_str("...");
710                }
711                result
712            }
713            TokenCountStrategy::CharDiv4 => {
714                let char_count = target_tokens * 4;
715                let chars: Vec<char> = text.chars().take(char_count).collect();
716                let mut result: String = chars.into_iter().collect();
717                if result.len() < text.len() {
718                    result.push_str("...");
719                }
720                result
721            }
722        }
723    }
724
725    /// 构建 LLM 提示词(系统提示 + 上下文 + 查询)
726    pub fn build_prompt(
727        &self,
728        system_prompt: &str,
729        context_result: &ContextWindowResult,
730        user_query: &str,
731    ) -> String {
732        let mut prompt = String::new();
733        prompt.push_str(system_prompt);
734        prompt.push_str("\n\n=== Context ===\n");
735        prompt.push_str(&context_result.context);
736        prompt.push_str("\n\n=== Question ===\n");
737        prompt.push_str(user_query);
738        prompt
739    }
740
741    /// 估算完整提示词的 token 数
742    pub fn estimate_prompt_tokens(
743        &self,
744        system_prompt: &str,
745        context_result: &ContextWindowResult,
746        user_query: &str,
747    ) -> usize {
748        let system_tokens = self.count_tokens(system_prompt);
749        let query_tokens = self.count_tokens(user_query);
750        system_tokens + context_result.used_tokens + query_tokens
751    }
752}
753
754impl Default for ContextWindowManager {
755    fn default() -> Self {
756        Self::with_defaults()
757    }
758}
759
760// ==================== 单元测试 ====================
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765
766    /// 构造测试用检索结果
767    fn make_result(id: &str, score: f32, content: &str, source: &str) -> RagSearchResult {
768        let mut metadata = std::collections::HashMap::new();
769        metadata.insert("source".to_string(), serde_json::json!(source));
770        RagSearchResult {
771            id: id.to_string(),
772            score,
773            content: content.to_string(),
774            metadata,
775        }
776    }
777
778    // ---- TruncationStrategy 测试 ----
779
780    #[test]
781    fn test_truncation_strategy_default() {
782        assert_eq!(TruncationStrategy::default(), TruncationStrategy::BestFirst);
783    }
784
785    #[test]
786    fn test_truncation_strategy_as_str() {
787        assert_eq!(TruncationStrategy::BestFirst.as_str(), "best_first");
788        assert_eq!(TruncationStrategy::FirstFirst.as_str(), "first_first");
789        assert_eq!(TruncationStrategy::Uniform.as_str(), "uniform");
790    }
791
792    // ---- TokenCountStrategy 测试 ----
793
794    #[test]
795    fn test_token_count_strategy_default() {
796        assert_eq!(
797            TokenCountStrategy::default(),
798            TokenCountStrategy::CharApprox
799        );
800    }
801
802    // ---- ContextWindowConfig 测试 ----
803
804    #[test]
805    fn test_context_window_config_default() {
806        let config = ContextWindowConfig::default();
807        assert_eq!(config.max_tokens, 4096);
808        assert_eq!(config.system_prompt_tokens, 500);
809        assert_eq!(config.query_tokens, 200);
810        assert_eq!(config.answer_tokens, 1000);
811        assert_eq!(config.strategy, TruncationStrategy::BestFirst);
812        assert_eq!(config.token_strategy, TokenCountStrategy::CharApprox);
813        assert!(config.include_source);
814    }
815
816    #[test]
817    fn test_context_window_config_new() {
818        let config = ContextWindowConfig::new(8000);
819        assert_eq!(config.max_tokens, 8000);
820    }
821
822    #[test]
823    fn test_context_window_config_builders() {
824        let config = ContextWindowConfig::new(8000)
825            .with_system_prompt_tokens(600)
826            .with_query_tokens(300)
827            .with_answer_tokens(2000)
828            .with_strategy(TruncationStrategy::Uniform)
829            .with_token_strategy(TokenCountStrategy::WhitespaceSplit)
830            .with_separator("\n")
831            .with_source(false);
832
833        assert_eq!(config.system_prompt_tokens, 600);
834        assert_eq!(config.query_tokens, 300);
835        assert_eq!(config.answer_tokens, 2000);
836        assert_eq!(config.strategy, TruncationStrategy::Uniform);
837        assert_eq!(config.token_strategy, TokenCountStrategy::WhitespaceSplit);
838        assert_eq!(config.separator, "\n");
839        assert!(!config.include_source);
840    }
841
842    #[test]
843    fn test_available_context_tokens() {
844        let config = ContextWindowConfig::new(4096)
845            .with_system_prompt_tokens(500)
846            .with_query_tokens(200)
847            .with_answer_tokens(1000);
848        assert_eq!(config.available_context_tokens(), 2396);
849    }
850
851    #[test]
852    fn test_available_context_tokens_zero_when_reserved_exceeds() {
853        let config = ContextWindowConfig::new(100)
854            .with_system_prompt_tokens(500)
855            .with_query_tokens(200)
856            .with_answer_tokens(1000);
857        assert_eq!(config.available_context_tokens(), 0);
858    }
859
860    // ---- ContextWindowResult 测试 ----
861
862    #[test]
863    fn test_context_window_result_has_truncation() {
864        let result = ContextWindowResult {
865            context: "test".to_string(),
866            used_tokens: 100,
867            included_chunks: 2,
868            truncated_chunks: 1,
869            dropped_chunks: 0,
870            chunk_tokens: vec![50, 50],
871        };
872        assert!(result.has_truncation());
873    }
874
875    #[test]
876    fn test_context_window_result_no_truncation() {
877        let result = ContextWindowResult {
878            context: "test".to_string(),
879            used_tokens: 100,
880            included_chunks: 2,
881            truncated_chunks: 0,
882            dropped_chunks: 0,
883            chunk_tokens: vec![50, 50],
884        };
885        assert!(!result.has_truncation());
886    }
887
888    #[test]
889    fn test_context_window_result_has_drops() {
890        let result = ContextWindowResult {
891            context: "test".to_string(),
892            used_tokens: 100,
893            included_chunks: 1,
894            truncated_chunks: 0,
895            dropped_chunks: 3,
896            chunk_tokens: vec![100],
897        };
898        assert!(result.has_drops());
899    }
900
901    #[test]
902    fn test_context_window_result_utilization() {
903        let result = ContextWindowResult {
904            context: "test".to_string(),
905            used_tokens: 500,
906            included_chunks: 2,
907            truncated_chunks: 0,
908            dropped_chunks: 0,
909            chunk_tokens: vec![250, 250],
910        };
911        assert!((result.utilization(1000) - 0.5).abs() < 1e-6);
912    }
913
914    #[test]
915    fn test_context_window_result_utilization_zero_available() {
916        let result = ContextWindowResult {
917            context: "test".to_string(),
918            used_tokens: 100,
919            included_chunks: 1,
920            truncated_chunks: 0,
921            dropped_chunks: 0,
922            chunk_tokens: vec![100],
923        };
924        assert_eq!(result.utilization(0), 0.0);
925    }
926
927    // ---- ContextWindowManager 测试 ----
928
929    #[test]
930    fn test_context_window_manager_with_defaults() {
931        let manager = ContextWindowManager::with_defaults();
932        assert_eq!(manager.config().max_tokens, 4096);
933    }
934
935    #[test]
936    fn test_context_window_manager_default() {
937        let manager = ContextWindowManager::default();
938        assert_eq!(manager.config().max_tokens, 4096);
939    }
940
941    #[test]
942    fn test_count_tokens_char_approx_english() {
943        let manager = ContextWindowManager::with_defaults();
944        // "hello world" = 11 ASCII chars, 11/4 = 2 tokens
945        let tokens = manager.count_tokens("hello world");
946        assert_eq!(tokens, 2);
947    }
948
949    #[test]
950    fn test_count_tokens_char_approx_chinese() {
951        let manager = ContextWindowManager::with_defaults();
952        // "你好世界" = 4 Chinese chars, 4 * 1.5 = 6 tokens
953        let tokens = manager.count_tokens("你好世界");
954        assert_eq!(tokens, 6);
955    }
956
957    #[test]
958    fn test_count_tokens_whitespace_split() {
959        let config =
960            ContextWindowConfig::default().with_token_strategy(TokenCountStrategy::WhitespaceSplit);
961        let manager = ContextWindowManager::new(config);
962        let tokens = manager.count_tokens("hello world foo bar");
963        assert_eq!(tokens, 4);
964    }
965
966    #[test]
967    fn test_count_tokens_char_div4() {
968        let config =
969            ContextWindowConfig::default().with_token_strategy(TokenCountStrategy::CharDiv4);
970        let manager = ContextWindowManager::new(config);
971        let tokens = manager.count_tokens("hello world!");
972        assert_eq!(tokens, 12 / 4); // 3
973    }
974
975    #[test]
976    fn test_assemble_empty_results() {
977        let manager = ContextWindowManager::with_defaults();
978        let result = manager.assemble(Vec::new());
979        assert!(result.context.is_empty());
980        assert_eq!(result.used_tokens, 0);
981        assert_eq!(result.included_chunks, 0);
982    }
983
984    #[test]
985    fn test_assemble_all_fit() {
986        let config = ContextWindowConfig::new(10000)
987            .with_system_prompt_tokens(0)
988            .with_query_tokens(0)
989            .with_answer_tokens(0)
990            .with_source(false);
991        let manager = ContextWindowManager::new(config);
992
993        let results = vec![
994            make_result("r1", 0.9, "hello world", "doc1"),
995            make_result("r2", 0.8, "foo bar baz", "doc2"),
996        ];
997        let result = manager.assemble(results);
998
999        assert_eq!(result.included_chunks, 2);
1000        assert_eq!(result.truncated_chunks, 0);
1001        assert_eq!(result.dropped_chunks, 0);
1002        assert!(!result.context.is_empty());
1003    }
1004
1005    #[test]
1006    fn test_assemble_best_first_sorts_by_score() {
1007        let config = ContextWindowConfig::new(10000)
1008            .with_system_prompt_tokens(0)
1009            .with_query_tokens(0)
1010            .with_answer_tokens(0)
1011            .with_source(false);
1012        let manager = ContextWindowManager::new(config);
1013
1014        let results = vec![
1015            make_result("r1", 0.5, "low score content", "doc1"),
1016            make_result("r2", 0.9, "high score content", "doc2"),
1017        ];
1018        let result = manager.assemble(results);
1019
1020        // 高分内容应在前面
1021        assert!(result.context.starts_with("high score content"));
1022    }
1023
1024    #[test]
1025    fn test_assemble_drops_when_exceeds_budget() {
1026        let config = ContextWindowConfig::new(100)
1027            .with_system_prompt_tokens(0)
1028            .with_query_tokens(0)
1029            .with_answer_tokens(0)
1030            .with_source(false)
1031            .with_separator("\n");
1032        let manager = ContextWindowManager::new(config);
1033
1034        let results = vec![
1035            make_result("r1", 0.9, &"a".repeat(500), "doc1"),
1036            make_result("r2", 0.8, &"b".repeat(500), "doc2"),
1037        ];
1038        let result = manager.assemble(results);
1039
1040        // 应该只包含部分片段
1041        assert!(result.included_chunks <= 2);
1042        // 预算为 100 token,每个片段 500 ASCII 字符 ≈ 125 token(CharApprox 策略),
1043        // 两个片段总 250 token 远超 100 token 预算,至少有一个片段应被截断或丢弃
1044        assert!(
1045            result.truncated_chunks + result.dropped_chunks >= 1,
1046            "expected at least one truncated or dropped chunk when budget exceeded, got truncated={} dropped={}",
1047            result.truncated_chunks,
1048            result.dropped_chunks
1049        );
1050    }
1051
1052    #[test]
1053    fn test_assemble_truncates_last_chunk() {
1054        let config = ContextWindowConfig::new(100)
1055            .with_system_prompt_tokens(0)
1056            .with_query_tokens(0)
1057            .with_answer_tokens(0)
1058            .with_source(false)
1059            .with_separator("\n");
1060        let manager = ContextWindowManager::new(config);
1061
1062        // 第一个片段小,第二个片段大
1063        let results = vec![
1064            make_result("r1", 0.9, "small", "doc1"),
1065            make_result("r2", 0.8, &"x".repeat(500), "doc2"),
1066        ];
1067        let result = manager.assemble(results);
1068
1069        // 应该截断第二个片段
1070        assert!(result.has_truncation() || result.has_drops());
1071    }
1072
1073    #[test]
1074    fn test_assemble_includes_source_when_configured() {
1075        let config = ContextWindowConfig::new(10000)
1076            .with_system_prompt_tokens(0)
1077            .with_query_tokens(0)
1078            .with_answer_tokens(0)
1079            .with_source(true);
1080        let manager = ContextWindowManager::new(config);
1081
1082        let results = vec![make_result("r1", 0.9, "content here", "mydoc")];
1083        let result = manager.assemble(results);
1084
1085        assert!(result.context.contains("source: mydoc"));
1086    }
1087
1088    #[test]
1089    fn test_assemble_excludes_source_when_disabled() {
1090        let config = ContextWindowConfig::new(10000)
1091            .with_system_prompt_tokens(0)
1092            .with_query_tokens(0)
1093            .with_answer_tokens(0)
1094            .with_source(false);
1095        let manager = ContextWindowManager::new(config);
1096
1097        let results = vec![make_result("r1", 0.9, "content here", "mydoc")];
1098        let result = manager.assemble(results);
1099
1100        assert!(!result.context.contains("source: mydoc"));
1101    }
1102
1103    #[test]
1104    fn test_assemble_uniform_all_fit() {
1105        let config = ContextWindowConfig::new(10000)
1106            .with_system_prompt_tokens(0)
1107            .with_query_tokens(0)
1108            .with_answer_tokens(0)
1109            .with_source(false);
1110        let manager = ContextWindowManager::new(config);
1111
1112        let results = vec![
1113            make_result("r1", 0.9, "hello", "doc1"),
1114            make_result("r2", 0.8, "world", "doc2"),
1115        ];
1116        let result = manager.assemble_uniform(results);
1117
1118        assert_eq!(result.included_chunks, 2);
1119        assert_eq!(result.truncated_chunks, 0);
1120    }
1121
1122    #[test]
1123    fn test_assemble_uniform_truncates_all() {
1124        let config = ContextWindowConfig::new(20)
1125            .with_system_prompt_tokens(0)
1126            .with_query_tokens(0)
1127            .with_answer_tokens(0)
1128            .with_source(false)
1129            .with_separator("");
1130        let manager = ContextWindowManager::new(config);
1131
1132        let results = vec![
1133            make_result("r1", 0.9, &"a".repeat(100), "doc1"),
1134            make_result("r2", 0.8, &"b".repeat(100), "doc2"),
1135        ];
1136        let result = manager.assemble_uniform(results);
1137
1138        // 两个片段都应被截断
1139        assert!(result.truncated_chunks >= 1);
1140        assert_eq!(result.dropped_chunks, 0);
1141    }
1142
1143    #[test]
1144    fn test_build_prompt_structure() {
1145        let manager = ContextWindowManager::with_defaults();
1146        let context_result = ContextWindowResult {
1147            context: "some context".to_string(),
1148            used_tokens: 10,
1149            included_chunks: 1,
1150            truncated_chunks: 0,
1151            dropped_chunks: 0,
1152            chunk_tokens: vec![10],
1153        };
1154        let prompt = manager.build_prompt("You are an assistant.", &context_result, "What is X?");
1155        assert!(prompt.contains("You are an assistant."));
1156        assert!(prompt.contains("=== Context ==="));
1157        assert!(prompt.contains("some context"));
1158        assert!(prompt.contains("=== Question ==="));
1159        assert!(prompt.contains("What is X?"));
1160    }
1161
1162    #[test]
1163    fn test_estimate_prompt_tokens() {
1164        let manager = ContextWindowManager::with_defaults();
1165        let context_result = ContextWindowResult {
1166            context: "hello world".to_string(),
1167            used_tokens: 2,
1168            included_chunks: 1,
1169            truncated_chunks: 0,
1170            dropped_chunks: 0,
1171            chunk_tokens: vec![2],
1172        };
1173        let total = manager.estimate_prompt_tokens("system prompt", &context_result, "query");
1174        assert!(total > 0);
1175    }
1176
1177    #[test]
1178    fn test_assemble_single_chunk_fits() {
1179        let config = ContextWindowConfig::new(10000)
1180            .with_system_prompt_tokens(0)
1181            .with_query_tokens(0)
1182            .with_answer_tokens(0)
1183            .with_source(false);
1184        let manager = ContextWindowManager::new(config);
1185
1186        let results = vec![make_result("r1", 1.0, "single chunk content", "doc1")];
1187        let result = manager.assemble(results);
1188
1189        assert_eq!(result.included_chunks, 1);
1190        assert_eq!(result.dropped_chunks, 0);
1191        assert_eq!(result.truncated_chunks, 0);
1192    }
1193
1194    #[test]
1195    fn test_assemble_uses_separator() {
1196        let config = ContextWindowConfig::new(10000)
1197            .with_system_prompt_tokens(0)
1198            .with_query_tokens(0)
1199            .with_answer_tokens(0)
1200            .with_source(false)
1201            .with_separator("|||");
1202        let manager = ContextWindowManager::new(config);
1203
1204        let results = vec![
1205            make_result("r1", 0.9, "chunk1", "doc1"),
1206            make_result("r2", 0.8, "chunk2", "doc2"),
1207        ];
1208        let result = manager.assemble(results);
1209
1210        assert!(result.context.contains("|||"));
1211    }
1212}