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