refactor: 观测层双轴正交重构——spectra→observation、工具/API 收敛、安全韧性加固

将"以光谱为中心"的观测数据架构升级为 (Source × ProductType) 双轴正交模型,
光谱降级为与光变/测光/图像平级的产品类型之一;同步把分散的工具、API、缓存表
收敛为统一入口。新增 Gaia 光变曲线(EPOCH_PHOTOMETRY)支持。

【架构】services/spectra 整体替换为 services/observation(双轴正交)
- Source(LAMOST/Gaia/SDSS/DESI)× ProductType(Spectrum/LightCurve/Photometry/Image)
  正交组合,新增源/产品类型为纯加法(OCP)
- ObservationFetcher trait + ObservationRegistry:每个有效组合实现一个 fetcher,
  启动时注册;SDSS specobj/APOGEE 共用 key 按 subtype 二级路由
- cone 缓存逻辑模板方法化(trait 默认方法),消除各源 4 份重复代码
- 多文件 Artifact 模型:一个逻辑产物可含多文件(如 Gaia 光变 G/BP/RP 三波段各一 FITS)
- 统一编排 dispatch.rs:search(仅检索)/ download(检索+下载),支持坐标模式
  (cone→选源→下载)与标识符模式(直按 ID 下载)双输入

【Agent 工具整合】26 → 24
- 新增 find_observation:跨源×跨产品×双模式统一观测下载,取代 find_spectrum
- catalog_operation 升级为 6 合 1(search/describe/query/cone/export/lookup),
  取代独立的 query_vizier + cone_search
- citation_network + library_search 合并为 library.rs

【API 路由】
- 新增 /observation/{search,download,capabilities,list} 命名空间
- 移除 /catalog/{crossmatch,spectrum/download,spectrum/list}
- GET /observation/capabilities 暴露 registry 能力清单,前端动态渲染源/产品/版本
  下拉(不再硬编码各源支持矩阵)

【数据库迁移】
- 新表 observation_cache:新增 product 列 + artifacts_json(多文件产物),无 TTL
  (观测数据不可变,区别于 vizier_query_cache 的 7 天 TTL)
- 20260705140001:spectrum_cache 旧数据迁入 observation_cache,单文件→单元素 artifacts

【前端】
- 新 ObservationPanel(988 行):双视图(检索下载 / 缓存库),选项由 capabilities 动态生成
- 新 useObservation hook、ObservationResultCard、observation/constants、utils/apiError

