refactor: 全栈架构重构与质量硬化——API 错误统一、工具域重组、安全加固、前端组件化

后端核心变更:
  - API 层: 新增 AppError 枚举统一错误类型,替代散落的 (StatusCode, String)
  - Agent 工具域: 重组为 astro/system/ 和 astro/research/ 两级域,新增 ProcessPaperTool 流水线工具
  - 安全: 新增 SSRF 双层防护 (同步字符串级 + 异步 DNS 解析级),覆盖 IPv4/IPv6 私网段
  - 弱密码检测: 扩展弱密码列表并增加最小长度检查
  - LLM 客户端: 新增 ChatCompleter/Embedder trait,支持依赖注入与批量向量化 embed_batch
  - 批量处理: AssetBatch 从串行改为 Semaphore 并发池 (BATCH_CONCURRENCY=3)
  - 分块器: 重写为三阶段结构化管线 (章节解析→短节合并→带标题路径子块)
  - RAG: embedding 计算移出事务,RetrievalResult 新增 headings/section_index 字段
  - 检索: ADS/arXiv 并行检索 (tokio::join!),去重改用 HashSet,本地库回填批量 IN 查询
  - 天体查询: Sesame API 升级到 v4,新增视差误差/自行/视向速度/多波段测光字段
  - 迁移: 14 个增量文件合并为单一 init.sql,支持 sqlx::migrate! 内存库集成测试
  - 测试: circuit_breaker/hooks/task_board/session/memory/streaming_executor 新增修正 15+ 测试

  前端架构重构:
  - 目录重组: features/ → pages/ + components/ + hooks/ 三层分离
  - App.tsx 从 1181 行压缩至 ~174 行 (逻辑抽入 9 个自定义 Hook)
  - Agent 面板拆分为 AgentSessionSidebar/AgentMessageList/AgentInputArea 子组件
  - 新增 GlobalDialog/PaperDetailModal/UncachedPaperModal 通用对话框组件
  - 工具函数抽取: celestial.ts (天体坐标格式), paper.tsx (文献信息渲染)
