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:
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¬es_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)),
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user