- 服务层拆分:删除 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 子命令
- 前端:移动端汉堡菜单、侧栏滑出面板、引用星系触屏手势(单指拖拽/双指缩放)
414 lines
12 KiB
Rust
414 lines
12 KiB
Rust
// 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"));
|
||
}
|
||
}
|