feat: 接入 VizieR 星表检索与 LAMOST/Gaia/SDSS/DESI 跨源光谱下载
新增天文观测数据获取能力,覆盖星表查询与一维光谱下载两大场景:
星表检索(CDS VizieR)
- VizieR TAP 客户端(JSON 优先 + VOTable 降级),共享 IVOA VOTable 解析层
- 业务层支持自由 ADQL、锥形检索、交叉证认、星表发现与 CSV 导出
- ADQL 注入防护(标识符清洗 + 字符串字面量转义),TTL 缓存(7 天)
跨望远镜光谱下载(统一入口)
- 接入 LAMOST(ConeSearch + FITS.gz)、Gaia(TAP + DataLink ZIP)、
SDSS(Data Lab TAP + SAS)、DESI(HEALPix coadd)四源
- 双模式:坐标模式(cone 检索 → 选源 → 下载)/ 标识符模式(直按 ID 下载)
- 光谱文件永久缓存(不可变),按 source+source_id 去重
Agent 与 API
- +4 工具:query_vizier / cone_search / find_spectrum / catalog_operation(22 → 26)
- +6 路由:/catalog/vizier、/cone、/crossmatch、/spectrum/{download,list}
- 前端新增 VizierResultCard / FindSpectrumCard 可视化卡片
工程重构
- services/target.rs (832 行) 拆分为 services/cds/{target,vizier}.rs + clients/cds/sesame.rs,
贯彻 client(通信)/ service(缓存+编排)分层
- ADS 返回字段新增 data(关联数据表 URL),与星表功能联动
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
// src/agent/tools/astro/research/catalog_operation.rs
|
||||
//
|
||||
// CatalogOperationTool —— 星表操作统一工具
|
||||
// 合并 search_catalogs / describe_table / export_table 为一个工具,通过 action 参数分发。
|
||||
// 参照 process_paper 的 tasks 数组模式,但这里用单 action 字符串(一次只做一个操作)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
|
||||
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
|
||||
|
||||
pub struct CatalogOperationTool;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for CatalogOperationTool {
|
||||
fn name(&self) -> &str {
|
||||
"catalog_operation"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"VizieR 星表操作统一工具。通过 action 参数选择操作:\
|
||||
(1) search — 按关键词搜索星表目录,返回表名和描述;\
|
||||
(2) describe — 查看指定表的列结构(字段名、类型、单位);\
|
||||
(3) export — 下载表数据保存为 CSV 文件;\
|
||||
(4) lookup — 通过文献 bibcode 查找关联的 VizieR 数据表。\
|
||||
不确定表名时先用 search,不确定列名时先用 describe。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["search", "describe", "export", "lookup"],
|
||||
"description": "操作类型"
|
||||
},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": "search 时的搜索关键词,如 'Gaia DR3'、'exoplanet'、'LAMOST'"
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "describe/export 时的 VizieR 表名,如 'I/355/gaiadr3'"
|
||||
},
|
||||
"adql": {
|
||||
"type": "string",
|
||||
"description": "export 时的自定义 ADQL 查询(与 table 二选一)"
|
||||
},
|
||||
"columns": {
|
||||
"type": "string",
|
||||
"description": "export + table 模式下指定列(逗号分隔,默认 *)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "search 时的最大返回条数(默认 10),或 export 时的最大行数(默认 100)"
|
||||
},
|
||||
"bibcode": {
|
||||
"type": "string",
|
||||
"description": "lookup 时的 ADS bibcode,如 '2020A&A...638A.102H'"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "export 时的保存路径(可选,默认自动生成)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
fn group(&self) -> &str {
|
||||
"as:research"
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||||
let action = match args.get("action").and_then(|v| v.as_str()) {
|
||||
Some(a) => a,
|
||||
None => return ToolOutput::error("缺少必需参数 'action'"),
|
||||
};
|
||||
|
||||
let state = &ctx.app_state;
|
||||
|
||||
match action {
|
||||
"search" => self.do_search(state, &args).await,
|
||||
"describe" => self.do_describe(state, &args).await,
|
||||
"export" => self.do_export(state, &args).await,
|
||||
"lookup" => self.do_lookup(state, &args).await,
|
||||
other => ToolOutput::error(format!(
|
||||
"未知 action '{}',仅支持: search, describe, export, lookup",
|
||||
other
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CatalogOperationTool {
|
||||
async fn do_search(
|
||||
&self,
|
||||
state: &crate::api::AppState,
|
||||
args: &serde_json::Value,
|
||||
) -> ToolOutput {
|
||||
let keyword = match args.get("keyword").and_then(|v| v.as_str()) {
|
||||
Some(k) => k,
|
||||
None => return ToolOutput::error("search 需要 'keyword' 参数"),
|
||||
};
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(10)
|
||||
.clamp(1, 30) as usize;
|
||||
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
|
||||
let results = match catalog.search(keyword, limit).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return ToolOutput::error(format!("搜索失败: {}", e)),
|
||||
};
|
||||
|
||||
if results.is_empty() {
|
||||
return ToolOutput::success(format!("未找到与 '{}' 匹配的星表", keyword), json!([]));
|
||||
}
|
||||
|
||||
let mut content = format!(
|
||||
"搜索 '{}' 匹配到 {} 个星表(按数据量降序):\n\n",
|
||||
keyword,
|
||||
results.len()
|
||||
);
|
||||
let items: Vec<serde_json::Value> = results
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let nrows_str = entry.nrows.map(|n| format!("{} 行", n)).unwrap_or_else(|| "行数未知".into());
|
||||
content.push_str(&format!("- `{}` — {} ({})\n", entry.table_name, entry.description, nrows_str));
|
||||
json!({"table": entry.table_name, "description": entry.description, "nrows": entry.nrows})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ToolOutput::success(content, json!(items))
|
||||
}
|
||||
|
||||
async fn do_describe(
|
||||
&self,
|
||||
state: &crate::api::AppState,
|
||||
args: &serde_json::Value,
|
||||
) -> ToolOutput {
|
||||
let table = match args.get("table").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
None => return ToolOutput::error("describe 需要 'table' 参数"),
|
||||
};
|
||||
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
|
||||
let columns = match catalog.describe(table).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return ToolOutput::error(format!("查询表结构失败: {}", e)),
|
||||
};
|
||||
|
||||
if columns.is_empty() {
|
||||
return ToolOutput::success(format!("表 '{}' 未找到列定义", table), json!([]));
|
||||
}
|
||||
|
||||
let mut content = format!("表 `{}` 共 {} 列:\n\n", table, columns.len());
|
||||
let items: Vec<serde_json::Value> = columns
|
||||
.iter()
|
||||
.map(|col| {
|
||||
let unit_str = col.unit.as_deref().unwrap_or("");
|
||||
let desc_str = col.description.as_deref().unwrap_or("");
|
||||
content.push_str(&format!("- `{}` ({}) {} — {}\n", col.column_name, col.datatype, unit_str, desc_str));
|
||||
json!({"name": col.column_name, "datatype": col.datatype, "unit": col.unit, "description": col.description})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ToolOutput::success(content, json!(items))
|
||||
}
|
||||
|
||||
async fn do_export(
|
||||
&self,
|
||||
state: &crate::api::AppState,
|
||||
args: &serde_json::Value,
|
||||
) -> ToolOutput {
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(100)
|
||||
.clamp(1, 5000);
|
||||
|
||||
let adql = if let Some(adql) = args.get("adql").and_then(|v| v.as_str()) {
|
||||
adql.to_string()
|
||||
} else if let Some(table) = args.get("table").and_then(|v| v.as_str()) {
|
||||
let columns = args.get("columns").and_then(|v| v.as_str()).unwrap_or("*");
|
||||
let table_ref = if table.contains('/') || table.contains(' ') {
|
||||
format!("\"{}\"", table)
|
||||
} else {
|
||||
table.to_string()
|
||||
};
|
||||
format!("SELECT TOP {} {} FROM {}", limit, columns, table_ref)
|
||||
} else {
|
||||
return ToolOutput::error("export 需要 'adql' 或 'table' 参数");
|
||||
};
|
||||
|
||||
info!(
|
||||
"[CatalogOp:export] ADQL: {}",
|
||||
adql.chars().take(150).collect::<String>()
|
||||
);
|
||||
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
|
||||
let export_result = match catalog.export(&adql, limit).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return ToolOutput::error(format!("查询失败: {}", e)),
|
||||
};
|
||||
|
||||
// 确定保存路径
|
||||
let output_path = if let Some(p) = args.get("output_path").and_then(|v| v.as_str()) {
|
||||
std::path::PathBuf::from(p)
|
||||
} else {
|
||||
let ts = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
std::path::PathBuf::from(format!("vizier_export_{}.csv", ts))
|
||||
};
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
if let Err(e) = tokio::fs::create_dir_all(parent).await {
|
||||
return ToolOutput::error(format!("创建目录失败: {}", e));
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&output_path, &export_result.csv).await {
|
||||
return ToolOutput::error(format!("写入文件失败: {}", e));
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"已导出 {} 行数据到 `{}`\n文件大小: {}",
|
||||
export_result.row_count,
|
||||
output_path.display(),
|
||||
export_result.csv.len(),
|
||||
);
|
||||
|
||||
ToolOutput::success(
|
||||
content,
|
||||
json!({
|
||||
"path": output_path.to_str(),
|
||||
"rows": export_result.row_count,
|
||||
"columns": export_result.column_count,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async fn do_lookup(
|
||||
&self,
|
||||
state: &crate::api::AppState,
|
||||
args: &serde_json::Value,
|
||||
) -> ToolOutput {
|
||||
let bibcode = match args.get("bibcode").and_then(|v| v.as_str()) {
|
||||
Some(b) => b,
|
||||
None => return ToolOutput::error("lookup 需要 'bibcode' 参数"),
|
||||
};
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(10)
|
||||
.clamp(1, 30) as usize;
|
||||
|
||||
let catalog = crate::services::cds::vizier::VizierCatalog::with_ads(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
&state.ads,
|
||||
);
|
||||
let results = match catalog.lookup(bibcode, limit).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return ToolOutput::error(format!("查找失败: {}", e)),
|
||||
};
|
||||
|
||||
if results.is_empty() {
|
||||
return ToolOutput::success(
|
||||
format!("文献 '{}' 未在 CDS/VizieR 中找到关联数据表", bibcode),
|
||||
json!([]),
|
||||
);
|
||||
}
|
||||
|
||||
let mut content = format!(
|
||||
"文献 '{}' 关联 {} 个 VizieR 数据表:\n\n",
|
||||
bibcode,
|
||||
results.len()
|
||||
);
|
||||
let items: Vec<serde_json::Value> = results
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let nrows_str = entry.nrows.map(|n| format!("{} 行", n)).unwrap_or_default();
|
||||
content.push_str(&format!("- `{}` — {} ({})\n", entry.table_name, entry.description, nrows_str));
|
||||
json!({"table": entry.table_name, "description": entry.description, "nrows": entry.nrows})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ToolOutput::success(content, json!(items))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// src/agent/tools/astro/research/find_spectrum.rs
|
||||
//
|
||||
// FindSpectrumTool —— 统一光谱下载工具(跨 LAMOST/Gaia/SDSS)
|
||||
//
|
||||
// 唯一的光谱工具,取代历史的三源分立工具。支持两种模式:
|
||||
// - 坐标模式(默认):给 ra/dec/radius + survey + strategy,自动 cone 检索 → 选源 → 下载
|
||||
// - 标识符模式:给 survey + source_ids(VizieR 交叉证认得到的源标识),直接下载
|
||||
//
|
||||
// 源标识格式:
|
||||
// LAMOST: obsid 数字,如 "438809089"
|
||||
// Gaia: "XP_CONTINUOUS|source_id"(可省略 RT 前缀,默认 XP_CONTINUOUS)
|
||||
// SDSS: "run2d-plate-mjd-fiberid",如 "26-2225-53729-439"
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
|
||||
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
|
||||
use crate::services::spectra::{FindStrategy, SpectrumRequest, SpectrumSurvey};
|
||||
|
||||
pub struct FindSpectrumTool;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for FindSpectrumTool {
|
||||
fn name(&self) -> &str {
|
||||
"find_spectrum"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"下载光谱(跨 LAMOST/Gaia/SDSS)。支持两种模式:\n\
|
||||
(1) 坐标模式(默认):给 ra/dec/radius + survey + strategy,自动 cone 检索并下载;\n\
|
||||
(2) 标识符模式:给 survey + source_ids,直接按源标识下载(跳过检索)。\n\
|
||||
- survey: lamost(低分辨率光学光谱)/ gaia(BP/RP 光谱)/ sdss(SDSS+BOSS+eBOSS 光谱)\n\
|
||||
- strategy: nearest(默认,最近一条)/ all(全部命中)\n\
|
||||
- source_ids(标识符模式)格式:\n\
|
||||
LAMOST=obsid数字 如 '438809089';\n\
|
||||
Gaia='XP_CONTINUOUS|6521...'(可省略RT前缀);\n\
|
||||
SDSS='run2d-plate-mjd-fiberid' 如 '26-2225-53729-439'\n\
|
||||
通过 VizieR 查询星表交叉匹配也能得到各源的标识符,再用标识符模式下载。\n\
|
||||
结果自动缓存,重复查询同一坐标/标识不会重复下载。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"survey": {
|
||||
"type": "string",
|
||||
"enum": ["lamost", "gaia", "sdss", "desi"],
|
||||
"description": "数据源:lamost / gaia / sdss / desi"
|
||||
},
|
||||
"ra": {
|
||||
"type": "number",
|
||||
"description": "赤经 RA(度,J2000/ICRS)。坐标模式必填"
|
||||
},
|
||||
"dec": {
|
||||
"type": "number",
|
||||
"description": "赤纬 Dec(度,J2000/ICRS)。坐标模式必填"
|
||||
},
|
||||
"radius_deg": {
|
||||
"type": "number",
|
||||
"description": "检索半径(度),坐标模式用,默认 0.1,范围 0~5",
|
||||
"default": 0.1
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"enum": ["nearest", "all"],
|
||||
"description": "选源策略(坐标模式):nearest(默认)/ all",
|
||||
"default": "nearest"
|
||||
},
|
||||
"source_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "源标识列表(标识符模式)。提供时切换到标识符模式,忽略 ra/dec/radius/strategy"
|
||||
},
|
||||
"release": {
|
||||
"type": "string",
|
||||
"description": "数据发布版本(可选)。LAMOST: dr5/dr6/.../dr11(默认dr10);Gaia: dr3(默认);SDSS: dr16/dr17/dr18/dr19(默认dr17);DESI: dr1(默认)/edr"
|
||||
},
|
||||
"data_type": {
|
||||
"type": "string",
|
||||
"description": "数据类型(可选)。LAMOST: lrs(低分辨率,默认)/mrs(中分辨率);Gaia: xp_continuous(默认)/xp_sampled/epoch_photometry/rvs;SDSS: spec(光学,默认)/apstar(APOGEE合并星谱)/aspcap(ASPCAP输出)"
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"description": "是否强制重新下载(忽略缓存),默认 false",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["survey"]
|
||||
})
|
||||
}
|
||||
|
||||
fn group(&self) -> &str {
|
||||
"as:research"
|
||||
}
|
||||
|
||||
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_readonly(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> ToolOutput {
|
||||
let state = &ctx.app_state;
|
||||
|
||||
let survey_str = match args.get("survey").and_then(|v| v.as_str()) {
|
||||
Some(s) => s,
|
||||
None => return ToolOutput::error("缺少必需参数 'survey'(lamost/gaia/sdss)"),
|
||||
};
|
||||
let survey = match survey_str.to_lowercase().as_str() {
|
||||
"lamost" => SpectrumSurvey::Lamost,
|
||||
"gaia" => SpectrumSurvey::Gaia,
|
||||
"sdss" => SpectrumSurvey::Sdss,
|
||||
"desi" => SpectrumSurvey::Desi,
|
||||
other => {
|
||||
return ToolOutput::error(format!(
|
||||
"不支持的 survey '{}',可选: lamost / gaia / sdss / desi",
|
||||
other
|
||||
))
|
||||
}
|
||||
};
|
||||
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let release = args
|
||||
.get("release")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let data_type = args
|
||||
.get("data_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 标识符模式 vs 坐标模式
|
||||
let request =
|
||||
if let Some(ids) = args.get("source_ids").and_then(|v| v.as_array()) {
|
||||
let source_ids: Vec<String> = ids
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
if source_ids.is_empty() {
|
||||
return ToolOutput::error("标识符模式下 source_ids 不能为空");
|
||||
}
|
||||
info!(
|
||||
"[FindSpectrum] by_id survey={} count={}",
|
||||
survey.display(),
|
||||
source_ids.len()
|
||||
);
|
||||
SpectrumRequest::ByIdentifier {
|
||||
survey,
|
||||
source_ids,
|
||||
release,
|
||||
data_type,
|
||||
}
|
||||
} else {
|
||||
let ra =
|
||||
match args.get("ra").and_then(|v| v.as_f64()) {
|
||||
Some(v) => v,
|
||||
None => return ToolOutput::error(
|
||||
"坐标模式缺少必需参数 'ra'(赤经,度),或改用 source_ids 标识符模式",
|
||||
),
|
||||
};
|
||||
let dec =
|
||||
match args.get("dec").and_then(|v| v.as_f64()) {
|
||||
Some(v) => v,
|
||||
None => return ToolOutput::error(
|
||||
"坐标模式缺少必需参数 'dec'(赤纬,度),或改用 source_ids 标识符模式",
|
||||
),
|
||||
};
|
||||
let radius = args
|
||||
.get("radius_deg")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.1);
|
||||
let strategy = match args.get("strategy").and_then(|v| v.as_str()) {
|
||||
Some("all") => FindStrategy::All,
|
||||
_ => FindStrategy::Nearest,
|
||||
};
|
||||
info!(
|
||||
"[FindSpectrum] by_coords survey={} ra={} dec={} radius={}° strategy={:?}",
|
||||
survey.display(),
|
||||
ra,
|
||||
dec,
|
||||
radius,
|
||||
strategy
|
||||
);
|
||||
SpectrumRequest::ByCoordinates {
|
||||
survey,
|
||||
ra,
|
||||
dec,
|
||||
radius_deg: radius,
|
||||
strategy,
|
||||
release,
|
||||
data_type,
|
||||
}
|
||||
};
|
||||
|
||||
match crate::services::spectra::download_spectrum(state, &request, force).await {
|
||||
Ok(batch) => {
|
||||
let content = render_batch(&batch);
|
||||
ToolOutput::success(content, json!(batch))
|
||||
}
|
||||
Err(e) => ToolOutput::error(format!("光谱下载失败: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染 DownloadBatch 为可读文本
|
||||
fn render_batch(b: &crate::services::spectra::DownloadBatch) -> String {
|
||||
let mut content = format!("{} 光谱下载", b.survey.display());
|
||||
if let (Some(ra), Some(dec), Some(r)) = (b.ra, b.dec, b.radius_deg) {
|
||||
content.push_str(&format!("(ra={}, dec={}, radius={}°)", ra, dec, r));
|
||||
}
|
||||
content.push_str(&format!(":命中 {} 条\n", b.matched_count));
|
||||
|
||||
if b.downloads.is_empty() && b.failures.is_empty() {
|
||||
content.push_str("(无光谱覆盖或无匹配)\n");
|
||||
return content;
|
||||
}
|
||||
|
||||
for d in &b.downloads {
|
||||
content.push_str(&format!(
|
||||
"\n✓ 已下载({}): {}\n 文件: {}\n URL: {}\n 格式: {},大小: {} 字节\n",
|
||||
if d.cached { "缓存" } else { "新下载" },
|
||||
d.source_label,
|
||||
d.file_path,
|
||||
d.file_url,
|
||||
d.file_format,
|
||||
d.size_bytes,
|
||||
));
|
||||
}
|
||||
for f in &b.failures {
|
||||
content.push_str(&format!("\n✗ 下载失败 {}: {}\n", f.source_label, f.error));
|
||||
}
|
||||
content
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
// src/agent/tools/astro/research/mod.rs
|
||||
// 研究级工具:科研人员消费本地数据进行分析
|
||||
|
||||
pub mod catalog_operation;
|
||||
pub mod citation_network;
|
||||
pub mod find_spectrum;
|
||||
pub mod library_search;
|
||||
pub mod metadata;
|
||||
pub mod note;
|
||||
pub mod paper;
|
||||
pub mod rag;
|
||||
pub mod target;
|
||||
pub mod vizier;
|
||||
|
||||
pub use catalog_operation::CatalogOperationTool;
|
||||
pub use citation_network::GetCitationNetworkTool;
|
||||
pub use find_spectrum::FindSpectrumTool;
|
||||
pub use library_search::SearchLocalLibraryTool;
|
||||
pub use metadata::GetPaperMetadataTool;
|
||||
pub use note::SaveNoteTool;
|
||||
pub use paper::{GetPaperContentTool, GetPaperOutlineTool};
|
||||
pub use rag::RagSearchTool;
|
||||
pub use target::QueryTargetTool;
|
||||
pub use vizier::{ConeSearchTool, QueryVizierTool};
|
||||
|
||||
@@ -54,7 +54,7 @@ impl AgentTool for QueryTargetTool {
|
||||
info!("[QueryTarget] 查询天体: {}", object_name);
|
||||
let state = &ctx.app_state;
|
||||
|
||||
match crate::services::target::query_target_cached(
|
||||
match crate::services::cds::target::query_target_cached(
|
||||
&state.db,
|
||||
&object_name,
|
||||
None,
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// src/agent/tools/astro/research/vizier.rs
|
||||
//
|
||||
// QueryVizierTool —— VizieR TAP 星表查询(自由 ADQL + 便捷表查询)
|
||||
// ConeSearchTool —— 锥形检索(按坐标查近邻天体)
|
||||
//
|
||||
// 对齐 QueryTargetTool 范式:单元结构体 + AgentTool 实现 + ctx.app_state 调 service
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tracing::info;
|
||||
|
||||
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
|
||||
use crate::clients::cds::vizier::VizierQueryResult;
|
||||
|
||||
/// 渲染查询结果为 Markdown 表格(前 N 行)
|
||||
fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> String {
|
||||
let mut content = String::new();
|
||||
|
||||
if let Some(ref table) = result.table_name {
|
||||
content.push_str(&format!("来源表: {}\n", table));
|
||||
}
|
||||
content.push_str(&format!(
|
||||
"共 {} 行({})\n\n",
|
||||
result.row_count,
|
||||
if result.truncated {
|
||||
"已截断,存在更多结果"
|
||||
} else {
|
||||
"完整结果"
|
||||
}
|
||||
));
|
||||
|
||||
if result.fields.is_empty() || result.rows.is_empty() {
|
||||
content.push_str("(无数据行)\n");
|
||||
return content;
|
||||
}
|
||||
|
||||
// 表头
|
||||
let headers: Vec<&str> = result.fields.iter().map(|f| f.name.as_str()).collect();
|
||||
content.push_str(&format!("| {} |\n", headers.join(" | ")));
|
||||
content.push_str(&format!(
|
||||
"| {} |\n",
|
||||
headers
|
||||
.iter()
|
||||
.map(|_| "---")
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
));
|
||||
|
||||
// 表体(限制预览行数)
|
||||
let show = result.rows.len().min(preview_rows);
|
||||
for row in result.rows.iter().take(show) {
|
||||
let cells: Vec<String> = row
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
serde_json::Value::Null => "—".to_string(),
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect();
|
||||
content.push_str(&format!("| {} |\n", cells.join(" | ")));
|
||||
}
|
||||
|
||||
if result.rows.len() > preview_rows {
|
||||
content.push_str(&format!(
|
||||
"\n(已省略 {} 行,完整数据见结构化输出)\n",
|
||||
result.rows.len() - preview_rows
|
||||
));
|
||||
}
|
||||
|
||||
// 列单位提示
|
||||
let units: Vec<&str> = result
|
||||
.fields
|
||||
.iter()
|
||||
.filter_map(|f| f.unit.as_deref())
|
||||
.collect();
|
||||
if !units.is_empty() {
|
||||
content.push_str(&format!("\n字段单位: {}\n", units.join(", ")));
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
// ── QueryVizierTool ──
|
||||
|
||||
pub struct QueryVizierTool;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for QueryVizierTool {
|
||||
fn name(&self) -> &str {
|
||||
"query_vizier"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"通过 VizieR TAP 服务查询天文星表数据,支持两种模式:\
|
||||
(1) 自由 ADQL 查询——传入 'adql' 参数执行标准 ADQL 语句;\
|
||||
(2) 便捷表查询——传入 'table_name' + 可选 'columns' + 'limit' 直接取行。\
|
||||
适用于:获取天体的精确测光/天体测量参数、查询星表中的近邻天体、交叉证认、批量拉取某类样本。\
|
||||
结果自动缓存 7 天。不确定表名时先用 search_catalogs 搜索。\
|
||||
ADQL 语法示例:SELECT TOP 10 ra, dec FROM \"I/355/gaiadr3\" WHERE parallax > 10"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"adql": {
|
||||
"type": "string",
|
||||
"description": "自由 ADQL 查询语句(与 table_name 二选一)。如 SELECT TOP 10 * FROM \"I/355/gaiadr3\""
|
||||
},
|
||||
"table_name": {
|
||||
"type": "string",
|
||||
"description": "VizieR 表名(便捷模式,与 adql 二选一),如 'I/355/gaiadr3'(Gaia DR3)、'J/AJ/165/8/table2'"
|
||||
},
|
||||
"columns": {
|
||||
"type": "string",
|
||||
"description": "需要返回的列名(逗号分隔),为空时返回所有列"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "最大返回行数(默认 20,上限 2000)",
|
||||
"default": 20
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 state = &ctx.app_state;
|
||||
|
||||
// 解析参数:adql 优先,否则走 table_name 便捷模式
|
||||
let result = if let Some(adql) = args.get("adql").and_then(|v| v.as_str()) {
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(20)
|
||||
.clamp(1, 2000);
|
||||
info!(
|
||||
"[QueryVizier] ADQL 查询 (limit={}): {}",
|
||||
limit,
|
||||
adql.chars().take(150).collect::<String>()
|
||||
);
|
||||
crate::services::cds::vizier::query_adql_cached(&state.db, &state.vizier, adql, limit)
|
||||
.await
|
||||
} else if let Some(table) = args.get("table_name").and_then(|v| v.as_str()) {
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(20)
|
||||
.clamp(1, 2000);
|
||||
let columns: Vec<String> = args
|
||||
.get("columns")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|c| c.split(',').map(|s| s.trim().to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
info!("[QueryVizier] 表查询 table={} limit={}", table, limit);
|
||||
crate::services::cds::vizier::query_table(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
table,
|
||||
&columns,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
return ToolOutput::error(
|
||||
"需要提供 'adql'(自由 ADQL)或 'table_name'(便捷表查询)参数之一",
|
||||
);
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let content = render_result_table(&r, 20);
|
||||
ToolOutput::success(content, json!(r))
|
||||
}
|
||||
Err(e) => ToolOutput::error(format!("VizieR 查询失败: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ConeSearchTool ──
|
||||
|
||||
pub struct ConeSearchTool;
|
||||
|
||||
#[async_trait]
|
||||
impl AgentTool for ConeSearchTool {
|
||||
fn name(&self) -> &str {
|
||||
"cone_search"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"锥形检索(Cone Search):按坐标在天文星表中检索近邻天体。\
|
||||
需要指定目标星表(table 参数)。\
|
||||
适用于:给定坐标找附近天体、获取某区域的星表数据、配合 query_target 解析名称后做区域查询。\
|
||||
坐标系统为 J2000(ICRS),单位为度。结果自动缓存 7 天。\
|
||||
不确定表名时先用 search_catalogs 搜索。"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ra": {
|
||||
"type": "number",
|
||||
"description": "赤经 RA(度,J2000/ICRS),范围 0~360"
|
||||
},
|
||||
"dec": {
|
||||
"type": "number",
|
||||
"description": "赤纬 Dec(度,J2000/ICRS),范围 -90~90"
|
||||
},
|
||||
"radius_deg": {
|
||||
"type": "number",
|
||||
"description": "检索半径(度),默认 0.1,范围 0~5",
|
||||
"default": 0.1
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "目标星表(必填),如 'I/355/gaiadr3'(Gaia DR3)、'II/246/out'(2MASS)"
|
||||
},
|
||||
"max_records": {
|
||||
"type": "integer",
|
||||
"description": "最大返回行数(默认 50,上限 2000)",
|
||||
"default": 50
|
||||
}
|
||||
},
|
||||
"required": ["ra", "dec", "table"]
|
||||
})
|
||||
}
|
||||
|
||||
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 state = &ctx.app_state;
|
||||
|
||||
let ra = match args.get("ra").and_then(|v| v.as_f64()) {
|
||||
Some(v) => v,
|
||||
None => return ToolOutput::error("缺少必需参数 'ra'(赤经,度)"),
|
||||
};
|
||||
let dec = match args.get("dec").and_then(|v| v.as_f64()) {
|
||||
Some(v) => v,
|
||||
None => return ToolOutput::error("缺少必需参数 'dec'(赤纬,度)"),
|
||||
};
|
||||
let radius = args
|
||||
.get("radius_deg")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.1);
|
||||
let table = match args.get("table").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
None => return ToolOutput::error("缺少必需参数 'table'(目标星表)"),
|
||||
};
|
||||
let max_records = args
|
||||
.get("max_records")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(50)
|
||||
.clamp(1, 2000);
|
||||
|
||||
info!(
|
||||
"[ConeSearch] ra={} dec={} radius={}° table={}",
|
||||
ra, dec, radius, table
|
||||
);
|
||||
|
||||
match crate::services::cds::vizier::cone_search(
|
||||
&state.db,
|
||||
&state.vizier,
|
||||
ra,
|
||||
dec,
|
||||
radius,
|
||||
table,
|
||||
max_records,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
let mut content = format!(
|
||||
"Cone Search 结果 (中心 ra={}, dec={}, radius={}°)\n\n",
|
||||
ra, dec, radius
|
||||
);
|
||||
content.push_str(&render_result_table(&r, 20));
|
||||
ToolOutput::success(content, json!(r))
|
||||
}
|
||||
Err(e) => ToolOutput::error(format!("Cone Search 失败: {}", e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user