- LAMOST 新增 DR12/13/14 及子版本(v0/v1.0/v1.1/v2.0)维度,Internal 发布标记需登录认证并前端灰显,release×subtype 交叉约束下沉至 capabilities 统一声明 - ObservationFetcher trait 扩展版本/认证/交叉约束能力声明,version 参数贯穿 client→service→API→Agent tool→前端全链路 - 安全:observation cache SQL 全参数绑定 + LIKE 转义、cone_cache_hash 加长度前缀防碰撞、DESI survey/program 白名单防穿越 - 异步化:persist/cached_files_total_size/maybe_persist_tool_result迁移到 tokio::fs;cancelled_runs 与 session_permission_checkers改用 DashMap;auth 读锁优先 + 60s 节流 - Gaia 去 native-tls 改禁用连接池规避 UnexpectedEof,reqwest 移除 native-tls feature - 重构:Source/ProductType from_str 集中解析、download 模块拆分为 try_download_pdf/html、AgentRuntime::init 抽取共享逻辑 - 部署:新增 deploy.sh 一键打包推送脚本、catch-panic 启用
313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
// 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 {
|
||
// enum 列表从枚举的 valid_values() 动态生成,新增源/产品时自动同步
|
||
let source_enum: Vec<&str> = Source::valid_values().to_vec();
|
||
let product_enum: Vec<&str> = ProductType::valid_values().to_vec();
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"source": {
|
||
"type": "string",
|
||
"enum": source_enum,
|
||
"description": "数据源"
|
||
},
|
||
"product": {
|
||
"type": "string",
|
||
"enum": product_enum,
|
||
"description": "产品类型,默认 spectrum",
|
||
"default": "spectrum"
|
||
},
|
||
"subtype": {
|
||
"type": "string",
|
||
"description": "产品子类型(可选)。各源可选值通过 GET /api/observation/capabilities 查询;LAMOST spectrum: lrs/mrs;Gaia spectrum: xp_continuous/xp_sampled/rvs;Gaia lightcurve: epoch_photometry;SDSS spectrum: spec/apstar/aspcap;DESI spectrum: coadd"
|
||
},
|
||
"ra": {
|
||
"type": "number",
|
||
"description": "赤经 RA(度,J2000/ICRS)。坐标模式必填"
|
||
},
|
||
"dec": {
|
||
"type": "number",
|
||
"description": "赤纬 Dec(度,J2000/ICRS)。坐标模式必填"
|
||
},
|
||
"radius_deg": {
|
||
"type": "number",
|
||
"description": "检索半径(度),坐标模式用,默认 0.1。建议值与硬上限可通过 GET /api/observation/capabilities 查询(LAMOST 建议≤5°,其他≤1°;硬上限 30°)",
|
||
"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 查询"
|
||
},
|
||
"version": {
|
||
"type": "string",
|
||
"description": "数据发布的子版本(可选,仅 LAMOST 有意义)。留空用该 DR 的默认(最新公开)子版本。可选值通过 GET /api/observation/capabilities 的 release_versions 字段查询"
|
||
},
|
||
"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 Source::from_str(s) {
|
||
Ok(src) => src,
|
||
Err(e) => return ToolOutput::error(e),
|
||
},
|
||
None => return ToolOutput::error("缺少必需参数 'source'(lamost/gaia/sdss/desi)"),
|
||
};
|
||
let product_type = match args.get("product").and_then(|v| v.as_str()) {
|
||
None => ProductType::Spectrum,
|
||
Some(s) => match ProductType::from_str(s) {
|
||
Ok(p) => p,
|
||
Err(e) => return ToolOutput::error(e),
|
||
},
|
||
};
|
||
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());
|
||
let version = args
|
||
.get("version")
|
||
.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,
|
||
version,
|
||
}
|
||
} 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,
|
||
version,
|
||
}
|
||
};
|
||
|
||
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 {
|
||
"新下载"
|
||
},
|
||
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)
|
||
}
|
||
}
|