// src/services/paper/reader.rs // // 文献内容读取与章节大纲解析服务。 use regex::Regex; use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use std::path::Path; use std::sync::LazyLock; use tracing; /// 读取模式 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum ReadMode { /// 全文模式。可选择是否同时附带翻译内容 Full { include_translation: bool }, /// 仅提取大纲目录(可指定最大层级,默认 3) Outline { max_level: Option }, /// 按序号提取单个章节(0-based) SectionIndex(usize), /// 按名称模糊匹配提取单个章节 SectionName(String), } /// Markdown 章节结构 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct MarkdownSection { /// 章节序号(0-based) pub index: usize, /// 标题级别(2=##, 3=###, ...) pub level: usize, /// 标题文本(不含 ## 标记) pub heading: String, /// 正文起始行号 pub start_line: usize, /// 下一章节起始行号(或 EOF) pub end_line: usize, /// 正文起始字节偏移量 pub char_start: usize, /// 正文结束字节偏移量 pub char_end: usize, } /// 统一的文献读取响应 #[derive(Debug, Clone, Serialize)] pub struct PaperReadResponse { pub bibcode: String, /// 主文本内容(原文全文、大纲、或者单个章节文本) pub content: String, /// 如果是 Full 模式且 include_translation = true,返回翻译内容全文 pub translation_content: Option, /// 如果是大纲模式,附带结构化的大纲数据,方便 API 转换为 JSON 返回 pub outline: Option>, } /// 统一读取文献内容的业务逻辑函数 pub async fn read_paper_content( db: &SqlitePool, library_dir: &Path, bibcode: &str, mode: ReadMode, ) -> anyhow::Result { // 1. 统一安全与数据库路径校验 let paths = super::db::check_paper_paths_in_db(db, library_dir, bibcode) .await? .ok_or_else(|| { anyhow::anyhow!("文献 {} 未在本地数据库中注册,请先检索该文献。", bibcode) })?; let (_, _, md_opt, tr_opt) = paths; // 2. 检查英文原文相对路径 let md_rel = md_opt.ok_or_else(|| { anyhow::anyhow!("文献 {} 尚未完成解析 (parse)。请先执行解析任务。", bibcode) })?; // 3. 读取英文原文物理文件 let md_abs = library_dir.join(&md_rel); if !md_abs.exists() { anyhow::bail!( "文献 {} 的本地原文文件已丢失,请重新执行解析任务。", bibcode ); } let full_text = tokio::fs::read_to_string(&md_abs).await?; // 4. 初始化响应字段 let mut content = full_text.clone(); let mut translation_content = None; let mut outline = None; // 5. 根据模式处理文本 match &mode { ReadMode::Full { include_translation, } => { if *include_translation { if let Some(tr_rel) = tr_opt { let tr_abs = library_dir.join(&tr_rel); if tr_abs.exists() { match tokio::fs::read_to_string(&tr_abs).await { Ok(tr_text) => translation_content = Some(tr_text), Err(e) => tracing::warn!( "翻译文件存在但读取失败 for {} ({}): {}", bibcode, tr_abs.display(), e ), } } } } } ReadMode::Outline { max_level } => { let level = max_level.unwrap_or(3); let sections = extract_outline(&full_text, level); let formatted = sections .iter() .map(|s| { format!( "{}[{}] {}", " ".repeat(s.level.saturating_sub(1)), s.index, s.heading ) }) .collect::>() .join("\n"); content = formatted; outline = Some(sections); } ReadMode::SectionIndex(idx) => { content = extract_section_by_index(&full_text, *idx) .ok_or_else(|| anyhow::anyhow!("未找到序号为 #{} 的章节,请先检查大纲。", idx))?; } ReadMode::SectionName(name) => { content = extract_section_by_name(&full_text, name).ok_or_else(|| { anyhow::anyhow!("未找到名称匹配 '{}' 的章节,请先检查大纲。", name) })?; } } Ok(PaperReadResponse { bibcode: bibcode.to_string(), content, translation_content, outline, }) } static HEADING_RE: LazyLock = LazyLock::new(|| Regex::new(r"^(#{2,})\s+(.+)$").unwrap()); pub fn extract_outline(content: &str, max_level: usize) -> Vec { let mut sections = Vec::new(); let mut index = 0usize; for (line_num, line) in content.lines().enumerate() { if let Some(caps) = HEADING_RE.captures(line) { let hashes = caps.get(1).unwrap().as_str(); let level = hashes.len(); if level > max_level { continue; } let heading = caps.get(2).unwrap().as_str().trim().to_string(); sections.push(MarkdownSection { index, level, heading, start_line: line_num + 1, end_line: 0, char_start: 0, char_end: 0, }); index += 1; } } for i in 0..sections.len() { let next_start_line = if i + 1 < sections.len() { sections[i + 1].start_line - 1 } else { content.lines().count() }; sections[i].end_line = next_start_line; let (cs, ce) = find_line_byte_range(content, sections[i].start_line, sections[i].end_line); sections[i].char_start = cs; sections[i].char_end = ce; } sections } pub fn extract_section_by_name(content: &str, section_name: &str) -> Option { let lower = section_name.to_lowercase(); let mut found_start: Option = None; let mut found_level: Option = None; let mut found_line: Option = None; for (line_num, line) in content.lines().enumerate() { if let Some(caps) = HEADING_RE.captures(line) { let hashes = caps.get(1).unwrap().as_str(); let level = hashes.len(); let heading = caps.get(2).unwrap().as_str().trim(); if heading.to_lowercase().contains(&lower) { found_start = Some(line_num + 1); found_level = Some(level); found_line = Some(line_num); break; } } } let start_line = found_start?; let section_level = found_level?; let section_line = found_line?; let total_lines = content.lines().count(); let mut end_line = total_lines; for (line_num, line) in content.lines().enumerate() { if line_num <= section_line { continue; } if let Some(caps) = HEADING_RE.captures(line) { let hashes = caps.get(1).unwrap().as_str(); let level = hashes.len(); if level <= section_level { end_line = line_num; break; } } } let (cs, ce) = find_line_byte_range(content, start_line, end_line); let section_content = &content[cs..ce]; Some(section_content.trim().to_string()) } pub fn extract_section_by_index(content: &str, index: usize) -> Option { let sections = extract_outline(content, 10); let section = sections.get(index)?; let section_content = &content[section.char_start..section.char_end]; Some(section_content.trim().to_string()) } fn find_line_byte_range(content: &str, start_line: usize, end_line: usize) -> (usize, usize) { let char_start = content .lines() .take(start_line) .map(|l| l.len() + 1) .sum::(); let char_end = if end_line >= content.lines().count() { content.len() } else { content .lines() .take(end_line) .map(|l| l.len() + 1) .sum::() }; (char_start.min(content.len()), char_end.min(content.len())) } #[cfg(test)] mod tests { use super::*; #[test] fn test_read_mode_serialize() { let mode = ReadMode::Full { include_translation: true, }; let serialized = serde_json::to_string(&mode).unwrap(); assert!(serialized.contains("Full")); assert!(serialized.contains("include_translation")); } #[test] fn test_extract_outline_basic() { let md = "\ # Paper Title ## Introduction This is the intro text. ## Methods ### Data Collection We collected data. ### Analysis We analyzed. ## Results The results are here. "; let sections = extract_outline(md, 2); assert_eq!(sections.len(), 3); assert_eq!(sections[0].heading, "Introduction"); assert_eq!(sections[1].heading, "Methods"); assert_eq!(sections[2].heading, "Results"); } #[test] fn test_extract_outline_max_level_3() { let md = "\ ## Intro text ## Methods ### Data data text ### Analysis analysis text ## Results results text "; let sections = extract_outline(md, 3); assert_eq!(sections.len(), 5); assert_eq!(sections[0].heading, "Intro"); assert_eq!(sections[1].heading, "Methods"); assert_eq!(sections[2].heading, "Data"); assert_eq!(sections[3].heading, "Analysis"); assert_eq!(sections[4].heading, "Results"); } #[test] fn test_extract_section_by_name() { let md = "\ ## Introduction Intro text here. ## Methods Methods text here. ## Results Results text here. "; let content = extract_section_by_name(md, "methods").unwrap(); assert!(content.contains("Methods text here")); assert!(!content.contains("Results text here")); } #[test] fn test_extract_section_by_name_case_insensitive() { let md = "\ ## Introduction Intro. ## DISCUSSION Discussion text. ## Conclusion Conclusion text. "; let content = extract_section_by_name(md, "discussion").unwrap(); assert!(content.contains("Discussion text")); } #[test] fn test_extract_section_by_index() { let md = "\ ## First First content. ## Second Second content. ## Third Third content. "; let content = extract_section_by_index(md, 1).unwrap(); assert!(content.contains("Second content")); } #[test] fn test_extract_last_section() { let md = "\ ## Intro Intro text. ## Conclusion Conclusion text. "; let content = extract_section_by_index(md, 1).unwrap(); assert!(content.contains("Conclusion text")); } #[test] fn test_section_stops_at_same_level() { let md = "\ ## Methods ### Subsection A Subsection text. ### Subsection B More text. ## Results Results text. "; let content = extract_section_by_name(md, "Methods").unwrap(); assert!(content.contains("Subsection text")); assert!(content.contains("More text")); assert!(!content.contains("Results text")); } }