AstroResearch/src/services/paper/reader.rs
Asfmq f885c0a4a8 refactor: 服务层抽象下沉、异步锁全栈迁移、客户端韧性加固与移动端适配
- 服务层拆分:删除 api/helpers.rs,新增 citation/note/session/pipeline/paper/vision 独立服务模块
  - Agent 工具精简:paper_content+paper_outline 合并为 paper.rs,图片分析逻辑下沉至 services/vision
  - 并发模型升级:std::sync::{Mutex,RwLock} → tokio::sync::{Mutex,RwLock},消除 async
  上下文中的阻塞风险
  - 客户端加固:HTTP 客户端统一超时配置、ADS 429 / arXiv 503 自动重试、构造函数返回 Result
  - 启动安全:全局 panic hook 日志化、空密码拒绝启动、向量表维度不匹配需显式确认
  - CLI 扩展:构建完整 AppState 复用服务层,新增 Content/Outline/Citations/Search/Process 子命令
  - 前端:移动端汉堡菜单、侧栏滑出面板、引用星系触屏手势(单指拖拽/双指缩放)
2026-06-30 19:26:01 +08:00

414 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<usize> },
/// 按序号提取单个章节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<String>,
/// 如果是大纲模式,附带结构化的大纲数据,方便 API 转换为 JSON 返回
pub outline: Option<Vec<MarkdownSection>>,
}
/// 统一读取文献内容的业务逻辑函数
pub async fn read_paper_content(
db: &SqlitePool,
library_dir: &Path,
bibcode: &str,
mode: ReadMode,
) -> anyhow::Result<PaperReadResponse> {
// 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::<Vec<_>>()
.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<Regex> = LazyLock::new(|| Regex::new(r"^(#{2,})\s+(.+)$").unwrap());
pub fn extract_outline(content: &str, max_level: usize) -> Vec<MarkdownSection> {
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<String> {
let lower = section_name.to_lowercase();
let mut found_start: Option<usize> = None;
let mut found_level: Option<usize> = None;
let mut found_line: Option<usize> = 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<String> {
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::<usize>();
let char_end = if end_line >= content.lines().count() {
content.len()
} else {
content
.lines()
.take(end_line)
.map(|l| l.len() + 1)
.sum::<usize>()
};
(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"));
}
}