feat: LAMOST DR12-14 与子版本体系接入、观测层安全加固与并发异步化

- 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 启用
This commit is contained in:
fmq
2026-07-07 21:16:46 +08:00
parent 2f1fd19d74
commit 2c8d0b8f8b
75 changed files with 2482 additions and 1370 deletions
+17 -3
View File
@@ -253,7 +253,14 @@ impl AgentTool for GetCitationNetworkTool {
// 有 query → 引用查找模式:在关联文献中按作者+年份搜索
if let Some(ref q) = query {
match crate::services::citation::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" {
@@ -315,8 +322,15 @@ impl AgentTool for GetCitationNetworkTool {
}
} else {
// 无 query → 分页浏览模式
match crate::services::citation::get_citations_paginated(&state.db, &paper.bibcode, direction, sort, offset, limit)
.await
match crate::services::citation::get_citations_paginated(
&state.db,
&paper.bibcode,
direction,
sort,
offset,
limit,
)
.await
{
Ok((rows, total)) => {
let page_count = rows.len();
+1 -1
View File
@@ -11,9 +11,9 @@ pub mod target;
pub mod vizier;
pub use library::{GetCitationNetworkTool, SearchLocalLibraryTool};
pub use observation::FindObservationTool;
pub use metadata::GetPaperMetadataTool;
pub use note::SaveNoteTool;
pub use observation::FindObservationTool;
pub use paper::{GetPaperContentTool, GetPaperOutlineTool};
pub use rag::RagSearchTool;
pub use target::QueryTargetTool;
+1
View File
@@ -58,6 +58,7 @@ impl AgentTool for SaveNoteTool {
let state = &ctx.app_state;
match crate::services::note::save_note_service(&state.config.library_dir, &title, &content)
.await
{
Ok((filename, filepath, size)) => ToolOutput::success(
format!("笔记已保存: {}", filename),
+99 -46
View File
@@ -52,23 +52,26 @@ impl AgentTool for FindObservationTool {
}
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": ["lamost", "gaia", "sdss", "desi"],
"enum": source_enum,
"description": "数据源"
},
"product": {
"type": "string",
"enum": ["spectrum", "lightcurve", "photometry", "image"],
"enum": product_enum,
"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"
"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",
@@ -80,7 +83,7 @@ impl AgentTool for FindObservationTool {
},
"radius_deg": {
"type": "number",
"description": "检索半径(度),坐标模式用,默认 0.1。各源无硬性 API 上限,但建议值:LAMOST≤5°,Gaia/SDSS/DESI≤1°(主表很大,超出易超时)。硬性上限 30°,超出报错。可通过 GET /api/observation/capabilities 查询各源建议值",
"description": "检索半径(度),坐标模式用,默认 0.1。建议值与硬上限可通过 GET /api/observation/capabilities 查询(LAMOST 建议≤5°,其他≤1°;硬上限 30°)",
"default": 0.1
},
"strategy": {
@@ -96,7 +99,11 @@ impl AgentTool for FindObservationTool {
},
"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"
"description": "数据发布版本(可选,留空用各源默认)。可选值与默认值通过 GET /api/observation/capabilities 查询"
},
"version": {
"type": "string",
"description": "数据发布的子版本(可选,仅 LAMOST 有意义)。留空用该 DR 的默认(最新公开)子版本。可选值通过 GET /api/observation/capabilities 的 release_versions 字段查询"
},
"force": {
"type": "boolean",
@@ -124,36 +131,41 @@ impl AgentTool for FindObservationTool {
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()
)),
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 | 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()
)),
None => ProductType::Spectrum,
Some(s) => match ProductType::from_str(s) {
Ok(p) => p,
Err(e) => return ToolOutput::error(e),
},
};
let subtype = args.get("subtype")
let subtype = args
.get("subtype")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let product = ProductSpec { product: product_type, subtype };
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 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()
let identifiers: Vec<String> = ids
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if identifiers.is_empty() {
@@ -161,23 +173,38 @@ impl AgentTool for FindObservationTool {
}
info!(
"[FindObservation] by_id source={:?} product={:?} count={}",
source, product.product, identifiers.len()
source,
product.product,
identifiers.len()
);
ObservationRequest::ByIdentifier { source, product, identifiers, release }
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 标识符模式",
),
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 标识符模式",
),
None => {
return ToolOutput::error(
"坐标模式缺少必需参数 'dec'(赤纬,度),或改用 source_ids 标识符模式",
)
}
};
let radius = args.get("radius_deg").and_then(|v| v.as_f64()).unwrap_or(0.1);
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,
@@ -187,7 +214,14 @@ impl AgentTool for FindObservationTool {
source, product.product, ra, dec, radius, strategy
);
ObservationRequest::ByCoordinates {
source, product, ra, dec, radius_deg: radius, strategy, release,
source,
product,
ra,
dec,
radius_deg: radius,
strategy,
release,
version,
}
};
@@ -203,7 +237,11 @@ impl AgentTool for FindObservationTool {
/// 渲染 ObservationBatch 为可读文本(支持多 artifact 展示)
fn render_batch(b: &crate::services::observation::ObservationBatch) -> String {
let mut content = format!("{} {} 下载", b.source.display(), b.product.product.display());
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));
}
@@ -219,18 +257,30 @@ fn render_batch(b: &crate::services::observation::ObservationBatch) -> String {
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();
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(),
if p.artifacts.iter().all(|a| a.cached) {
"缓存"
} else {
"新下载"
},
p.source_label,
artifact_summary.join(", ")
));
@@ -238,7 +288,10 @@ fn render_batch(b: &crate::services::observation::ObservationBatch) -> String {
content.push_str(&format!(
" {}{}\n",
a.file_path,
a.band.as_ref().map(|b| format!(" [{}]", b)).unwrap_or_default()
a.band
.as_ref()
.map(|b| format!(" [{}]", b))
.unwrap_or_default()
));
}
}
+26 -47
View File
@@ -185,10 +185,7 @@ impl AgentTool for CatalogOperationTool {
}
}
async fn do_catalog_search(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
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' 参数"),
@@ -226,10 +223,7 @@ async fn do_catalog_search(
ToolOutput::success(content, json!(items))
}
async fn do_catalog_describe(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
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' 参数"),
@@ -259,10 +253,7 @@ async fn do_catalog_describe(
ToolOutput::success(content, json!(items))
}
async fn do_catalog_query(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
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())
@@ -275,8 +266,7 @@ async fn do_catalog_query(
limit,
adql.chars().take(150).collect::<String>()
);
crate::services::cds::vizier::query_adql_cached(&state.db, &state.vizier, adql, limit)
.await
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")
@@ -284,14 +274,8 @@ async fn do_catalog_query(
.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
crate::services::cds::vizier::query_table(&state.db, &state.vizier, table, &columns, limit)
.await
} else {
return ToolOutput::error("query 需要 'adql' 或 'table' 参数之一");
};
@@ -305,10 +289,7 @@ async fn do_catalog_query(
}
}
async fn do_catalog_cone(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
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"),
@@ -338,7 +319,11 @@ async fn do_catalog_cone(
info!(
"[CatalogOp:cone] ra={} dec={} radius={}° table={} strategy={}",
ra, dec, radius, table, if nearest { "nearest" } else { "all" }
ra,
dec,
radius,
table,
if nearest { "nearest" } else { "all" }
);
match crate::services::cds::vizier::cone_search(
@@ -365,10 +350,7 @@ async fn do_catalog_cone(
}
}
async fn do_catalog_export(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
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())
@@ -384,13 +366,16 @@ async fn do_catalog_export(
}
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 {
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)),
};
@@ -410,10 +395,7 @@ async fn do_catalog_export(
)
}
async fn do_catalog_lookup(
state: &crate::api::AppState,
args: &serde_json::Value,
) -> ToolOutput {
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' 参数"),
@@ -424,11 +406,8 @@ async fn do_catalog_lookup(
.unwrap_or(10)
.clamp(1, 30) as usize;
let catalog = crate::services::cds::vizier::VizierCatalog::with_ads(
&state.db,
&state.vizier,
&state.ads,
);
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)),