This commit is contained in:
fmq
2026-06-25 23:45:37 +08:00
parent b11b8ad015
commit 5db4cc5998
131 changed files with 13934 additions and 7887 deletions
@@ -0,0 +1,445 @@
// src/agent/tools/astro/research/citation_network.rs
// GetCitationNetworkTool — 引用查找与引用网络浏览
//
// 两个核心场景:
// 1. 引用查找:阅读中遇到 "Lei et al. 2023",通过作者+年份匹配定位文献
// 2. 引用网络:分页浏览参考文献/被引列表,支持排序
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>,
}
pub struct GetCitationNetworkTool;
#[async_trait]
impl AgentTool for GetCitationNetworkTool {
fn name(&self) -> &str {
"get_citation_network"
}
fn description(&self) -> &str {
"浏览和查找文献的引用关系。两种用法:\
1. 引用查找——提供 query 参数(如 'Lei 2023'),在参考文献/被引列表中按作者+年份模糊匹配,返回匹配的文献信息;\
2. 引用网络——不提供 query,分页浏览某篇文献的参考文献列表或被引列表。\
默认查参考文献(该文献引用了谁),设置 direction='citations' 可查谁引用了该文献。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "源文献标识符,支持 ADS Bibcode、DOI 或 arXiv ID"
},
"query": {
"type": "string",
"description": "可选的引用查找字符串,如 'Lei 2023'、'Zhang et al. 2020'。通过作者名+年份在引用列表中模糊匹配。不提供时返回完整列表"
},
"direction": {
"type": "string",
"enum": ["references", "citations"],
"description": "查询方向。references: 该文献引用了哪些文献(默认);citations: 哪些文献引用了该文献",
"default": "references"
},
"sort": {
"type": "string",
"enum": ["citation_count", "year", "default"],
"description": "排序方式。citation_count: 按被引次数降序;year: 按年份降序;default: 数据库默认顺序",
"default": "citation_count"
},
"offset": {
"type": "integer",
"description": "分页偏移量(0-based),默认 0",
"default": 0
},
"limit": {
"type": "integer",
"description": "每页返回数量,默认 20,最大 50",
"default": 20
}
},
"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 query = args.get("query").and_then(|q| q.as_str()).map(String::from);
let direction = args
.get("direction")
.and_then(|d| d.as_str())
.unwrap_or("references");
let sort = args
.get("sort")
.and_then(|s| s.as_str())
.unwrap_or("citation_count");
let offset = args.get("offset").and_then(|o| o.as_u64()).unwrap_or(0) as i64;
let limit = args
.get("limit")
.and_then(|l| l.as_u64())
.unwrap_or(20)
.min(50) as i64;
info!(
"[GetCitationNetwork] bibcode={}, query={:?}, direction={}, sort={}, offset={}, limit={}",
bibcode, query, direction, sort, offset, limit
);
let state = &ctx.app_state;
// 验证源文献存在
let paper = match crate::api::helpers::get_paper_from_db(
&state.db,
&state.config.library_dir,
&bibcode,
)
.await
{
Ok(p) => p,
Err(_) => {
return ToolOutput::error(format!(
"文献 {} 未在本地数据库中。请先使用 search_papers 检索该文献。",
bibcode
));
}
};
// 构建基础查询
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;
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 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),
)
})
.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"
}),
)
} 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)
.await
.unwrap_or_default();
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)| {
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),
)
})
.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"
}),
)
}
}
}
/// 解析查询字符串,提取作者部分和年份部分。
/// "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()));
}
}
@@ -0,0 +1,131 @@
// src/agent/tools/astro/research/library_search.rs
// SearchLocalLibraryTool — 本地文献库 FTS5 全文检索
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct SearchLocalLibraryTool;
#[async_trait]
impl AgentTool for SearchLocalLibraryTool {
fn name(&self) -> &str {
"search_local_library"
}
fn description(&self) -> &str {
"在本地文献库中进行全文检索。搜索范围包括标题、作者、关键词、摘要和期刊名。\
使用 BM25 相关性排序,返回匹配度最高的结果。适用于:查找已入库的文献、按关键词或作者浏览本地馆藏。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词。支持多词联合搜索,如 'white dwarf atmosphere'"
},
"limit": {
"type": "integer",
"description": "返回结果数量,默认 10,最大 50",
"default": 10
}
},
"required": ["query"]
})
}
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 query = match args.get("query").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
None => return ToolOutput::error("缺少必需参数 'query'"),
};
let limit = args
.get("limit")
.and_then(|l| l.as_u64())
.unwrap_or(10)
.min(50) as usize;
info!(
"[SearchLocalLibrary] 本地检索: query='{}', limit={}",
query, limit
);
let state = &ctx.app_state;
match crate::services::search::search_local_library(&state.db, &query, limit).await {
Ok(results) => {
if results.is_empty() {
return ToolOutput::success(
"本地文献库中未找到匹配的文献。请尝试调整搜索关键词,或使用 search_papers 从外部检索新文献。",
json!({ "count": 0 }),
);
}
let display: Vec<serde_json::Value> = results
.iter()
.map(|p| {
let first_author = p
.authors
.first()
.cloned()
.unwrap_or_else(|| "未知".to_string());
json!({
"bibcode": p.bibcode,
"title": p.title,
"first_author": first_author,
"year": p.year,
"pub_journal": p.pub_journal,
"citation_count": p.citation_count,
"has_markdown": p.has_markdown,
})
})
.collect();
let content = display
.iter()
.enumerate()
.map(|(i, r)| {
format!(
"{}. [{}] {} ({})\n 第一作者: {} | 期刊: {}\n 被引: {} 次 | 已解析: {}",
i + 1,
r["bibcode"].as_str().unwrap_or(""),
r["title"].as_str().unwrap_or(""),
r["year"].as_str().unwrap_or(""),
r["first_author"].as_str().unwrap_or(""),
r["pub_journal"].as_str().unwrap_or(""),
r["citation_count"].as_i64().unwrap_or(0),
if r["has_markdown"].as_bool().unwrap_or(false) {
""
} else {
""
},
)
})
.collect::<Vec<_>>()
.join("\n\n");
ToolOutput::success(
content,
json!({ "count": results.len(), "papers": display }),
)
}
Err(e) => ToolOutput::error(format!("本地文献检索失败: {}", e)),
}
}
}
@@ -0,0 +1,73 @@
// src/agent/tools/astro/research/metadata.rs
// GetPaperMetadataTool — 查询文献完整元数据
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct GetPaperMetadataTool;
#[async_trait]
impl AgentTool for GetPaperMetadataTool {
fn name(&self) -> &str {
"get_paper_metadata"
}
fn description(&self) -> &str {
"获取指定文献的完整元数据信息(包括完整标题、所有作者、出版期刊、关键字、引用数、完整摘要等)。\
适用于:需要查看某篇文献的详细信息、阅读完整摘要以评估文献相关性。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"bibcode": {
"type": "string",
"description": "文献的唯一标识符,支持 ADS Bibcode(如 '2024ApJ...960..123A')、DOI 或 arXiv ID"
}
},
"required": ["bibcode"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> 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!("[GetPaperMetadata] 获取文献元数据: {}", bibcode);
let state = &ctx.app_state;
match crate::api::helpers::get_paper_from_db(&state.db, &state.config.library_dir, &bibcode)
.await
{
Ok(paper) => {
let content = format!(
"文献元数据 [{}]\n 标题: {}\n 作者: {}\n 年份: {}\n 期刊: {}\n 关键字: {}\n DOI: {}\n arXiv ID: {}\n 引用数: {}\n 参考文献数: {}\n 文献类型: {}\n 已下载: {}\n 已解析为 Markdown: {}\n 摘要:\n{}",
paper.bibcode, paper.title, paper.authors.join(", "), paper.year,
paper.pub_journal, paper.keywords.join(", "), paper.doi, paper.arxiv_id,
paper.citation_count, paper.reference_count, paper.doctype,
paper.is_downloaded, paper.has_markdown, paper.abstract_text
);
ToolOutput::success(content, json!(paper))
}
Err(e) => ToolOutput::error(format!("获取文献 {} 元数据失败: {}", bibcode, e)),
}
}
}
+20
View File
@@ -0,0 +1,20 @@
// src/agent/tools/astro/research/mod.rs
// 研究级工具:科研人员消费本地数据进行分析
pub mod citation_network;
pub mod library_search;
pub mod metadata;
pub mod note;
pub mod paper_content;
pub mod paper_outline;
pub mod rag;
pub mod target;
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 rag::RagSearchTool;
pub use target::QueryTargetTool;
+100
View File
@@ -0,0 +1,100 @@
// src/agent/tools/astro/research/note.rs
// SaveNoteTool — 保存研究笔记
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, InterruptBehavior, ToolContext, ToolOutput};
pub struct SaveNoteTool;
#[async_trait]
impl AgentTool for SaveNoteTool {
fn name(&self) -> &str {
"save_note"
}
fn description(&self) -> &str {
"将研究中间结果或最终结论保存为 Markdown 笔记文件。适用于记录阅读摘要、研究思路、文献分析等。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "笔记标题,将被用于文件名(特殊字符会被过滤)"
},
"content": {
"type": "string",
"description": "笔记正文内容,支持 Markdown 格式(含 LaTeX 数学公式)"
}
},
"required": ["title", "content"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn interrupt_behavior(&self) -> InterruptBehavior {
InterruptBehavior::Block
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let title = match args.get("title").and_then(|t| t.as_str()) {
Some(t) => t.to_string(),
None => return ToolOutput::error("缺少必需参数 'title'"),
};
let content = match args.get("content").and_then(|c| c.as_str()) {
Some(c) => c.to_string(),
None => return ToolOutput::error("缺少必需参数 'content'"),
};
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(
format!("笔记已保存: {}", filename),
json!({
"filename": filename,
"path": filepath.to_string_lossy(),
"size": full_content.len()
}),
),
Err(e) => ToolOutput::error(format!("保存笔记失败: {}", e)),
}
}
}
@@ -0,0 +1,166 @@
// 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
}),
)
}
}
}
@@ -0,0 +1,135 @@
// 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
}),
)
}
}
+121
View File
@@ -0,0 +1,121 @@
// src/agent/tools/astro/research/rag.rs
// RagSearchTool — 混合检索(稠密向量 + 稀疏 BM25,RRF 融合)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{truncate_content, AgentTool, ToolContext, ToolOutput};
pub struct RagSearchTool;
#[async_trait]
impl AgentTool for RagSearchTool {
fn name(&self) -> &str {
"rag_search"
}
fn description(&self) -> &str {
"在向量化文献库中执行混合语义检索。同时利用稠密向量(语义相似)和稀疏 BM25(关键词精确匹配),\
用 RRF(倒数秩融合)算法合并两路结果,兼顾同义表达和精确术语。\
要求文献已完成向量化嵌入(process_paper 的 embed 任务)。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "自然语言问题或搜索词"
},
"top_k": {
"type": "integer",
"description": "返回最相关的段落数量,默认5",
"default": 5
}
},
"required": ["question"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let question = match args.get("question").and_then(|q| q.as_str()) {
Some(q) => q.to_string(),
None => return ToolOutput::error("缺少必需参数 'question'"),
};
let top_k = args.get("top_k").and_then(|k| k.as_u64()).unwrap_or(5) as usize;
info!(
"[RagSearch] 混合检索: question='{}', top_k={}",
question, top_k
);
let state = &ctx.app_state;
match crate::services::rag::retrieve_hybrid(&state.db, &state.embedding, &question, top_k)
.await
{
Ok(chunks) => {
if chunks.is_empty() {
return ToolOutput::success(
"未找到匹配的文献段落。请尝试调整问题表述,或确保文献已完成向量化。",
json!({ "count": 0, "sources": [] }),
);
}
let max_chars = ctx.max_output_chars;
let sources: Vec<serde_json::Value> = chunks
.iter()
.map(|c| {
json!({
"bibcode": c.bibcode,
"paragraph_index": c.paragraph_index,
"headings": c.headings,
"distance": c.distance,
"preview": truncate_content(&c.content, 300)
})
})
.collect();
let content = chunks
.iter()
.enumerate()
.map(|(i, c)| {
let heading_info = if c.headings.is_empty() || c.headings == "Document" {
String::new()
} else {
format!(", 章节: {}", c.headings)
};
format!(
"[片段 {}] 来源: {} (段落 #{}{})\n{}",
i + 1,
c.bibcode,
c.paragraph_index,
heading_info,
truncate_content(&c.content, max_chars / chunks.len().max(1))
)
})
.collect::<Vec<_>>()
.join("\n\n---\n\n");
ToolOutput::success(
content,
json!({ "count": chunks.len(), "sources": sources }),
)
}
Err(e) => ToolOutput::error(format!("混合检索失败: {}", e)),
}
}
}
+104
View File
@@ -0,0 +1,104 @@
// src/agent/tools/astro/research/target.rs
// QueryTargetTool — 天体目标查询 (CDS Sesame)
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
pub struct QueryTargetTool;
#[async_trait]
impl AgentTool for QueryTargetTool {
fn name(&self) -> &str {
"query_target"
}
fn description(&self) -> &str {
"查询天体目标的基本物理参数(坐标 RA/Dec、视星等、光谱类型、视差等)。数据来源为 CDS SIMBAD/Sesame 名称解析服务,结果自动缓存。\
支持常见天体名称,如 'NGC 6752'、'GD 358'、'HD 209458'、'M 31' 等。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"object_name": {
"type": "string",
"description": "天体目标名称,如 'NGC 6752'、'GD 358'、'HD 209458'、'M 31'"
}
},
"required": ["object_name"]
})
}
fn group(&self) -> &str {
"as:research"
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
true
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
let object_name = match args.get("object_name").and_then(|n| n.as_str()) {
Some(n) => n.to_string(),
None => return ToolOutput::error("缺少必需参数 'object_name'"),
};
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
{
Ok(target) => {
let mut content = String::new();
content.push_str(&format!("天体 {} 查询结果:\n", target.target_name));
if let Some(ref ra) = target.ra {
content.push_str(&format!(" 赤经 (RA): {}\n", ra));
}
if let Some(ref dec) = target.dec {
content.push_str(&format!(" 赤纬 (Dec): {}\n", dec));
}
if let Some(p) = target.parallax {
content.push_str(&format!(" 视差: {:.4} mas\n", p));
}
if let Some(ref st) = target.spectral_type {
content.push_str(&format!(" 光谱类型: {}\n", st));
}
if let Some(v) = target.v_magnitude {
content.push_str(&format!(" V 波段星等: {:.3}\n", v));
}
if let Some(ref ot) = target.otype {
content.push_str(&format!(" 天体类型: {}\n", ot));
}
if let Some(ref on) = target.oname {
content.push_str(&format!(" 主要名称: {}\n", on));
}
if !target.aliases.is_empty() {
content.push_str(&format!(" 别名: {}\n", target.aliases.join(", ")));
}
if let Some(ref phot) = target.photometry {
if !phot.is_empty() {
content.push_str(" 多波段测光:\n");
let mut bands: Vec<_> = phot.iter().collect();
bands.sort_by_key(|(k, _)| k.to_string());
for (band, mag) in bands {
content.push_str(&format!(" {}: {:.3}\n", band, mag));
}
}
}
ToolOutput::success(content, json!(target))
}
Err(e) => ToolOutput::error(format!("天体查询失败: {}", e)),
}
}
}