AstroResearch/src/api/helpers.rs
Asfmq 22e7e1dcee feat: RAG 文献问答、天体目标识别、解析器模块化与批量管线扩展
核心新增:
  - RAG 问答系统:Markdown 安全切片器 (LaTeX 保护) + 向量化 + sqlite-vec 检索 + LLM 生成
  - 天体目标识别:IAU 标准正则提取 15+ 星表标识符,CDS SIMBAD/Sesame 查询与本地缓存
  - 多模态 LLM:chat_completion_with_image 支持图表视觉分析
  - CLI Skills Agent (cli.rs):对外暴露 rag/target/ingest 等 5 个子命令
  - 解析器模块化重构:单体 778 行 → 按期刊拆分 (A&A/ar5iv/IOP/Generic/PDF) +
    common.rs 静态正则工具库

  管线与 Schema:
  - AssetSync→AssetBatch 重命名,批量管线新增 embed/target 两个处理阶段
  - 新增 paper_chunks_content (RAG 切片) 和 paper_targets (天体缓存) 两张表
  - StandardPaper 新增 has_vector 字段,所有查询同步更新

  前端:
  - 新增 AI 助手侧边栏 (RAG 问答 + 来源跳转高亮)
  - 最近浏览文献列表 (localStorage 持久化)、跨面板无缝导航
  - SyncPanel 批量阶段扩展为下拉选项,支持向量化/天体识别

  测试与清理:
  - 集成测试合并至 ads.rs 和 llm.rs,新增 chunker + target 单元测试 15 个
  - 删除旧单体 parser.rs、独立测试文件及过期 scratch 脚本
2026-06-15 13:25:18 +08:00