【安全与韧性加固】
- sessions 锁 Mutex → RwLock(读多写少,降低争用)
- 新增 upload_rate_limiter;login_rate_limiter 容量保护(10000 上限,超限清最旧一半)
- bookmarklet API 密钥 SHA-1 → SHA-256;ADMIN_PASSWORD 长度上限 128
- *_TIMEOUT_SECS / EMBEDDING_DIM 非法值告警并回退默认;DB_POOL_SIZE 可配置(原硬编码 5)
- sqlite-vec 注册逻辑下沉至 utils::register_sqlite_vec_extension
This commit is contained in:
fmq
2026-07-07 01:34:02 +08:00
parent a156252bc3
commit 2f1fd19d74
71 changed files with 6229 additions and 4198 deletions
@@ -1,300 +0,0 @@
// 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))
}
}
@@ -1,236 +0,0 @@
// src/agent/tools/astro/research/find_spectrum.rs
//
// FindSpectrumTool —— 统一光谱下载工具(跨 LAMOST/Gaia/SDSS
//
// 唯一的光谱工具,取代历史的三源分立工具。支持两种模式:
// - 坐标模式(默认):给 ra/dec/radius + survey + strategy,自动 cone 检索 → 选源 → 下载
// - 标识符模式:给 survey + source_idsVizieR 交叉证认得到的源标识),直接下载
//
// 源标识格式:
// 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(低分辨率光学光谱)/ gaiaBP/RP 光谱)/ sdssSDSS+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/rvsSDSS: 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,16 +1,144 @@
// src/agent/tools/astro/research/citation_network.rs
// GetCitationNetworkTool — 引用查找与引用网络浏览
// src/agent/tools/astro/research/library.rs
//
// 两个核心场景:
// 1. 引用查找:阅读中遇到 "Lei et al. 2023",通过作者+年份匹配定位文献
// 2. 引用网络:分页浏览参考文献/被引列表,支持排序
// SearchLocalLibraryTool — 本地文献库 FTS5 全文检索
// GetCitationNetworkTool — 引用查找与引用网络浏览
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
use crate::services::citation::{get_citations_paginated, search_citations};
// ── SearchLocalLibraryTool ──
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)),
}
}
}
// ── GetCitationNetworkTool ──
//
// 引用查找与引用网络浏览:
// 1. 引用查找:阅读中遇到 "Lei et al. 2023",通过作者+年份匹配定位文献
// 2. 引用网络:分页浏览参考文献/被引列表,支持排序
pub struct GetCitationNetworkTool;
@@ -125,7 +253,7 @@ impl AgentTool for GetCitationNetworkTool {
// 有 query → 引用查找模式:在关联文献中按作者+年份搜索
if let Some(ref q) = query {
match search_citations(&state.db, &paper.bibcode, direction, q).await {
match crate::services::citation::search_citations(&state.db, &paper.bibcode, direction, q).await {
Ok(results) => {
if results.is_empty() {
let dir_label = if direction == "citations" {
@@ -187,7 +315,7 @@ impl AgentTool for GetCitationNetworkTool {
}
} else {
// 无 query → 分页浏览模式
match get_citations_paginated(&state.db, &paper.bibcode, direction, sort, offset, limit)
match crate::services::citation::get_citations_paginated(&state.db, &paper.bibcode, direction, sort, offset, limit)
.await
{
Ok((rows, total)) => {
@@ -1,131 +0,0 @@
// 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)),
}
}
}
+5 -9
View File
@@ -1,24 +1,20 @@
// 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 library;
pub mod metadata;
pub mod note;
pub mod observation;
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 library::{GetCitationNetworkTool, SearchLocalLibraryTool};
pub use observation::FindObservationTool;
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};
pub use vizier::CatalogOperationTool;
@@ -0,0 +1,259 @@
// src/agent/tools/astro/research/observation.rs
//
// FindObservationTool —— 统一观测数据下载工具
//
// 跨 (LAMOST/Gaia/SDSS/DESI) × (Spectrum/LightCurve/Photometry/Image) 双轴:
// - 坐标模式(默认):给 ra/dec/radius + source + product + strategy,自动 cone 检索 → 选源 → 下载
// - 标识符模式:给 source + product + source_ids,直接按标识下载(跳过检索)
//
// 源 × 产品支持矩阵(registry 注册决定,工具运行时按需校验):
// Spectrum LightCurve Photometry
// LAMOST lrs/mrs - -
// Gaia xp_continuous epoch_photometry -
// xp_sampled
// rvs
// SDSS spec/apstar - -
// aspcap
// DESI coadd - -
//
// 源标识格式:
// LAMOST spectrum: obsid 数字,如 "438809089"
// Gaia spectrum: "XP_CONTINUOUS|source_id"(可省略 RT 前缀,默认 XP_CONTINUOUS
// Gaia lightcurve(epoch_photometry): source_id
// SDSS spectrum(spec): "run2d-plate-mjd-fiberid",如 "26-2225-53729-439"
// SDSS spectrum(apstar/aspcap): "telescope|field|apogee_id"
// DESI spectrum: "survey-program-healpix",如 "main-dark-10050"
//
// 结果自动缓存(observation_cache,按 source+product+source_id 去重),重复查询不会重复下载。
// Gaia 光变(EPOCH_PHOTOMETRY) 返回 G/BP/RP 三波段各一个文件(多 artifact)。
use async_trait::async_trait;
use serde_json::json;
use tracing::info;
use crate::agent::tools::{AgentTool, ToolContext, ToolOutput};
use crate::services::observation::{
download_observation, FindStrategy, ObservationRequest, ProductSpec, ProductType, Source,
};
pub struct FindObservationTool;
#[async_trait]
impl AgentTool for FindObservationTool {
fn name(&self) -> &str {
"find_observation"
}
fn description(&self) -> &str {
"下载天文观测数据(LAMOST/Gaia/SDSS/DESI 的光谱/光变/测光/图像)。\n\
坐标模式:给 ra/dec + source + product,自动 cone 检索并下载最近(或全部)命中源。\n\
标识符模式:给 source + product + source_ids,直接按源标识下载。\n\
结果自动缓存,重复查询不重复下载。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["lamost", "gaia", "sdss", "desi"],
"description": "数据源"
},
"product": {
"type": "string",
"enum": ["spectrum", "lightcurve", "photometry", "image"],
"description": "产品类型,默认 spectrum",
"default": "spectrum"
},
"subtype": {
"type": "string",
"description": "产品子类型(可选)。LAMOST spectrum: lrs/mrsGaia spectrum: xp_continuous/xp_sampled/rvsGaia lightcurve: epoch_photometrySDSS spectrum: spec/apstar/aspcapDESI spectrum: coadd"
},
"ra": {
"type": "number",
"description": "赤经 RA(度,J2000/ICRS)。坐标模式必填"
},
"dec": {
"type": "number",
"description": "赤纬 Dec(度,J2000/ICRS)。坐标模式必填"
},
"radius_deg": {
"type": "number",
"description": "检索半径(度),坐标模式用,默认 0.1。各源无硬性 API 上限,但建议值:LAMOST≤5°,Gaia/SDSS/DESI≤1°(主表很大,超出易超时)。硬性上限 30°,超出报错。可通过 GET /api/observation/capabilities 查询各源建议值",
"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": "数据发布版本(可选,留空用各源默认)。可选值与默认值可通过 GET /api/observation/capabilities 查询;LAMOST: dr5..dr11(默认dr10MRS 需 dr7+);Gaia: dr3SDSS spec: dr16/dr17(默认dr17);SDSS apstar/aspcap: dr17DESI: edr/dr1(默认dr1"
},
"force": {
"type": "boolean",
"description": "是否强制重新下载(忽略缓存),默认 false",
"default": false
}
},
"required": ["source"]
})
}
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 source = match args.get("source").and_then(|v| v.as_str()) {
Some(s) => match s.to_lowercase().as_str() {
"lamost" => Source::Lamost,
"gaia" => Source::Gaia,
"sdss" => Source::Sdss,
"desi" => Source::Desi,
other => return ToolOutput::error(format!(
"不支持的 source '{}',可选: {:?}", other, Source::valid_values()
)),
},
None => return ToolOutput::error("缺少必需参数 'source'lamost/gaia/sdss/desi"),
};
let product_type = match args.get("product").and_then(|v| v.as_str()) {
None | Some("spectrum") => ProductType::Spectrum,
Some("lightcurve") | Some("light_curve") | Some("lc") => ProductType::LightCurve,
Some("photometry") => ProductType::Photometry,
Some("image") => ProductType::Image,
Some(other) => return ToolOutput::error(format!(
"不支持的 product '{}',可选: {:?}", other, ProductType::valid_values()
)),
};
let subtype = args.get("subtype")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let product = ProductSpec { product: product_type, subtype };
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());
// 标识符模式 vs 坐标模式
let request = if let Some(ids) = args.get("source_ids").and_then(|v| v.as_array()) {
let identifiers: Vec<String> = ids.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if identifiers.is_empty() {
return ToolOutput::error("标识符模式下 source_ids 不能为空");
}
info!(
"[FindObservation] by_id source={:?} product={:?} count={}",
source, product.product, identifiers.len()
);
ObservationRequest::ByIdentifier { source, product, identifiers, release }
} 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!(
"[FindObservation] by_coords source={:?} product={:?} ra={} dec={} radius={}° strategy={:?}",
source, product.product, ra, dec, radius, strategy
);
ObservationRequest::ByCoordinates {
source, product, ra, dec, radius_deg: radius, strategy, release,
}
};
match download_observation(state, &state.observation_registry, &request, force).await {
Ok(batch) => {
let content = render_batch(&batch);
ToolOutput::success(content, json!(batch))
}
Err(e) => ToolOutput::error(format!("观测数据下载失败: {}", e)),
}
}
}
/// 渲染 ObservationBatch 为可读文本(支持多 artifact 展示)
fn render_batch(b: &crate::services::observation::ObservationBatch) -> String {
let mut content = format!("{} {} 下载", b.source.display(), b.product.product.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));
}
if let Some(st) = &b.product.subtype {
content.push_str(&format!(" [{}]", st));
}
content.push_str(&format!(":命中 {}\n", b.matched_count));
if b.products.is_empty() && b.failures.is_empty() {
content.push_str("(无观测数据覆盖或无匹配)\n");
return content;
}
for p in &b.products {
// 多 artifact 展示(如 Gaia 光变 G/BP/RP 三波段)
let artifact_summary: Vec<String> = p.artifacts.iter().map(|a| {
match &a.band {
Some(band) => format!("{}波段 {} ({})",
band, a.file_format.to_uppercase(), format_size(a.size_bytes)),
None => format!("{} ({})",
a.file_format.to_uppercase(), format_size(a.size_bytes)),
}
}).collect();
content.push_str(&format!(
"\n✓ 已下载({}: {} —— {}\n",
if p.artifacts.iter().all(|a| a.cached) { "缓存" } else { "新下载" }
.to_string(),
p.source_label,
artifact_summary.join(", ")
));
for a in &p.artifacts {
content.push_str(&format!(
" {}{}\n",
a.file_path,
a.band.as_ref().map(|b| format!(" [{}]", b)).unwrap_or_default()
));
}
}
for f in &b.failures {
content.push_str(&format!("\n✗ 下载失败 {}: {}\n", f.source_label, f.error));
}
content
}
fn format_size(bytes: usize) -> String {
if bytes > 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / 1024.0 / 1024.0)
} else if bytes > 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
+339 -185
View File
@@ -1,9 +1,12 @@
// src/agent/tools/astro/research/vizier.rs
//
// QueryVizierTool —— VizieR TAP 星表查询(自由 ADQL + 便捷表查询)
// ConeSearchTool —— 锥形检索(按坐标查近邻天体)
//
// 对齐 QueryTargetTool 范式:单元结构体 + AgentTool 实现 + ctx.app_state 调 service
// CatalogOperationTool —— VizieR 星表统一操作工具
// search: 按关键词搜索星表目录
// describe: 查看表的列结构
// query: 执行 ADQL 或按表名查询,返回数据
// cone: 按坐标锥形检索
// export: 导出为 CSV 文件
// lookup: 通过 bibcode 查找关联数据表
use async_trait::async_trait;
use serde_json::json;
@@ -34,7 +37,6 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
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!(
@@ -46,7 +48,6 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
.join(" | ")
));
// 表体(限制预览行数)
let show = result.rows.len().min(preview_rows);
for row in result.rows.iter().take(show) {
let cells: Vec<String> = row
@@ -67,7 +68,6 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
));
}
// 列单位提示
let units: Vec<&str> = result
.fields
.iter()
@@ -80,161 +80,73 @@ fn render_result_table(result: &VizierQueryResult, preview_rows: usize) -> Strin
content
}
// ── QueryVizierTool ──
// ── CatalogOperationTool ──
pub struct QueryVizierTool;
pub struct CatalogOperationTool;
#[async_trait]
impl AgentTool for QueryVizierTool {
impl AgentTool for CatalogOperationTool {
fn name(&self) -> &str {
"query_vizier"
"catalog_operation"
}
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"
"VizieR 星表统一操作。通过 action 选择:\n\
search — 按关键词搜索星表目录;\n\
describe — 查看表的列结构;\n\
query — 执行 ADQL 或按表名查询数据;\n\
cone — 按坐标锥形检索近邻天体;\n\
export — 下载表数据保存为 CSV;\n\
lookup — 通过 bibcode 查找关联数据表。"
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"adql": {
"action": {
"type": "string",
"description": "自由 ADQL 查询语句(与 table_name 二选一)。如 SELECT TOP 10 * FROM \"I/355/gaiadr3\""
"enum": ["search", "describe", "query", "cone", "export", "lookup"],
"description": "操作类型"
},
"table_name": {
"keyword": {
"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
"description": "search 时的搜索关键词,如 'Gaia DR3'"
},
"table": {
"type": "string",
"description": "目标星表(必填),如 'I/355/gaiadr3'Gaia DR3)、'II/246/out'2MASS"
"description": "VizieR 表名,describe/query/cone/export 时使用,如 'I/355/gaiadr3'"
},
"max_records": {
"adql": {
"type": "string",
"description": "query/export 时的 ADQL 语句(与 table 二选一)"
},
"columns": {
"type": "string",
"description": "query/export + table 模式下指定列(逗号分隔,默认 *)"
},
"coords": {
"type": "object",
"description": "cone 时的坐标参数",
"properties": {
"ra": { "type": "number", "description": "赤经 RA(度)" },
"dec": { "type": "number", "description": "赤纬 Dec(度)" },
"radius_deg": { "type": "number", "description": "检索半径(度),默认 0.1", "default": 0.1 },
"strategy": { "type": "string", "enum": ["nearest", "all"], "description": "nearest(默认)/ all", "default": "nearest" }
},
"required": ["ra", "dec"]
},
"limit": {
"type": "integer",
"description": "最大返回行数(默认 50,上限 2000)",
"default": 50
},
"bibcode": {
"type": "string",
"description": "lookup 时的 ADS bibcode"
}
},
"required": ["ra", "dec", "table"]
"required": ["action"]
})
}
@@ -247,59 +159,301 @@ impl AgentTool for ConeSearchTool {
}
fn is_readonly(&self) -> bool {
true
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;
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)),
match action {
"search" => do_catalog_search(state, &args).await,
"describe" => do_catalog_describe(state, &args).await,
"query" => do_catalog_query(state, &args).await,
"cone" => do_catalog_cone(state, &args).await,
"export" => do_catalog_export(state, &args).await,
"lookup" => do_catalog_lookup(state, &args).await,
other => ToolOutput::error(format!(
"未知 action '{}',可选: search/describe/query/cone/export/lookup",
other
)),
}
}
}
async fn do_catalog_search(
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_catalog_describe(
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_catalog_query(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(50)
.clamp(1, 2000);
let result = if let Some(adql) = args.get("adql").and_then(|v| v.as_str()) {
info!(
"[CatalogOp:query] 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").and_then(|v| v.as_str()) {
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!("[CatalogOp:query] table={} limit={}", table, limit);
crate::services::cds::vizier::query_table(
&state.db,
&state.vizier,
table,
&columns,
limit,
)
.await
} else {
return ToolOutput::error("query 需要 'adql' 或 'table' 参数之一");
};
match result {
Ok(r) => {
let content = render_result_table(&r, 20);
ToolOutput::success(content, json!(r))
}
Err(e) => ToolOutput::error(format!("查询失败: {}", e)),
}
}
async fn do_catalog_cone(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
let coords = match args.get("coords").and_then(|v| v.as_object()) {
Some(c) => c,
None => return ToolOutput::error("cone 需要 'coords' 参数(含 ra/dec"),
};
let ra = match coords.get("ra").and_then(|v| v.as_f64()) {
Some(v) => v,
None => return ToolOutput::error("coords 缺少 'ra'"),
};
let dec = match coords.get("dec").and_then(|v| v.as_f64()) {
Some(v) => v,
None => return ToolOutput::error("coords 缺少 'dec'"),
};
let radius = coords
.get("radius_deg")
.and_then(|v| v.as_f64())
.unwrap_or(0.1);
let nearest = coords.get("strategy").and_then(|v| v.as_str()) != Some("all");
let table = match args.get("table").and_then(|v| v.as_str()) {
Some(t) => t,
None => return ToolOutput::error("cone 需要 'table' 参数"),
};
let max_records = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(50)
.clamp(1, 2000);
info!(
"[CatalogOp:cone] ra={} dec={} radius={}° table={} strategy={}",
ra, dec, radius, table, if nearest { "nearest" } else { "all" }
);
match crate::services::cds::vizier::cone_search(
&state.db,
&state.vizier,
ra,
dec,
radius,
table,
max_records,
nearest,
)
.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)),
}
}
async fn do_catalog_export(
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 = args.get("adql").and_then(|v| v.as_str());
let table = args.get("table").and_then(|v| v.as_str());
let columns = args.get("columns").and_then(|v| v.as_str());
if adql.is_none() && table.is_none() {
return ToolOutput::error("export 需要 'adql' 或 'table' 参数");
}
let catalog = crate::services::cds::vizier::VizierCatalog::new(&state.db, &state.vizier);
let result = match catalog.export_to_file(
state.config.library_dir.to_str().unwrap_or("."),
adql,
table,
columns,
limit,
).await {
Ok(r) => r,
Err(e) => return ToolOutput::error(format!("导出失败: {}", e)),
};
ToolOutput::success(
format!(
"已导出 {} 行数据到 `{}`\n文件大小: {}",
result.row_count,
result.path.display(),
result.size_bytes,
),
json!({
"path": result.path.to_str(),
"rows": result.row_count,
"columns": result.column_count,
}),
)
}
async fn do_catalog_lookup(
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))
}