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