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 子命令
  - 前端:移动端汉堡菜单、侧栏滑出面板、引用星系触屏手势(单指拖拽/双指缩放)
This commit is contained in:
fmq
2026-06-30 19:26:01 +08:00
parent c5fd5b0d66
commit f885c0a4a8
90 changed files with 5184 additions and 3916 deletions
+118 -307
View File
@@ -7,20 +7,10 @@
use async_trait::async_trait;
use serde_json::json;
use sqlx::FromRow;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
/// 引用条目(关联查询结果)
#[derive(Debug, FromRow)]
struct CitationEntry {
bibcode: String,
title: String,
authors: Option<String>,
year: Option<String>,
citation_count: Option<i32>,
}
use crate::services::citation::{get_citations_paginated, search_citations};
pub struct GetCitationNetworkTool;
@@ -117,7 +107,7 @@ impl AgentTool for GetCitationNetworkTool {
let state = &ctx.app_state;
// 验证源文献存在
let paper = match crate::api::helpers::get_paper_from_db(
let paper = match crate::services::paper::get_paper_from_db(
&state.db,
&state.config.library_dir,
&bibcode,
@@ -133,313 +123,134 @@ impl AgentTool for GetCitationNetworkTool {
}
};
// 构建基础查询
let (join_col, filter_col) = match direction {
"citations" => ("source_bibcode", "target_bibcode"),
_ => ("target_bibcode", "source_bibcode"),
};
let order_clause = match sort {
"year" => "p.year DESC",
"citation_count" => "p.citation_count DESC",
_ => "p.bibcode ASC",
};
// 有 query → 引用查找模式:在关联文献中按作者+年份搜索
if let Some(ref q) = query {
let (author_part, year_part) = parse_query(q);
let results = search_citations(
&state.db,
&paper.bibcode,
join_col,
filter_col,
&author_part,
&year_part,
)
.await;
match search_citations(&state.db, &paper.bibcode, direction, q).await {
Ok(results) => {
if results.is_empty() {
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
return ToolOutput::success(
format!(
"{}{}列表中未找到匹配 '{}' 的文献。请尝试调整搜索词(如仅用作者姓氏)。",
paper.bibcode, dir_label, q
),
json!({ "bibcode": paper.bibcode, "query": q, "direction": direction, "count": 0, "results": [] }),
);
}
if results.is_empty() {
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
return ToolOutput::success(
format!(
"{}{}列表中未找到匹配 '{}' 的文献。请尝试调整搜索词(如仅用作者姓氏)。",
paper.bibcode, dir_label, q
),
json!({ "bibcode": paper.bibcode, "query": q, "direction": direction, "count": 0, "results": [] }),
);
}
let content = results
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 作者: {} | 被引: {}",
i + 1,
r.bibcode,
r.title,
r.year.as_deref().unwrap_or(""),
r.authors.join(", "),
r.citation_count,
)
})
.collect::<Vec<_>>()
.join("\n\n");
let display: Vec<serde_json::Value> = results.iter().map(citation_to_json).collect();
let content = display
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 作者: {} | 被引: {}",
i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["authors"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
ToolOutput::success(
format!(
"{} {}列表中找到 {} 篇匹配 '{}' 的文献:\n\n{}",
paper.bibcode,
dir_label,
results.len(),
q,
content
),
json!({
"bibcode": paper.bibcode,
"query": q,
"direction": direction,
"count": results.len(),
"results": results,
"mode": "search"
}),
)
})
.collect::<Vec<_>>()
.join("\n\n");
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
ToolOutput::success(
format!(
"{}{}列表中找到 {} 篇匹配 '{}' 的文献:\n\n{}",
paper.bibcode,
dir_label,
results.len(),
q,
content
),
json!({
"bibcode": paper.bibcode,
"query": q,
"direction": direction,
"count": results.len(),
"results": display,
"mode": "search"
}),
)
}
Err(e) => ToolOutput::error(format!("引文检索失败: {}", e)),
}
} else {
// 无 query → 分页浏览模式
let total: i64 = sqlx::query_scalar(&format!(
"SELECT COUNT(*) FROM citations_references WHERE {} = ?",
filter_col
))
.bind(&paper.bibcode)
.fetch_one(&state.db)
.await
.unwrap_or(0);
let query_sql = format!(
"SELECT p.bibcode, p.title, p.authors, p.year, p.citation_count \
FROM papers p \
JOIN citations_references cr ON p.bibcode = cr.{} \
WHERE cr.{} = ? \
ORDER BY {} \
LIMIT ? OFFSET ?",
join_col, filter_col, order_clause
);
let rows: Vec<CitationEntry> = sqlx::query_as(&query_sql)
.bind(&paper.bibcode)
.bind(limit)
.bind(offset)
.fetch_all(&state.db)
match get_citations_paginated(&state.db, &paper.bibcode, direction, sort, offset, limit)
.await
.unwrap_or_default();
{
Ok((rows, total)) => {
let page_count = rows.len();
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
let sort_label = match sort {
"citation_count" => "按被引次数降序",
"year" => "按年份降序",
_ => "默认顺序",
};
let page_count = rows.len();
let display: Vec<serde_json::Value> = rows.iter().map(citation_to_json).collect();
let dir_label = if direction == "citations" {
"被引"
} else {
"参考文献"
};
let sort_label = match sort {
"citation_count" => "按被引次数降序",
"year" => "按年份降序",
_ => "默认顺序",
};
let content = if rows.is_empty() {
format!("文献 {} 暂无{}记录。", paper.bibcode, dir_label)
} else {
let items = display
.iter()
.enumerate()
.map(|(i, r)| {
let content = if rows.is_empty() {
format!("文献 {} 暂无{}记录。", paper.bibcode, dir_label)
} else {
let items = rows
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 作者: {} | 被引: {}",
offset as usize + i + 1,
r.bibcode,
r.title,
r.year.as_deref().unwrap_or(""),
r.authors.join(", "),
r.citation_count,
)
})
.collect::<Vec<_>>()
.join("\n\n");
format!(
"{}. [{}] {} ({})\n 作者: {} | 被引: {}",
offset as usize + i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["authors"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
"文献 {}{}列表({},共 {} 篇,第 {}-{} 条):\n\n{}",
paper.bibcode,
dir_label,
sort_label,
total,
offset + 1,
offset + page_count as i64,
items,
)
})
.collect::<Vec<_>>()
.join("\n\n");
format!(
"文献 {}{}列表({},共 {} 篇,第 {}-{} 条):\n\n{}",
paper.bibcode,
dir_label,
sort_label,
total,
offset + 1,
offset + page_count as i64,
items,
)
};
};
ToolOutput::success(
content,
json!({
"bibcode": paper.bibcode,
"direction": direction,
"sort": sort,
"total": total,
"offset": offset,
"limit": limit,
"page_count": page_count,
"results": display,
"mode": "browse"
}),
)
ToolOutput::success(
content,
json!({
"bibcode": paper.bibcode,
"direction": direction,
"sort": sort,
"total": total,
"offset": offset,
"limit": limit,
"page_count": page_count,
"results": rows,
"mode": "browse"
}),
)
}
Err(e) => ToolOutput::error(format!("获取引文列表失败: {}", e)),
}
}
}
}
/// 解析查询字符串,提取作者部分和年份部分。
/// "Lei 2023" → ("Lei", Some("2023"))
/// "Zhang et al. 2020" → ("Zhang et al.", Some("2020"))
/// "Smith" → ("Smith", None)
fn parse_query(q: &str) -> (String, Option<String>) {
// 去掉 "et al." 变体中的句号,保留为空格分隔
let cleaned = q.replace("et al.", "et al");
let parts: Vec<&str> = cleaned.split_whitespace().collect();
// 找最后一个看起来像年份的 token(4位数字,19xx 或 20xx
let mut year: Option<String> = None;
let mut author_tokens: Vec<&str> = Vec::new();
for (i, &p) in parts.iter().enumerate().rev() {
if year.is_none()
&& p.len() == 4
&& p.chars().all(|c| c.is_ascii_digit())
&& (p.starts_with("19") || p.starts_with("20"))
{
year = Some(p.to_string());
// 年份之前的是作者,之后的不属于查询
author_tokens = parts[..i].to_vec();
break;
}
}
if year.is_none() {
// 没有找到年份,整个字符串作为作者搜索
author_tokens = parts;
}
let author_part = if author_tokens.is_empty() {
q.to_string()
} else {
author_tokens.join(" ")
};
(author_part, year)
}
/// 在引用关系中搜索匹配的文献
async fn search_citations(
db: &sqlx::SqlitePool,
bibcode: &str,
join_col: &str,
filter_col: &str,
author: &str,
year: &Option<String>,
) -> Vec<CitationEntry> {
let author_pattern = format!("%{}%", author);
if let Some(ref y) = year {
let sql = format!(
"SELECT p.bibcode, p.title, p.authors, p.year, p.citation_count \
FROM papers p \
JOIN citations_references cr ON p.bibcode = cr.{} \
WHERE cr.{} = ? AND p.authors LIKE ? AND p.year = ?",
join_col, filter_col
);
sqlx::query_as::<_, CitationEntry>(&sql)
.bind(bibcode)
.bind(&author_pattern)
.bind(y)
.fetch_all(db)
.await
.unwrap_or_default()
} else {
let sql = format!(
"SELECT p.bibcode, p.title, p.authors, p.year, p.citation_count \
FROM papers p \
JOIN citations_references cr ON p.bibcode = cr.{} \
WHERE cr.{} = ? AND p.authors LIKE ?",
join_col, filter_col
);
sqlx::query_as::<_, CitationEntry>(&sql)
.bind(bibcode)
.bind(&author_pattern)
.fetch_all(db)
.await
.unwrap_or_default()
}
}
fn citation_to_json(e: &CitationEntry) -> serde_json::Value {
let authors: Vec<String> = e
.authors
.as_deref()
.and_then(|a| serde_json::from_str(a).ok())
.unwrap_or_default();
let first_author = authors
.first()
.cloned()
.unwrap_or_else(|| "未知".to_string());
json!({
"bibcode": e.bibcode,
"title": e.title,
"authors": authors,
"first_author": first_author,
"year": e.year,
"citation_count": e.citation_count.unwrap_or(0),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_query_with_year() {
let (author, year) = parse_query("Lei 2023");
assert_eq!(author, "Lei");
assert_eq!(year, Some("2023".into()));
}
#[test]
fn test_parse_query_et_al() {
let (author, year) = parse_query("Zhang et al. 2020");
assert_eq!(author, "Zhang et al");
assert_eq!(year, Some("2020".into()));
}
#[test]
fn test_parse_query_author_only() {
let (author, year) = parse_query("Smith");
assert_eq!(author, "Smith");
assert_eq!(year, None);
}
#[test]
fn test_parse_query_full_name() {
let (author, year) = parse_query("Gaia Collaboration 2018");
assert_eq!(author, "Gaia Collaboration");
assert_eq!(year, Some("2018".into()));
}
}
+6 -2
View File
@@ -54,8 +54,12 @@ impl AgentTool for GetPaperMetadataTool {
info!("[GetPaperMetadata] 获取文献元数据: {}", bibcode);
let state = &ctx.app_state;
match crate::api::helpers::get_paper_from_db(&state.db, &state.config.library_dir, &bibcode)
.await
match crate::services::paper::get_paper_from_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await
{
Ok(paper) => {
let content = format!(
+2 -4
View File
@@ -5,8 +5,7 @@ pub mod citation_network;
pub mod library_search;
pub mod metadata;
pub mod note;
pub mod paper_content;
pub mod paper_outline;
pub mod paper;
pub mod rag;
pub mod target;
@@ -14,7 +13,6 @@ pub use citation_network::GetCitationNetworkTool;
pub use library_search::SearchLocalLibraryTool;
pub use metadata::GetPaperMetadataTool;
pub use note::SaveNoteTool;
pub use paper_content::GetPaperContentTool;
pub use paper_outline::GetPaperOutlineTool;
pub use paper::{GetPaperContentTool, GetPaperOutlineTool};
pub use rag::RagSearchTool;
pub use target::QueryTargetTool;
+4 -31
View File
@@ -57,41 +57,14 @@ impl AgentTool for SaveNoteTool {
info!("[SaveNote] 保存笔记: {}", title);
let state = &ctx.app_state;
// 文件名安全化
let safe_title: String = title
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' {
c
} else {
'_'
}
})
.collect::<String>()
.replace(' ', "_");
let notes_dir = state.config.library_dir.join("notes");
if let Err(e) = std::fs::create_dir_all(&notes_dir) {
return ToolOutput::error(format!("创建笔记目录失败: {}", e));
}
let filename = format!("{}.md", safe_title);
let filepath = notes_dir.join(&filename);
let full_content = format!(
"---\ntitle: {}\ndate: {}\ngenerated_by: AstroResearch Agent\n---\n\n{}",
title,
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
content
);
match std::fs::write(&filepath, &full_content) {
Ok(_) => ToolOutput::success(
match crate::services::note::save_note_service(&state.config.library_dir, &title, &content)
{
Ok((filename, filepath, size)) => ToolOutput::success(
format!("笔记已保存: {}", filename),
json!({
"filename": filename,
"path": filepath.to_string_lossy(),
"size": full_content.len()
"size": size
}),
),
Err(e) => ToolOutput::error(format!("保存笔记失败: {}", e)),
+197
View File
@@ -0,0 +1,197 @@
// src/agent/tools/astro/research/paper_reader_tools.rs
//
// 文献内容读取与大纲提取的智能体工具定义。
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
// ==========================================
// 1. GetPaperOutlineTool (获取文献大纲目录)
// ==========================================
#[derive(Default)]
pub struct GetPaperOutlineTool;
#[async_trait]
impl AgentTool for GetPaperOutlineTool {
fn name(&self) -> &'static str {
"get_paper_outline"
}
fn description(&self) -> &'static str {
"获取文献的章节大纲目录,列出所有章节的序号和标题。\n\
在读取文献内容前,强烈建议先使用此工具了解章节结构,以便按需读取,节约 token。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符(bibcode,如 2006BaltA..15...69W"
}
},
"required": ["bibcode"]
})
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
info!("[GetPaperOutline] 提取大纲: {}", bibcode);
let state = &ctx.app_state;
let mode = crate::services::paper::ReadMode::Outline { max_level: Some(3) };
match crate::services::paper::read_paper_content(
&state.db,
&state.config.library_dir,
&bibcode,
mode,
)
.await
{
Ok(res) => {
let sections = res.outline.unwrap_or_default();
if sections.is_empty() {
return ToolOutput::success(
"该文献未检测到章节标题(## 或 ### 格式)。请直接读取全文。",
json!({ "bibcode": bibcode, "sections": [] }),
);
}
let display_sections: Vec<serde_json::Value> = sections
.iter()
.map(|s| {
json!({
"index": s.index,
"level": s.level,
"heading": s.heading.clone(),
})
})
.collect();
let header = format!("文献 {} 章节大纲(共 {} 节):\n", bibcode, sections.len());
let footer = "\n\n使用 get_paper_content 并传入 section_index 或 section_name 读取指定章节内容。";
ToolOutput::success(
header + &res.content + footer,
json!({
"bibcode": bibcode,
"section_count": sections.len(),
"sections": display_sections
}),
)
}
Err(e) => ToolOutput::error(e.to_string()),
}
}
}
// ==========================================
// 2. GetPaperContentTool (获取文献具体内容)
// ==========================================
#[derive(Default)]
pub struct GetPaperContentTool;
#[async_trait]
impl AgentTool for GetPaperContentTool {
fn name(&self) -> &'static str {
"get_paper_content"
}
fn description(&self) -> &'static str {
"获取文献的具体内容(全文或指定章节内容)。\n\
可传入 section_index 或 section_name 指定仅读取某一章节。\n\
先使用 get_paper_outline 获取章节结构后按需读取,可大幅减少 token 消耗。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符(bibcode,如 2006BaltA..15...69W"
},
"section_index": {
"type": "integer",
"description": "章节序号(0-based)。从 get_paper_outline 返回的 sections[index] 获取。与 section_name 二选一"
},
"section_name": {
"type": "string",
"description": "章节标题文本(支持模糊匹配,如 'introduction')。与 section_index 二选一"
}
},
"required": ["bibcode"]
})
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
let section_index = args
.get("section_index")
.and_then(|s| s.as_u64())
.map(|n| n as usize);
let section_name = args
.get("section_name")
.and_then(|s| s.as_str())
.map(String::from);
info!(
"[GetPaperContent] bibcode={}, section_index={:?}, section_name={:?}",
bibcode, section_index, section_name
);
let state = &ctx.app_state;
let mode = if let Some(idx) = section_index {
crate::services::paper::ReadMode::SectionIndex(idx)
} else if let Some(name) = section_name {
crate::services::paper::ReadMode::SectionName(name)
} else {
crate::services::paper::ReadMode::Full {
include_translation: false,
}
};
let label = match &mode {
crate::services::paper::ReadMode::SectionIndex(idx) => Some(format!("#{}", idx)),
crate::services::paper::ReadMode::SectionName(name) => Some(name.clone()),
_ => None,
};
let is_full = matches!(mode, crate::services::paper::ReadMode::Full { .. });
match crate::services::paper::read_paper_content(
&state.db,
&state.config.library_dir,
&bibcode,
mode,
)
.await
{
Ok(res) => ToolOutput::success(
res.content.clone(),
json!({
"bibcode": bibcode,
"chars": res.content.len(),
"section": label,
"full_text": is_full
}),
),
Err(e) => ToolOutput::error(e.to_string()),
}
}
}
@@ -1,166 +0,0 @@
// src/agent/tools/astro/research/paper_content.rs
// GetPaperContentTool — 文献内容读取(全文 + 按章节)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct GetPaperContentTool;
#[async_trait]
impl AgentTool for GetPaperContentTool {
fn name(&self) -> &str {
"get_paper_content"
}
fn description(&self) -> &str {
"读取已解析文献的 Markdown 内容。默认返回全文;可通过 section_index(序号)或 section_name(标题名)\
指定仅读取某一章节。先使用 get_paper_outline 获取章节结构后按需读取,可大幅减少 token 消耗。\
注意:本工具仅读取本地已解析的文献,不会触发下载或解析。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
},
"section_index": {
"type": "integer",
"description": "章节序号(0-based)。从 get_paper_outline 返回的 sections[index] 获取。与 section_name 二选一"
},
"section_name": {
"type": "string",
"description": "章节标题名称,大小写不敏感,支持模糊匹配。如 'Introduction'、'Results'、'Discussion'。与 section_index 二选一"
}
},
"required": ["bibcode"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
let section_index = args
.get("section_index")
.and_then(|s| s.as_u64())
.map(|n| n as usize);
let section_name = args
.get("section_name")
.and_then(|s| s.as_str())
.map(String::from);
let want_section = section_index.is_some() || section_name.is_some();
info!(
"[GetPaperContent] bibcode={}, section_index={:?}, section_name={:?}",
bibcode, section_index, section_name
);
let state = &ctx.app_state;
let paths = crate::api::helpers::check_paper_paths_in_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await;
let md_opt = match paths {
Ok(Some((_, _, md_opt, _))) => md_opt,
Ok(None) => return ToolOutput::error(
"获取文献内容失败:该文献未在本地数据库中注册,请先使用 search_papers 搜索该文献。",
),
Err(e) => return ToolOutput::error(format!("获取文献内容失败: {}", e)),
};
let md_rel = match md_opt {
Some(rel) => rel,
None => {
return ToolOutput::error(
"获取文献内容失败:该文献尚未完成结构化解析。请使用 process_paper 执行 download 和 parse 任务。",
)
}
};
let md_abs = state.config.library_dir.join(&md_rel);
if !md_abs.exists() {
return ToolOutput::error(
"获取文献内容失败:文献本地 Markdown 文件已丢失,请重新使用 process_paper 执行 parse 任务。",
);
}
let content = match std::fs::read_to_string(&md_abs) {
Ok(c) => c,
Err(e) => {
return ToolOutput::error(format!("获取文献内容失败,读取本地文件错误: {}", e))
}
};
// 按章节读取
if want_section {
let section_content = if let Some(idx) = section_index {
crate::services::section_parser::extract_section_by_index(&content, idx)
} else if let Some(ref name) = section_name {
crate::services::section_parser::extract_section_by_name(&content, name)
} else {
None
};
match section_content {
Some(sec) => {
let label = section_name
.or_else(|| section_index.map(|i| format!("#{}", i)))
.unwrap_or_default();
ToolOutput::success(
sec.clone(),
json!({
"bibcode": bibcode,
"chars": sec.len(),
"section": label,
"full_text": false
}),
)
}
None => {
let hint = if section_name.is_some() {
"请使用 get_paper_outline 查看可用的章节标题,确认章节名称拼写正确。"
} else {
"请使用 get_paper_outline 查看该文献的章节数量。"
};
ToolOutput::error(format!("获取章节内容失败:未找到匹配的章节。{}", hint))
}
}
} else {
// 返回全文(原行为)
ToolOutput::success(
content.clone(),
json!({
"bibcode": bibcode,
"chars": content.len(),
"section": null,
"full_text": true
}),
)
}
}
}
@@ -1,135 +0,0 @@
// src/agent/tools/astro/research/paper_outline.rs
// GetPaperOutlineTool — 提取文献章节大纲(目录)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct GetPaperOutlineTool;
#[async_trait]
impl AgentTool for GetPaperOutlineTool {
fn name(&self) -> &str {
"get_paper_outline"
}
fn description(&self) -> &str {
"提取已解析文献的章节大纲(目录)。返回所有章节标题、层级和序号,供后续按章节读取使用。\
适用于:快速浏览文献结构、定位感兴趣的章节。配合 get_paper_content 的 section_index/section_name 参数使用。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
}
},
"required": ["bibcode"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_readonly(&self) -> bool {
true
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let bibcode = match args.get("bibcode").and_then(|b| b.as_str()) {
Some(b) => b.to_string(),
None => return ToolOutput::error("缺少必需参数 'bibcode'"),
};
info!("[GetPaperOutline] 提取大纲: {}", bibcode);
let state = &ctx.app_state;
let paths =
match crate::api::helpers::check_paper_paths_in_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await
{
Ok(Some((_, _, md_opt, _))) => md_opt,
Ok(None) => return ToolOutput::error(
"获取大纲失败:该文献未在本地数据库中注册。请先使用 search_papers 搜索该文献。",
),
Err(e) => return ToolOutput::error(format!("获取大纲失败: {}", e)),
};
let md_rel = match paths {
Some(rel) => rel,
None => {
return ToolOutput::error(
"获取大纲失败:该文献尚未完成结构化解析。请先使用 process_paper 执行 download 和 parse 任务。",
)
}
};
let md_abs = state.config.library_dir.join(&md_rel);
let content = match std::fs::read_to_string(&md_abs) {
Ok(c) => c,
Err(e) => return ToolOutput::error(format!("获取大纲失败,读取文件错误: {}", e)),
};
let sections = crate::services::section_parser::extract_outline(&content, 3);
if sections.is_empty() {
return ToolOutput::success(
"该文献未检测到章节标题(## 或 ### 格式)。请使用 get_paper_content 直接读取全文。",
json!({ "bibcode": bibcode, "sections": [] }),
);
}
let display_sections: Vec<serde_json::Value> = sections
.iter()
.map(|s| {
json!({
"index": s.index,
"level": s.level,
"heading": s.heading,
})
})
.collect();
let content_display = sections
.iter()
.map(|s| {
let prefix = "#".repeat(s.level);
format!(
"[{}] {} {} ({} 字符)",
s.index,
prefix,
s.heading,
s.char_end - s.char_start
)
})
.collect::<Vec<_>>()
.join("\n");
let header = format!("文献 {} 章节大纲(共 {} 节):\n", bibcode, sections.len());
let footer =
"\n\n使用 get_paper_content 并传入 section_index 或 section_name 读取指定章节内容。";
ToolOutput::success(
header + &content_display + footer,
json!({
"bibcode": bibcode,
"section_count": sections.len(),
"sections": display_sections
}),
)
}
}
+7 -3
View File
@@ -54,9 +54,13 @@ impl AgentTool for QueryTargetTool {
info!("[QueryTarget] 查询天体: {}", object_name);
let state = &ctx.app_state;
let client = reqwest::Client::new();
match crate::services::target::query_target_cached(&state.db, &object_name, None, &client)
.await
match crate::services::target::query_target_cached(
&state.db,
&object_name,
None,
&state.http_client,
)
.await
{
Ok(target) => {
let mut content = String::new();