feat: 科研分析层全栈落地——光谱/时域/运动学分析工具链 + JWST/X 射线数据源 + 定时文献同步

数据分析层(新增 services/{spectrum,timeseries,analysis}):
- 光谱参数提取 parameters.rs:LAMOST/SDSS/APOGEE/DESI FITS header 跨源归一化读取
  Teff/logg/[Fe/H]/RV 及 ASPCAP 20+ 元素丰度,rayon 并发批量提取
- 谱线测量 lines.rs:内置真空/空气波长谱线表,窗口内极值搜索 + 梯形法积分 EW + FWHM,支持自定义谱线
- 交叉相关测速 cross_correlate.rs:对数波长重采样对齐,内置 Pickles 模板按光谱型插值,
  CCF 峰值位置提取 RV 及不确定度
- 周期搜索 periodicity.rs:Lomb-Scargle 周期图(含 FAP 误报概率)+ BLS 凌星检测 + 相位折叠
- 变星分类 classification.rs:振幅/偏度/峰度/过零率/eta 等统计特征 + 规则分类(RR Lyrae/Cepheid/食双星/AGN 等)
- SED 拟合 sed.rs:多波段测光黑体模型拟合,输出 T_eff/半径/消光 A_V/光度及不确定度
- 运动学 kinematics.rs:视差+自行+RV → 银河系 UVW 空间速度,含移动星群成员概率(Banyan Σ 简化版)
- 化学丰度 chemistry.rs:[α/Fe] vs [Fe/H] 计算,厚盘/薄盘/晕星族判别
- 观测规划 observability.rs:目标升落时间/airmass/月相影响/曝光时间估算
- 赫罗图 hr_diagram.rs:Gaia TAP CMD 查询,新增 GET /api/analysis/hr-diagram 端点

数据获取层:
- JWST:clients/mast/jwst.rs 封装 MAST Portal 锥形检索 + JwstSpectrumFetcher(NIRSpec/MIRI 光谱)
- X 射线:clients/heasarc 封装 HEASARC TAP(ADQL)+ XMM-Newton/Chandra 光谱 fetcher
- 图像 cutout:SDSS SkyServer/STScI DSS/Pan-STARRS 三源 cutout + 发现图(Finding Chart)生成
- Source 枚举新增 Jwst/Xmm/Chandra 并注册 ObservationRegistry,前端 SOURCE_THEME 与筛选器同步三源

Agent 工具集(24→35):
- 新增 9 个分析工具:get_spectrum_parameters / measure_spectral_lines / measure_radial_velocity /
  find_period / classify_variable_star / fit_sed / analyze_kinematics / analyze_abundance_pattern / plan_observation
- batch_process:批量样本"查询→下载→分析→报告"流水线,并发控制防数据源速率限制
- literature_monitor:按 ADS 查询式/时间窗/最低引用数检查最新文献

定时文献同步:
- sync_queries 表新增 is_scheduled 列(migration 20260713)
- 新增 POST /sync/queries/:id/schedule 端点
- 服务启动时拉起每小时调度器,对 is_scheduled=1 的检索配置静默执行 ADS(entdate 增量)/arXiv 增量同步
- search_history 工具收敛至 services/search::search_agent_history,消除 FTS 查询逻辑重复

其他:
- plotting skill 由占位填充为完整科研绘图规范:光谱/光变/折叠曲线/CMD/SED/[α/Fe]/周期图/Mollweide/发现图 9 类 matplotlib 模板
- 删除死代码 streaming_executor.rs(929 行,仅剩 mod 声明引用,无调用方)
- 新增 docs/roadmap-research-features.md 科研功能路线图及实现状态
This commit is contained in:
fmq
2026-09-07 21:50:32 +08:00
parent eaf85707b5
commit d6b064a490
104 changed files with 12101 additions and 3301 deletions
+80 -12
View File
@@ -1,27 +1,95 @@
// src/agent/runtime/duplicate_detector.rs
//
// 同质调用检测器:检测连续重复的工具调用,防止死循环。
// 同质调用检测器:检测重复的工具调用,防止死循环。
//
// 历史实现是单槽(只记住上一次调用),A/B 交替死循环
// call A → call B → call A → call B ...)检测不到。
// 现改为"连续计数 + 滑动窗口计数"双通道:
// - 连续通道:连续相同调用 ≥ threshold(原语义,立即触发)
// - 窗口通道:最近 threshold*2 次调用中同一调用出现 ≥ threshold+2 次
// (捕获 A/B 交替;门槛略高以放过合理的重复只读调用)
use std::collections::VecDeque;
/// 同质调用检测器
#[derive(Debug, Default)]
pub struct DuplicateDetector {
last_call: Option<(String, String)>, // (tool_name, arguments)
consecutive_count: usize,
/// 最近调用的滑动窗口(name, arguments
recent: VecDeque<(String, String)>,
}
impl DuplicateDetector {
/// 记录一次调用,返回是否检测到死循环
pub fn record(&mut self, tool_name: &str, arguments: &str, threshold: usize) -> bool {
let key = (tool_name.to_string(), arguments.to_string());
if self.last_call.as_ref() == Some(&key) {
self.consecutive_count += 1;
if self.consecutive_count >= threshold {
return true;
}
} else {
self.last_call = Some(key);
self.consecutive_count = 1;
let window = (threshold * 2).max(4);
self.recent.push_back(key.clone());
while self.recent.len() > window {
self.recent.pop_front();
}
// 连续通道:尾部连续出现次数
let consecutive = self.recent.iter().rev().take_while(|k| *k == &key).count();
if threshold >= 2 && consecutive >= threshold {
return true;
}
// 窗口通道:同一调用在窗口内出现次数(捕获 A/B 交替)。
// 门槛与 threshold 相同:纯交替下窗口内单键最多出现 window/2 =
// threshold 次,恰好可达;合理的间隔重复(≤ threshold-1 次)不受影响。
let occurrences = self.recent.iter().filter(|k| **k == key).count();
threshold >= 2 && occurrences >= threshold
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_consecutive_duplicates_detected() {
let mut det = DuplicateDetector::default();
assert!(!det.record("search_papers", "q=1", 3));
assert!(!det.record("search_papers", "q=1", 3));
assert!(det.record("search_papers", "q=1", 3));
}
#[test]
fn test_alternating_loop_detected() {
// A/B 交替:单槽实现检测不到,窗口通道应捕获
let mut det = DuplicateDetector::default();
let mut tripped = false;
for i in 0..6 {
let a = det.record("tool_a", "{}", 3);
let b = det.record("tool_b", "{}", 3);
if a || b {
// 交替 5 次出现(threshold+2 = 5)后触发
assert!(i >= 1, "不应过早触发 (round {})", i);
tripped = true;
break;
}
}
assert!(tripped, "A/B 交替死循环应被检测到");
}
#[test]
fn test_spaced_repeats_not_detected() {
// 合理的重复只读调用(间隔其他调用)不应触发
let mut det = DuplicateDetector::default();
det.record("read_file", "a.rs", 3);
det.record("grep_files", "pat", 3);
det.record("read_file", "b.rs", 3);
det.record("glob_files", "*.rs", 3);
let tripped = det.record("read_file", "a.rs", 3);
assert!(!tripped);
}
#[test]
fn test_different_args_not_duplicates() {
let mut det = DuplicateDetector::default();
for q in ["q=1", "q=2", "q=3", "q=4", "q=5", "q=6", "q=7", "q=8"] {
assert!(!det.record("search_papers", q, 3));
}
false
}
}