389 lines
15 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/api/helpers.rs
use sqlx::{SqlitePool, Row};
use tracing::info;
use crate::clients::ads::AdsPaperDoc;
use crate::clients::arxiv::ArxivPaper;
use super::StandardPaper;
pub fn convert_ads_doc_to_standard(doc: &AdsPaperDoc) -> StandardPaper {
let title = doc.title.as_ref()
.and_then(|v: &Vec<String>| v.first())
.cloned()
.unwrap_or_else(|| doc.bibcode.clone());
let authors = doc.author.clone().unwrap_or_default();
let keywords = doc.keyword.clone().unwrap_or_default();
let doi = doc.doi.as_ref()
.and_then(|v: &Vec<String>| v.first())
.cloned()
.unwrap_or_default();
let mut arxiv_id = String::new();
if let Some(identifiers) = &doc.identifier {
for id in identifiers {
if id.starts_with("arXiv:") {
arxiv_id = id.replace("arXiv:", "").trim().to_string();
break;
}
}
}
if arxiv_id.is_empty() {
if doc.bibcode.starts_with("arXiv") {
arxiv_id = doc.bibcode.replace("arXiv", "").trim().to_string();
}
}
StandardPaper {
bibcode: doc.bibcode.clone(),
title,
authors,
year: doc.year.clone().unwrap_or_default(),
pub_journal: doc.pub_journal.clone().unwrap_or_default(),
keywords,
abstract_text: doc.abstract_text.clone().unwrap_or_default(),
doi,
arxiv_id,
citation_count: doc.citation_count.unwrap_or(0),
reference_count: doc.reference_count.unwrap_or(0),
is_downloaded: false,
has_pdf: false,
has_html: false,
has_markdown: false,
has_translation: false,
has_vector: false,
doctype: doc.doctype.clone().unwrap_or_else(|| "article".to_string()),
pdf_error: None,
html_error: None,
}
}
pub fn convert_arxiv_to_standard(doc: &ArxivPaper) -> StandardPaper {
StandardPaper {
bibcode: doc.id.clone(),
title: doc.title.clone(),
authors: doc.authors.clone(),
year: doc.year.clone(),
pub_journal: "arXiv Preprint".to_string(),
keywords: Vec::new(),
abstract_text: doc.abstract_text.clone(),
doi: doc.doi.clone().unwrap_or_default(),
arxiv_id: doc.id.clone(),
citation_count: 0,
reference_count: 0,
is_downloaded: false,
has_pdf: false,
has_html: false,
has_markdown: false,
has_translation: false,
has_vector: false,
doctype: "eprint".to_string(),
pdf_error: None,
html_error: None,
}
}
pub async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyhow::Result<()> {
let authors_json = serde_json::to_string(&p.authors)?;
let keywords_json = serde_json::to_string(&p.keywords)?;
// 1. 如果存在 arxiv_id检查是否有已存在的相同 arxiv_id 记录以防 duplicate
if !p.arxiv_id.is_empty() {
let existing_opt: Option<(String, Option<String>, Option<String>, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT bibcode, pdf_path, html_path, markdown_path, translation_path FROM papers WHERE arxiv_id = ?"
)
.bind(&p.arxiv_id)
.fetch_optional(db)
.await?;
if let Some((existing_bibcode, _pdf, _html, _md, _tr)) = existing_opt {
if existing_bibcode != p.bibcode {
// 发现不同 bibcode 标识的同一篇文献记录,需要进行合并
// 如果已存在的记录使用的是临时 arXiv ID 作为 bibcode且新记录使用的是正式 ADS bibcode我们升级 bibcode 主键
let is_existing_temp = existing_bibcode == p.arxiv_id;
let is_new_formal = p.bibcode != p.arxiv_id;
if is_existing_temp && is_new_formal {
info!("发现相同 arXiv ID 的文献,将临时主键 {} 升级为正式 ADS Bibcode: {}", existing_bibcode, p.bibcode);
sqlx::query(
"UPDATE papers SET bibcode = ?, title = ?, authors = ?, year = ?, pub = ?, keywords = ?, abstract = ?, doi = ?, citation_count = ?, reference_count = ?, doctype = ? WHERE bibcode = ?"
)
.bind(&p.bibcode)
.bind(&p.title)
.bind(&authors_json)
.bind(&p.year)
.bind(&p.pub_journal)
.bind(&keywords_json)
.bind(&p.abstract_text)
.bind(&p.doi)
.bind(p.citation_count)
.bind(p.reference_count)
.bind(&p.doctype)
.bind(&existing_bibcode)
.execute(db)
.await?;
return Ok(());
} else {
// 如果已存在的是正式 ADS bibcode而新插入的是临时 arXiv ID直接忽略或更新元数据而不更改主键
info!("发现相同 arXiv ID 的文献 {} 已存在正式记录,忽略临时 arXiv 插入", existing_bibcode);
return Ok(());
}
}
}
}
// 2. 正常插入/冲突更新
sqlx::query(
"INSERT INTO papers (bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, doctype) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(bibcode) DO UPDATE SET \
title=excluded.title, \
authors=excluded.authors, \
pub=excluded.pub, \
keywords=excluded.keywords, \
abstract=excluded.abstract, \
doi=excluded.doi, \
arxiv_id=excluded.arxiv_id, \
citation_count=excluded.citation_count, \
reference_count=excluded.reference_count, \
doctype=excluded.doctype"
)
.bind(&p.bibcode)
.bind(&p.title)
.bind(authors_json)
.bind(&p.year)
.bind(&p.pub_journal)
.bind(keywords_json)
.bind(&p.abstract_text)
.bind(&p.doi)
.bind(&p.arxiv_id)
.bind(p.citation_count)
.bind(p.reference_count)
.bind(&p.doctype)
.execute(db)
.await?;
Ok(())
}
pub async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, bibcode: &str) -> anyhow::Result<StandardPaper> {
let r = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype, EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) FROM papers WHERE bibcode = ?")
.bind(bibcode)
.fetch_one(db)
.await?;
let pdf_path: Option<String> = r.get(11);
let html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13);
let translation_path: Option<String> = r.get(14);
let doctype_val: Option<String> = r.get(15);
let has_vector: bool = r.get(16);
let authors_str: Option<String> = r.get(2);
let authors: Vec<String> = authors_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
let keywords_str: Option<String> = r.get(5);
let keywords: Vec<String> = keywords_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
let is_pdf_exist = pdf_path.as_ref().map(|p| library_dir.join(p).exists()).unwrap_or(false);
let is_html_exist = html_path.as_ref().map(|p| library_dir.join(p).exists()).unwrap_or(false);
let is_md_exist = markdown_path.as_ref().map(|p| library_dir.join(p).exists()).unwrap_or(false);
let is_tr_exist = translation_path.as_ref().map(|p| library_dir.join(p).exists()).unwrap_or(false);
let pdf_error = pdf_path.as_ref()
.filter(|p| p.starts_with("error:"))
.map(|p| p["error:".len()..].trim().to_string());
let html_error = html_path.as_ref()
.filter(|p| p.starts_with("error:"))
.map(|p| p["error:".len()..].trim().to_string());
Ok(StandardPaper {
bibcode: r.get(0),
title: r.get(1),
authors,
year: r.get(3),
pub_journal: r.get(4),
keywords,
abstract_text: r.get(6),
doi: r.get(7),
arxiv_id: r.get(8),
citation_count: r.get(9),
reference_count: r.get(10),
is_downloaded: is_pdf_exist || is_html_exist,
has_pdf: is_pdf_exist,
has_html: is_html_exist,
has_markdown: is_md_exist,
has_translation: is_tr_exist,
has_vector,
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
pdf_error,
html_error,
})
}
pub async fn check_paper_paths_in_db(
db: &SqlitePool,
library_dir: &std::path::Path,
bibcode: &str
) -> anyhow::Result<Option<(Option<String>, Option<String>, Option<String>, Option<String>)>> {
let r_opt = sqlx::query("SELECT pdf_path, html_path, markdown_path, translation_path FROM papers WHERE bibcode = ?")
.bind(bibcode)
.fetch_optional(db)
.await?;
if let Some(r) = r_opt {
let pdf: Option<String> = r.get(0);
let html: Option<String> = r.get(1);
let md: Option<String> = r.get(2);
let tr: Option<String> = r.get(3);
let pdf_res = pdf.filter(|p| library_dir.join(p).exists());
let html_res = html.filter(|p| library_dir.join(p).exists());
let md_res = md.filter(|p| library_dir.join(p).exists());
let tr_res = tr.filter(|p| library_dir.join(p).exists());
Ok(Some((pdf_res, html_res, md_res, tr_res)))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
#[test]
fn test_convert_ads_doc_to_standard() {
let doc = AdsPaperDoc {
bibcode: "2026A&A...123..456X".to_string(),
title: Some(vec!["A Test Title".to_string()]),
author: Some(vec!["Author A".to_string(), "Author B".to_string()]),
year: Some("2026".to_string()),
pub_journal: Some("Astronomy & Astrophysics".to_string()),
keyword: Some(vec!["Keyword 1".to_string()]),
abstract_text: Some("This is abstract".to_string()),
doi: Some(vec!["10.1000/test.doi".to_string()]),
citation_count: Some(5),
reference_count: Some(10),
reference: None,
citation: None,
identifier: None,
doctype: Some("article".to_string()),
};
let paper = convert_ads_doc_to_standard(&doc);
assert_eq!(paper.bibcode, "2026A&A...123..456X");
assert_eq!(paper.title, "A Test Title");
assert_eq!(paper.authors, vec!["Author A", "Author B"]);
assert_eq!(paper.year, "2026");
assert_eq!(paper.pub_journal, "Astronomy & Astrophysics");
assert_eq!(paper.keywords, vec!["Keyword 1"]);
assert_eq!(paper.abstract_text, "This is abstract");
assert_eq!(paper.doi, "10.1000/test.doi");
assert_eq!(paper.arxiv_id, "");
assert_eq!(paper.citation_count, 5);
assert_eq!(paper.reference_count, 10);
}
#[test]
fn test_convert_ads_doc_to_standard_with_arxiv_identifier() {
let doc = AdsPaperDoc {
bibcode: "2026MNRAS.530.1234A".to_string(),
title: Some(vec!["Another Test Title".to_string()]),
author: Some(vec!["Author A".to_string()]),
year: Some("2026".to_string()),
pub_journal: Some("MNRAS".to_string()),
keyword: None,
abstract_text: None,
doi: None,
citation_count: None,
reference_count: None,
reference: None,
citation: None,
identifier: Some(vec!["2026MNRAS.530.1234A".to_string(), "arXiv:2606.12345".to_string()]),
doctype: Some("article".to_string()),
};
let paper = convert_ads_doc_to_standard(&doc);
assert_eq!(paper.bibcode, "2026MNRAS.530.1234A");
assert_eq!(paper.arxiv_id, "2606.12345");
}
#[test]
fn test_convert_arxiv_to_standard() {
let doc = ArxivPaper {
id: "2606.12345".to_string(),
title: "Arxiv Title".to_string(),
authors: vec!["Author C".to_string()],
year: "2026".to_string(),
abstract_text: "Arxiv abstract".to_string(),
doi: Some("10.1000/arxiv.doi".to_string()),
pdf_url: "https://arxiv.org/pdf/2606.12345.pdf".to_string(),
};
let paper = convert_arxiv_to_standard(&doc);
assert_eq!(paper.bibcode, "2606.12345");
assert_eq!(paper.title, "Arxiv Title");
assert_eq!(paper.authors, vec!["Author C"]);
assert_eq!(paper.year, "2026");
assert_eq!(paper.pub_journal, "arXiv Preprint");
assert_eq!(paper.doi, "10.1000/arxiv.doi");
assert_eq!(paper.arxiv_id, "2606.12345");
}
#[tokio::test]
async fn test_db_operations() -> anyhow::Result<()> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await?;
// 运行迁移
sqlx::migrate!("./migrations")
.run(&pool)
.await?;
let paper = StandardPaper {
bibcode: "2026A&A...123..456X".to_string(),
title: "A Test Title".to_string(),
authors: vec!["Author A".to_string()],
year: "2026".to_string(),
pub_journal: "Astronomy & Astrophysics".to_string(),
keywords: vec!["Keyword 1".to_string()],
abstract_text: "This is abstract".to_string(),
doi: "10.1000/test.doi".to_string(),
arxiv_id: "".to_string(),
citation_count: 5,
reference_count: 10,
is_downloaded: false,
has_pdf: false,
has_html: false,
has_markdown: false,
has_translation: false,
has_vector: false,
doctype: "article".to_string(),
pdf_error: None,
html_error: None,
};
// 保存
save_paper_to_db(&pool, &paper).await?;
// 读取
let retrieved = get_paper_from_db(&pool, std::path::Path::new(""), "2026A&A...123..456X").await?;
assert_eq!(retrieved.title, paper.title);
assert_eq!(retrieved.authors, paper.authors);
assert_eq!(retrieved.keywords, paper.keywords);
// 检查路径状态(初始为 None
let paths = check_paper_paths_in_db(&pool, std::path::Path::new(""), "2026A&A...123..456X").await?;
assert!(paths.is_some());
let (pdf, html, md, tr) = paths.unwrap();
assert!(pdf.is_none());
assert!(html.is_none());
assert!(md.is_none());
assert!(tr.is_none());
Ok(())
}
}