AstroResearch/src/agent/tools/astro/research/observation.rs
Asfmq eaf85707b5 refactor: 全栈架构重构与质量硬化
核心架构重构:
- Config 按职责拆分为 LlmConfig/EmbeddingConfig/VisionConfig/CdsConfig/StorageConfig 五个子结构
- AppState 拆分为 LlmState/DataSourceState/SessionState 三个子结构,消除 50+ 平铺字段
- 新增 ServiceError 结构化错误类型替代 handler 中的 msg.contains() 字符串匹配
- 移除 api::handlers 兼容命名空间,路由直接引用 agent/auth/papers 等模块

认证性能优化:
- login_rate_limiter/upload_rate_limiter 从 Mutex<HashMap> 迁移为 DashMap(无锁)
- 新增 session_last_active: DashMap<String, AtomicU64>,auth 中间件快速路径免写锁
- 会话过期清理改为按间隔触发(300s),避免每次请求全表扫描
- MAX_SESSIONS 1000→10000,SSE 广播通道 256→1024

Agent 工具增强:
- AgentTool trait 新增 is_internal()/display_name(),SSE 事件携带工具元数据
- 新增 GET /chat/tools 端点暴露注册工具列表
- pending_questions/pending_permissions 增加 created_at 时间戳,自动清理过期条目(10min TTL)
- Agent 超时现在正确 abort 后台任务并设置取消令牌

观测数据源修复:
- FITS 解析: APOGEE/DESI 改用 read_image+切片替代 read_rows(修复 fitsio panic)
- ZTF: CIRCLE 参数分隔符 +→空格(修复 IRSA 400),半径自动裁剪至硬上限
- MAST TESS: parse_tic_json 兼容数组/对象两种 API 响应格式
- 统一检索: per_target_limit 默认 50→1,sources 支持 per-source release/version
- Gaia 测光从 VizieR 镜像切换至官方 TAP 服务

RAG 并发优化:
- 向量化降级从串行改为并发 5 条/批(buffer_unordered)
- 混合检索 RRF 合并从借用改为 owned RetrievalResult

安全加固:
- PDF 中间件: URL 解码 %2F/%2E 后判扩展名;文件名过滤非 ASCII + 禁 \ 防头注入
- chat_agent 日志截断问题内容至 50 字符;list_sessions 强制 limit clamp

前端双主题:
- 设计令牌三层架构: primitive→semantic→component,浅色暖纸张学术/暗色 Night Indigo
- useTheme hook + ThemeToggle 侧边栏组件 + main.tsx 防 FOUC 初始化
- 全组件从硬编码 slate 色迁移至语义令牌(bg-surface/text-content/border-subtle 等)
- 新增 ToastContainer 非阻塞通知系统

部署优化:
- deploy.sh 引入 SSH ControlMaster 单次密码复用
2026-07-11 14:57:40 +08:00

319 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 display_name(&self) -> &str {
"观测数据获取"
}
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/mrsGaia spectrum: xp_continuous/xp_sampled/rvsGaia lightcurve: epoch_photometrySDSS spectrum: spec/apstar/aspcapDESI spectrum: coadd"
},
"ra": {
"type": "number",
"description": "赤经 RAJ2000/ICRS。坐标模式必填"
},
"dec": {
"type": "number",
"description": "赤纬 DecJ2000/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.sources.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)
}
}