فهرست منبع

feat: restore graduation project from PR #614

Original PR: https://github.com/datawhalechina/hello-agents/pull/614
Original commits:
960ec5c9f5f8939500a7174a1adb47b7911680dd
eaa7a0388afe2909eea0bfcb2f72bd2c4213160a
a5f4cfad6bc1820bc8794c1ece5469a7dd9066b7

DeLunnLi 2 ماه پیش
والد
کامیت
3e2cdbf9c1
100فایلهای تغییر یافته به همراه16568 افزوده شده و 0 حذف شده
  1. 260 0
      Co-creation-projects/DeLunnLi-PaperGraph/README.md
  2. 82 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/.env.example
  3. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/__init__.py
  4. 30 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/__init__.py
  5. 36 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/base.py
  6. 153 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/knowledge_graph_agent.py
  7. 773 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/paper_analysis_agent.py
  8. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/__init__.py
  9. 26 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/knowledge_graph.py
  10. 36 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/paper_analysis.py
  11. 89 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/search.py
  12. 196 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/search_agent.py
  13. 11 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/__init__.py
  14. 164 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/paper_analysis_helpers.py
  15. 337 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_paper_lookup_tool.py
  16. 148 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_pdf_parse_tool.py
  17. 312 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_reference_lookup_tool.py
  18. 140 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_table_tool.py
  19. 43 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/search_models.py
  20. 46 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/dependencies.py
  21. 137 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/main.py
  22. 64 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/repo.py
  23. 5 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/__init__.py
  24. 85 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/paper_reader.py
  25. 218 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/papers.py
  26. 284 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/search.py
  27. 81 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/search_route_support.py
  28. 35 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/tool_events.py
  29. 1 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/__init__.py
  30. 41 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/author.py
  31. 145 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/conference_landing_pdf.py
  32. 174 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/paper.py
  33. 109 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/paper_paths.py
  34. 227 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/pdf_download.py
  35. 11 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/__init__.py
  36. 312 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/normalize.py
  37. 406 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/paper_searcher.py
  38. 1 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/__init__.py
  39. 399 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/arxiv.py
  40. 508 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/dblp.py
  41. 490 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/openalex.py
  42. 57 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/source_common.py
  43. 341 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/tavily.py
  44. 725 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/storage.py
  45. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/models/__init__.py
  46. 233 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/models/schemas.py
  47. 3 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/__init__.py
  48. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/__init__.py
  49. 115 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_auto_refresh.py
  50. 73 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_cache_store.py
  51. 292 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_recommend_feedback.py
  52. 52 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_recommend_store.py
  53. 567 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_service.py
  54. 514 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_support.py
  55. 220 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/user_behavior_analytics.py
  56. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/feedback/__init__.py
  57. 182 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/feedback/negative_feedback_memory.py
  58. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/graph/__init__.py
  59. 184 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/graph/graph_service.py
  60. 367 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/graph/kg_relations.py
  61. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/__init__.py
  62. 23 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/agent_config.py
  63. 134 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/agent_runtime.py
  64. 246 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/llm_service.py
  65. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/__init__.py
  66. 134 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/papers_converters.py
  67. 47 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/papers_helpers.py
  68. 361 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/papers_library_service.py
  69. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/pdf/__init__.py
  70. 124 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/pdf/pdf_service.py
  71. 1 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/__init__.py
  72. 136 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_artifact.py
  73. 532 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_context.py
  74. 107 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_history.py
  75. 243 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_service.py
  76. 127 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_structure.py
  77. 66 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/reader_opening_cache.py
  78. 238 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/reader_recommend_llm.py
  79. 1 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reading_log/__init__.py
  80. 45 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reading_log/log.py
  81. 0 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/__init__.py
  82. 99 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/method_acronym.py
  83. 136 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/paper_filters.py
  84. 441 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/paper_ranker.py
  85. 104 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/pipeline_runtime.py
  86. 108 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/plan_helpers.py
  87. 216 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/proceedings_discovery.py
  88. 293 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/proceedings_recall.py
  89. 228 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/ranking_prompt.py
  90. 242 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/recall_context.py
  91. 318 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/recall_jobs.py
  92. 149 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/relevance_guard.py
  93. 262 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/search_pipeline.py
  94. 127 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/search_plan.py
  95. 149 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/search_recipe.py
  96. 33 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/source_plan.py
  97. 180 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/tavily_venue_config.py
  98. 81 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/tavily_venue_domains.json
  99. 282 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/web_presearch.py
  100. 15 0
      Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/search_intent/__init__.py

+ 260 - 0
Co-creation-projects/DeLunnLi-PaperGraph/README.md

@@ -0,0 +1,260 @@
+# PaperGraph(知脉)- 面向研究者的智能文献工作台
+
+<div align="center">
+
+**Academic Paper Search, Reading, Recommendation and Knowledge Graph Workspace**
+
+[![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/downloads/)
+[![FastAPI](https://img.shields.io/badge/FastAPI-0.104+-green.svg)](https://fastapi.tiangolo.com/)
+[![Vue](https://img.shields.io/badge/Vue-3.x-brightgreen.svg)](https://vuejs.org/)
+[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
+[![GitHub](https://img.shields.io/badge/GitHub-DeLunnLi/PaperGraph-black.svg)](https://github.com/DeLunnLi/PaperGraph)
+
+*基于 HelloAgents 构建的学术文献搜索、阅读、推荐与知识图谱系统*
+
+*把找论文、读论文、管理论文和沉淀研究脉络串成一个连续工作流*
+
+</div>
+
+---
+
+## 项目简介
+
+PaperGraph(知脉)是一个面向科研学习、论文调研和研究方向跟踪的智能文献工作台。系统以 SearchAgent、PaperAnalysisAgent 和 KnowledgeGraphAgent 为核心,将自然语言检索、多源论文召回、PDF 阅读问答、每日推荐、文献保存和知识图谱构建整合到同一个 Web 应用中。
+
+它希望解决研究者在日常文献工作中的几个高频痛点:
+
+- 关键词检索分散在多个平台,结果需要手动筛选、去重和排序
+- 阅读论文时缺少上下文辅助,方法、实验、引用关系难以持续沉淀
+- 每日新论文、个人文献库和知识图谱彼此割裂,难以形成长期研究记忆
+- 从发现论文到保存、阅读、追问、归类之间缺少一条顺滑的工作流
+
+### 核心特性
+
+- **自然语言文献搜索**:输入研究问题、论文标题、作者或会议线索,由 LLM 解析意图并生成 SearchRecipe
+- **多源并行召回与精排**:整合 arXiv、DBLP、OpenAlex 与 Tavily 线索,完成去重、过滤、排序和兜底召回
+- **论文阅读助手**:支持 PDF 正文抽取、AI 导读、阅读对话、参考文献查找和表格上下文辅助
+- **每日论文推荐**:根据用户兴趣选择 arXiv 分类,生成个性化候选论文与推荐理由
+- **我的文献库**:支持论文保存、PDF 下载、分类管理、阅读记录和阅读日历
+- **知识图谱构建**:从已保存论文中抽取主题、方法、引用和相关关系,并进行可视化浏览
+- **多智能体共享记忆**:通过 GSSC(Gather -> Score -> Select)流水线选择上下文,减少重复信息干扰
+
+### 技术亮点
+
+- **Recipe 驱动检索**:将 LLM 对用户意图的理解转成可执行检索计划,降低硬编码特殊路径依赖
+- **多源召回与优雅降级**:arXiv、DBLP、OpenAlex 和 Tavily 互为补充,单一来源失败时仍尽量返回可用结果
+- **流式过程反馈**:搜索过程通过 SSE 返回阶段状态和工具调用摘要,便于用户理解结果来源
+- **阅读上下文增强**:阅读器结合论文正文、表格、参考文献和历史记忆回答问题,而不只是展示 PDF
+- **共享记忆机制**:多个 Agent 共享论文、偏好和反馈上下文,让后续搜索与推荐更贴近用户兴趣
+- **前后端契约生成**:通过 OpenAPI 导出前端类型,减少接口字段漂移
+
+## 应用场景
+
+### 适合谁使用?
+
+- **研究生/博士生**:快速进入新方向,建立论文阅读和调研脉络
+- **科研工作者**:跟踪每日新论文,沉淀个人文献库和主题关系
+- **AI/工程研发人员**:围绕技术问题快速查找论文、保存证据和复盘方法
+- **课程学习者**:用对话式阅读辅助理解论文方法、实验和引用背景
+
+### 典型使用场景
+
+1. **主题调研**:输入研究问题 -> 多源召回论文 -> 保存候选论文 -> 形成阅读列表
+2. **论文精读**:打开 PDF -> 获取 AI 导读 -> 围绕方法、实验和局限继续追问
+3. **每日跟踪**:系统拉取新论文 -> 个性化推荐 -> 保存感兴趣论文 -> 反馈偏好
+4. **知识沉淀**:从文献库抽取关系 -> 生成图谱 -> 观察主题、作者和论文之间的连接
+
+## 系统架构
+
+### 整体架构
+
+```text
+┌──────────────────────────────────────────────────────────────┐
+│                         前端界面层                            │
+│  文献搜索 | 每日论文 | 我的文献库 | 论文阅读助手 | 知识图谱       │
+└──────────────────────────────────────────────────────────────┘
+                              │
+                              │ REST + SSE
+                              ▼
+┌──────────────────────────────────────────────────────────────┐
+│                         API 接口层                            │
+│              FastAPI Routes + OpenAPI + Tool Events           │
+└──────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌──────────────────────────────────────────────────────────────┐
+│                       智能体编排层                             │
+│  SearchAgent | PaperAnalysisAgent | KnowledgeGraphAgent        │
+└──────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌──────────────────────────────────────────────────────────────┐
+│                         核心服务层                            │
+│  Search Pipeline | PDF Parser | Daily Recommend | AgentMemory  │
+└──────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌──────────────────────────────────────────────────────────────┐
+│                         数据持久层                            │
+│       SQLite 文献库 | PDF 文件存储 | 阅读记录 | 反馈记忆          │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### 三类智能体
+
+| 智能体 | 职责 | 核心能力 |
+|--------|------|----------|
+| **SearchAgent** | 文献搜索与结果解释 | 意图解析、SearchRecipe、多源召回、LLM 精排 |
+| **PaperAnalysisAgent** | 论文阅读与分析 | 摘要生成、标签归类、阅读问答、引用查找 |
+| **KnowledgeGraphAgent** | 知识图谱构建 | 论文关系抽取、图谱数据生成、节点详情解释 |
+
+### 搜索链路
+
+```text
+用户问题
+  -> LLM 意图解析
+  -> SearchRecipe
+  -> arXiv / DBLP / OpenAlex / Tavily 多源召回
+  -> 去重与过滤
+  -> LLM 精排
+  -> 结果解释与保存
+```
+
+## Quick Start
+
+### 1. 环境要求
+
+- Python 3.10+
+- Node.js 18+
+- 可用的 OpenAI 兼容 LLM 服务
+
+### 2. 后端安装与配置
+
+```bash
+cd backend
+pip install -r requirements.txt
+```
+
+在 `backend/.env` 中配置模型信息:
+
+```env
+LLM_API_KEY=your_api_key
+LLM_BASE_URL=https://your-openai-compatible-endpoint/v1
+LLM_MODEL_ID=your_model_id
+```
+
+启动后端:
+
+```bash
+python run.py
+```
+
+默认访问地址:`http://localhost:8000`
+
+### 3. 前端安装与启动
+
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+默认访问地址:`http://localhost:5173`
+
+### 4. 一键启动
+
+也可以在项目根目录执行:
+
+```bash
+./start.sh
+```
+
+## 使用流程
+
+1. 在 **文献搜索** 页面输入自然语言问题,例如“2024 年视觉语言模型中的 grounding 相关论文”。
+2. SearchAgent 解析检索意图,选择关键词、来源、年份、会议等约束,并执行多源召回。
+3. 用户将有价值的论文保存到 **我的文献库**,必要时下载 PDF。
+4. 在 **我的文献库** 中打开某篇论文,进入 **论文阅读助手** 页面查看 AI 导读,并围绕方法、实验、局限和参考文献继续提问。
+5. 在 **每日论文** 页面追踪新论文,在 **知识图谱** 页面观察主题和论文之间的关系。
+
+## 演示效果
+
+文献搜索、每日论文推荐、文献库、论文阅读助手和知识图谱等界面截图,请参见 [毕业设计提交 PR #614](https://github.com/datawhalechina/hello-agents/pull/614)。
+
+## 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| **智能体框架** | HelloAgents(SimpleAgent + ToolRegistry + CircuitBreaker + ContextBuilder) |
+| **后端服务** | FastAPI + SQLite + Pydantic |
+| **前端应用** | Vue 3 + Vite + Ant Design Vue + KaTeX + PDF.js |
+| **LLM 接入** | DeepSeek-V4 / OpenAI 兼容接口 |
+| **论文数据源** | arXiv + DBLP + OpenAlex + Tavily |
+| **PDF 处理** | PyMuPDF(fitz) |
+
+## 工程指标
+
+- **搜索链路**:意图解析 + SearchRecipe + 多源召回 + LLM 精排
+- **推荐链路**:arXiv 候选拉取 + 用户兴趣词 + 个性化筛选 + 反馈记忆
+- **阅读链路**:PDF 解析 + 正文上下文 + 对话历史 + 参考文献查找
+- **交互方式**:REST API + SSE 流式状态更新
+- **数据存储**:SQLite 文献库 + 本地 PDF 文件 + 阅读记录
+
+## 项目结构
+
+```text
+.
+├─ backend/                 # FastAPI 后端与智能体服务
+│  ├─ app/agents/           # SearchAgent / PaperAnalysisAgent / KnowledgeGraphAgent
+│  ├─ app/api/              # API 路由、依赖和 SSE 工具事件
+│  ├─ app/core/             # Paper 模型、PDF 下载、搜索源适配
+│  ├─ app/services/         # 检索、阅读、推荐、记忆、图谱等业务服务
+│  ├─ data/                 # 本地数据库与运行数据(不提交)
+│  └─ downloads/            # PDF 下载目录(不提交)
+├─ frontend/                # Vue 3 前端应用
+│  ├─ src/views/            # 搜索、每日论文、文献库、阅读器、知识图谱页面
+│  ├─ src/components/       # 论文卡片、搜索结果、工具轨迹、阅读日历等组件
+│  ├─ src/composables/      # 搜索对话、历史记录、标题关键词等组合逻辑
+│  └─ src/services/         # API 客户端与接口封装
+├─ ports.env                # 本地端口配置
+├─ start.sh                 # 一键启动脚本
+└─ README.md
+```
+
+## 开发路线图
+
+### v1.0(当前版本)
+
+- [x] 自然语言文献搜索
+- [x] 多源论文召回与 LLM 精排
+- [x] PDF 阅读助手
+- [x] 每日论文推荐
+- [x] 我的文献库与阅读日历
+- [x] 知识图谱可视化
+
+### v1.1(计划中)
+
+- [ ] 补充搜索、阅读和推荐链路的集成测试
+- [ ] 增加搜索结果缓存和可复现实验样例
+- [ ] 优化搜索过程可观测性和错误提示
+- [ ] 增强知识图谱的关系过滤、编辑和导出能力
+
+### v2.0(未来)
+
+- [ ] 支持 Docker 一键部署
+- [ ] 优化移动端与小屏阅读体验
+- [ ] 引入向量检索或本地语义索引
+- [ ] 支持团队共享文献库和多用户偏好
+
+## 许可证
+
+MIT License
+
+## 作者
+
+GitHub: [@DeLunnLi](https://github.com/DeLunnLi)  
+项目地址: [github.com/DeLunnLi/PaperGraph](https://github.com/DeLunnLi/PaperGraph)
+
+## 致谢
+
+感谢 Datawhale 社区和 Hello-Agents 项目。本项目基于 HelloAgents 的智能体、工具注册、上下文构建和熔断能力完成实践探索。

+ 82 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/.env.example

@@ -0,0 +1,82 @@
+# PaperGraph backend env template.
+# Copy to backend/.env and fill local secrets.
+
+# LLM API
+# OpenAI-compatible chat endpoint.
+LLM_API_KEY=your-llm-api-key
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_MODEL_ID=deepseek-v4-flash
+
+# Set to 1 if local proxy causes EOF/SSL errors.
+LLM_DISABLE_PROXY=1
+
+# Optional aliases:
+# OPENAI_API_KEY=your-openai-compatible-api-key
+# OPENAI_BASE_URL=https://api.openai.com/v1
+# OPENAI_MODEL=gpt-4o-mini
+# AIHUBMIX_API_KEY=your-aihubmix-api-key
+# AIHUBMIX_BASE_URL=https://aihubmix.com/v1
+# AIHUBMIX_MODEL_ID=your-model-id
+
+# Embedding API
+# Optional memory embeddings.
+EMBED_MODEL_TYPE=dashscope
+EMBED_MODEL_NAME=text-embedding-v4
+EMBED_API_KEY=your-embedding-api-key
+EMBED_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+
+# Academic search APIs
+NCBI_EMAIL=
+NCBI_API_KEY=
+
+# OpenAlex polite-pool email; blank is allowed.
+OPENALEX_MAILTO=
+
+# Optional web pre-search / proceedings discovery.
+TAVILY_PRESEARCH_ENABLED=true
+TAVILY_API_KEY=
+
+# App
+APP_NAME=PaperGraph
+APP_VERSION=0.1.0
+DEBUG=true
+
+HOST=0.0.0.0
+PORT=8000
+CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000
+
+# Default runtime data path is backend/data.
+# DATA_DIR=/absolute/path/to/DeLunnLi-PaperGraph/backend/data
+# DOWNLOADS_DIR=/absolute/path/to/DeLunnLi-PaperGraph/backend/downloads/papers
+
+LOG_LEVEL=INFO
+
+# Search / ranking tuning
+PAPERGRAPH_SEARCH_AGENT_WALL_SEC=420
+PAPERGRAPH_SEARCH_RECALL_WALL_SEC=60.0
+PAPERGRAPH_SEARCH_ARXIV_FALLBACK_WALL_SEC=15.0
+PAPERGRAPH_SEARCH_RECALL_HTTP_TIMEOUT_SEC=18.0
+PAPERGRAPH_SEARCH_HTTP_MAX_ATTEMPTS=2
+PAPERGRAPH_DBLP_MAX_ATTEMPTS=2
+
+PAPERGRAPH_RECALL_MAX_CANDIDATES=24
+PAPERGRAPH_FINE_RANK_CANDIDATES=15
+PAPERGRAPH_FINE_RANK_TIMEOUT_SEC=30
+PAPERGRAPH_FINE_RANK_ABSTRACT_CHARS=200
+PAPERGRAPH_FINE_RANK_PIPELINE_WALL_SEC=25.0
+
+PAPERGRAPH_PROCEEDINGS_SUPPLEMENT_ENABLED=true
+PAPERGRAPH_PROCEEDINGS_SUPPLEMENT_MIN_CANDIDATES=8
+PAPERGRAPH_PROCEEDINGS_AUTO_DISCOVER=true
+# PAPERGRAPH_TAVILY_VENUE_DOMAINS_JSON=/absolute/path/to/tavily_venue_domains.json
+
+# Daily recommendations
+PAPERGRAPH_DAILY_AUTO_REFRESH=true
+PAPERGRAPH_DAILY_AUTO_REFRESH_IDLE_SEC=90
+PAPERGRAPH_DAILY_AUTO_REFRESH_POLL_SEC=180
+PAPERGRAPH_DAILY_AUTO_REFRESH_STARTUP_GRACE_SEC=120
+PAPERGRAPH_DAILY_ARXIV_HTTP_TIMEOUT_SEC=45.0
+PAPERGRAPH_DAILY_ARXIV_HTTP_MAX_ATTEMPTS=3
+
+# Empty means built-in arXiv category defaults.
+DAILY_ARXIV_CS_CATEGORIES=

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/__init__.py


+ 30 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/__init__.py

@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from .knowledge_graph_agent import KnowledgeGraphAgent
+from .paper_analysis_agent import PaperAnalysisAgent
+from .search_agent import get_search_agent
+
+__all__ = [
+    "KnowledgeGraphAgent",
+    "PaperAnalysisAgent",
+    "get_knowledge_graph_agent",
+    "get_paper_analysis_agent",
+    "get_search_agent",
+]
+
+_paper_analysis_agent: PaperAnalysisAgent | None = None
+_knowledge_graph_agent: KnowledgeGraphAgent | None = None
+
+
+def get_paper_analysis_agent() -> PaperAnalysisAgent:
+    global _paper_analysis_agent
+    if _paper_analysis_agent is None:
+        _paper_analysis_agent = PaperAnalysisAgent()
+    return _paper_analysis_agent
+
+
+def get_knowledge_graph_agent() -> KnowledgeGraphAgent:
+    global _knowledge_graph_agent
+    if _knowledge_graph_agent is None:
+        _knowledge_graph_agent = KnowledgeGraphAgent()
+    return _knowledge_graph_agent

+ 36 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/base.py

@@ -0,0 +1,36 @@
+"""智能体基类 —— LLM 初始化、配置读取与通用工具方法."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from ..services.llm.llm_service import get_llm
+from ..settings import get_settings
+
+logger = logging.getLogger(__name__)
+
+class BaseAgent:
+    def __init__(self) -> None:
+        self._settings = get_settings()
+        self.llm = self._init_llm()
+
+    def _init_llm(self) -> Any:
+        try:
+            return get_llm()
+        except Exception as e:
+            logger.exception("[%s] LLM 初始化失败", type(self).__name__)
+            raise RuntimeError(f"{type(self).__name__}_llm_init_failed") from e
+
+    def _cfg(self, name: str, default: Any = None) -> Any:
+        return getattr(self._settings, name, default)
+
+    def _cfg_int(self, name: str, default: int = 0) -> int:
+        try:
+            return int(self._cfg(name, default))
+        except (TypeError, ValueError):
+            return int(default)
+
+    @staticmethod
+    def _clip(value: Any, limit: int) -> str:
+        return str(value or "").strip()[:limit]

+ 153 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/knowledge_graph_agent.py

@@ -0,0 +1,153 @@
+"""知识图谱智能体 —— 从已保存论文中抽取主题/方法/引用关系并构建可视化图谱."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+
+from hello_agents import SimpleAgent
+
+from ..utils import parse_llm_json
+from ..services.llm.agent_config import papergraph_agent_config
+from .base import BaseAgent
+from .prompts.knowledge_graph import REL_PROMPT
+
+logger = logging.getLogger(__name__)
+
+class KnowledgeGraphAgent(BaseAgent):
+    """知识图谱构建智能体 —— 从论文集中抽取关系、生成节点与边数据."""
+
+    def __init__(
+        self,
+        *,
+        agent: SimpleAgent | None = None,
+        min_score: float = 0.55,
+        max_edges: int = 12,
+        chunk_size: int = 20,
+    ) -> None:
+        super().__init__()
+        self.min_score = min_score
+        self.max_edges = max_edges
+        self.chunk_size = chunk_size
+        self._agent = agent or SimpleAgent(
+            name="papergraph_kg_rel",
+            llm=self.llm,
+            system_prompt=REL_PROMPT,
+            config=papergraph_agent_config(),
+        )
+
+    def _candidate_id(self, paper: dict[str, Any]) -> int | None:
+        for key in ("paper_id", "id", "target_paper_id"):
+            try:
+                value = int(paper.get(key))
+                if value > 0:
+                    return value
+            except (TypeError, ValueError):
+                continue
+        return None
+
+    def _compact_paper(self, paper: dict[str, Any]) -> dict[str, Any]:
+        out = {
+            "paper_id": self._candidate_id(paper),
+            "title": self._clip(paper.get("title"), 300),
+            "abstract": self._clip(paper.get("abstract"), 2000),
+            "keywords": list((paper.get("keywords") or [])[:12]),
+            "source": paper.get("source"),
+            "year": paper.get("year"),
+            "category": paper.get("category"),
+            "pdf_excerpt": self._clip(paper.get("pdf_excerpt"), 1200),
+            "related_work_excerpt": self._clip(paper.get("related_work_excerpt"), 1200),
+        }
+        return {k: v for k, v in out.items() if v not in (None, "", [], {})}
+
+    def _validate_edges(self, edges: Any, allowed_ids: set[int]) -> list[dict[str, Any]]:
+        if not isinstance(edges, list):
+            raise ValueError("edges is not a list")
+        best: dict[int, dict[str, Any]] = {}
+        for e in edges:
+            if not isinstance(e, dict):
+                continue
+            try:
+                tid = int(e.get("target_paper_id"))
+                score = float(e.get("score") or 0.0)
+            except Exception:
+                continue
+            if tid not in allowed_ids or score < self.min_score:
+                continue
+            relation = str(e.get("relation") or "").strip()
+            if not relation:
+                continue
+            edge = {"target_paper_id": tid, "relation": relation,
+                    "score": max(0.0, min(1.0, score)),
+                    "evidence": str(e.get("evidence") or "").strip()[:80]}
+            if tid not in best or score > best[tid]["score"]:
+                best[tid] = edge
+        return sorted(best.values(), key=lambda x: x["score"], reverse=True)
+
+    def _chunks(self, items: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
+        return [items[i : i + self.chunk_size] for i in range(0, len(items), self.chunk_size)]
+
+    def infer_edges(
+        self, *, new_paper: dict[str, Any], candidates: list[dict[str, Any]]
+    ) -> tuple[list[dict[str, Any]], str | None]:
+        compact_new = self._compact_paper(new_paper)
+
+        compact_candidates: list[dict[str, Any]] = []
+        for c in candidates:
+            tid = self._candidate_id(c)
+            if tid is None:
+                continue
+            item = self._compact_paper(c)
+            item["paper_id"] = tid
+            compact_candidates.append(item)
+
+        if not compact_candidates:
+            return [], None
+
+        merged: dict[int, dict[str, Any]] = {}
+
+        for chunk in self._chunks(compact_candidates):
+            payload = {"new_paper": compact_new, "candidates": chunk}
+            try:
+                raw = self._agent.run(json.dumps(payload, ensure_ascii=False))
+            except Exception as exc:
+                logger.exception("kg_llm_run_failed")
+                raise RuntimeError("kg_llm_run_failed") from exc
+
+            data = parse_llm_json(raw)
+            if data is None:
+                raise ValueError("kg_llm_parse_failed")
+
+            try:
+                valid_edges = self._validate_edges(
+                    data.get("edges"),
+                    allowed_ids={int(x["paper_id"]) for x in chunk},
+                )
+            except Exception as exc:
+                raise ValueError("kg_edge_validation_failed") from exc
+
+            for edge in valid_edges:
+                tid = edge["target_paper_id"]
+                if tid not in merged or edge["score"] > merged[tid]["score"]:
+                    merged[tid] = edge
+
+        edges = sorted(merged.values(), key=lambda x: x["score"], reverse=True)[: self.max_edges]
+
+        try:
+            from ..services.memory.agent_memory import get_agent_memory
+
+            am = get_agent_memory()
+            title = str(new_paper.get("title") or "")[:120]
+            am.add(agent_name="knowledge_graph", content=f"关系抽取:{title}", memory_type="working", importance=0.45, shared=False)
+            if edges:
+                am.add(
+                    agent_name="knowledge_graph",
+                    content=f"关系抽取要点:top_relation={edges[0].get('relation')} score={edges[0].get('score')}",
+                    memory_type="working",
+                    importance=0.5,
+                    shared=True,
+                )
+        except Exception:
+            logger.debug("kg_memory_write_failed", exc_info=True)
+        return edges, None

+ 773 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/paper_analysis_agent.py

@@ -0,0 +1,773 @@
+"""论文分析智能体 —— PDF 全文解析、表格提取与深度问答."""
+
+from __future__ import annotations
+
+import logging
+import re
+import threading
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from hello_agents import SimpleAgent
+from hello_agents.tools.registry import ToolRegistry
+
+from app.core.paper_paths import normalize_library_category_display
+
+from ..utils import parse_llm_json
+from ..services.llm.agent_config import papergraph_agent_config
+from ..services.llm.llm_service import is_llm_configured, coerce_hello_agents_llm_output_to_str
+from .base import BaseAgent
+from .support.paper_analysis_helpers import (
+    clip_text as _clip,
+    clip_reader_history as _clip_reader_history,
+    clean_library_tag as _clean_library_tag,
+    dedupe_tags as _dedupe_tags,
+    nearest_major_in as _nearest_major_in,
+    parse_taxonomy_majors as _parse_taxonomy_majors,
+    prioritize_reader_context as _prioritize_reader_context,
+    top_similar_categories as _top_similar_categories,
+)
+from .support.reader_pdf_parse_tool import ReaderPdfParseTool
+from .support.reader_table_tool import ReaderTableTool
+from .support.reader_paper_lookup_tool import ReaderPaperLookupTool, ground_score_paper_vs_reference_blob
+from .support.reader_reference_lookup_tool import (
+    READER_RECOMMEND_MAX_RESULTS, ReaderReferenceLookupTool,
+    READER_RELATED_FROM_BIBLIOGRAPHY, READER_RELATED_FROM_REF_BLOCK,
+    paper_matches_reader_snap, parse_reader_recommendation_intent,
+    prioritize_reader_related_pairs_refs_first, reader_user_allows_external_paper_lookup,
+    rerank_reader_pairs_by_anchor_refs_first, resolve_references_via_openalex,
+    strip_reader_reco_boilerplate, user_message_may_need_reference_lookup,
+    READER_RELATED_FROM_PRE_SEARCH,
+)
+
+from .prompts.paper_analysis import ANALYSIS_SYSTEM, READER_CHAT_SYSTEM
+
+logger = logging.getLogger(__name__)
+
+def _reader_resolve_user_hint(user_message: str, snap: Dict[str, Any], *, want_reco: bool) -> str:
+    um = (user_message or "").strip()
+    if not want_reco:
+        return um[:900]
+    core = strip_reader_reco_boilerplate(um) or um
+    if len(core) >= 28:
+        return um[:900]
+    title = str(snap.get("title") or "").strip()
+    ab = str(snap.get("abstract") or "").strip()[:520]
+    bits = [x for x in (core, title, ab) if x]
+    return "\n".join(bits).strip()[:900] or um[:900]
+
+_MAJOR_LOCK = threading.Lock()
+_MAJOR_WHITELIST: Optional[Tuple[str, ...]] = None
+
+@dataclass
+class TaskSpec:
+    name: str
+    agent: Any
+    parser: Optional[Callable[[str], Any]] = None
+    max_chars: int = 6000
+
+class PaperAnalysisAgent(BaseAgent):
+    """论文分析核心智能体 —— 管理多个子 Agent 协同完成 PDF 解析、表格提取与问答."""
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._analysis = SimpleAgent(
+            name="papergraph_analysis",
+            llm=self.llm,
+            system_prompt=ANALYSIS_SYSTEM,
+            config=papergraph_agent_config(),
+        )
+        self._reader_lookup_lock = threading.Lock()
+        self._reader_lookup_buffer: List[Tuple[Any, str]] = []
+        self._reader_snap: Dict[str, Any] = {}
+        self._reader_last_user_message: str = ""
+
+        self._reader_reco_ref_offset: Dict[int, int] = {}
+        _reader_reg = ToolRegistry()
+        _reader_reg.register_tool(
+            ReaderPaperLookupTool(
+                on_papers_found=self._reader_tool_on_found,
+                get_snap=lambda: getattr(self, "_reader_snap", None) or {},
+            )
+        )
+        _reader_reg.register_tool(
+            ReaderReferenceLookupTool(
+                get_snap=lambda: getattr(self, "_reader_snap", None) or {},
+                on_papers_found=self._reader_tool_on_found,
+                get_user_message=lambda: getattr(self, "_reader_last_user_message", "") or "",
+            )
+        )
+        _reader_reg.register_tool(
+            ReaderPdfParseTool(
+                get_snap=lambda: getattr(self, "_reader_snap", None) or {},
+                on_parsed=self._reader_on_pdf_structure,
+            )
+        )
+        _reader_reg.register_tool(
+            ReaderTableTool(
+                get_snap=lambda: getattr(self, "_reader_snap", None) or {},
+            )
+        )
+        self._reader = SimpleAgent(
+            name="papergraph_paper_reader",
+            llm=self.llm,
+            system_prompt=READER_CHAT_SYSTEM,
+            config=papergraph_agent_config(),
+            tool_registry=_reader_reg,
+            enable_tool_calling=True,
+            max_tool_iterations=5,
+        )
+
+    def _ensure_major_whitelist(self) -> None:
+        global _MAJOR_WHITELIST
+        if _MAJOR_WHITELIST is not None:
+            return
+        with _MAJOR_LOCK:
+            if _MAJOR_WHITELIST is not None:
+                return
+            wl: Optional[Tuple[str, ...]] = None
+            taxonomy_prompt = (
+                "# 任务:为个人/小团队文献库生成顶层大类\n"
+                '输出: {"majors":["名称1",...]}\n'
+                "- 共 16~24 条;恰含一个「未分类」\n"
+                "- 名称须具学术划分意义,覆盖计算机与交叉学科\n"
+                "- 每条 2~10 个中文字;互异;禁含「/」及路径非法字符\n"
+            )
+            try:
+                raw = self._analysis.run(taxonomy_prompt)
+                parsed = _parse_taxonomy_majors(raw)
+                if not parsed:
+                    self._analysis.run(taxonomy_prompt + '\n请确保输出合法 JSON。')
+                if parsed:
+                    wl = tuple(parsed)
+            except Exception:
+                logger.exception("major_taxonomy_bootstrap_failed")
+            if not wl:
+                raise RuntimeError("major_taxonomy_bootstrap_failed")
+            _MAJOR_WHITELIST = wl
+            logger.info("paper_analysis_major_whitelist_ready", extra={"n": len(wl)})
+
+    def _get_major_whitelist(self) -> Tuple[str, ...]:
+        self._ensure_major_whitelist()
+        if _MAJOR_WHITELIST is None:
+            raise RuntimeError("major_whitelist_unavailable")
+        return _MAJOR_WHITELIST
+
+    def _cleanup_mixed_reader_response(self, reply: str, user_message: str) -> str:
+        """Use one cleanup pass when tool output leaks into the final reply."""
+        t = reply.strip()
+        # Typical leak: disclaimer plus useful data, or raw tool JSON.
+        has_disclaimer = any(x in t[:300] for x in ("材料不足", "缺少", "仅有标题")) or bool(re.search(r"基于.*推测", t[:300]))
+        has_actual_data = len(t) > 600 and any(x in t for x in ("Tab.", "Table", "结果", "实验", "| "))
+        has_tool_artifact = "reader_pdf_struct" in t or '"chapters"' in t[:500]
+
+        if (has_disclaimer and has_actual_data) or has_tool_artifact:
+            if not is_llm_configured():
+                return reply
+            logger.info("paper_reader: detected mixed response, running cleanup pass")
+            try:
+                um = (user_message or "").strip()[:200]
+                # Keep extracted paper facts; drop wrapper noise.
+                cleaned = re.sub(r"^.*?(?:当前文献材料说明|当前提供的材料).*?\n\n", "", t, flags=re.S)
+                cleaned = re.sub(r"reader_pdf_struct\S*", "", cleaned)
+                cleaned = cleaned.strip()[:6000]
+                if cleaned:
+                    prompt = (
+                        f"用户问:{um}\n\n"
+                        f"以下是从论文中提取的信息(可能含多个片段):\n\n{cleaned}\n\n"
+                        "请整合成一个连贯的回答。用中文分点说明。如有表格数据用 Markdown 表格呈现。"
+                        "不要提及'材料不足'或'推测'——只基于已有信息回答,不确定的地方标注'论文未提供'。"
+                    )
+                    result = self._reader_chat_llm(prompt)
+                    if result.strip():
+                        return result.strip()
+            except Exception:
+                logger.debug("cleanup_mixed_response_failed", exc_info=True)
+        return reply
+
+    @staticmethod
+    def _looks_like_raw_tool_output(text: str) -> bool:
+        """Detect raw tool output that still needs interpretation."""
+        t = (text or "").strip()
+        if not t:
+            return False
+        if "reader_pdf_structure" in t:
+            return True
+        if t.startswith("## ") and len(t) > 300:
+            first_line = t.split("\n")[0]
+            if re.match(r"^## \d", first_line) or re.match(r"^## [A-Z]", first_line):
+                return True
+        if '"chapters"' in t[:500] or '"references"' in t[:500]:
+            return True
+        return False
+
+    def _interpret_tool_output(self, tool_output: str, user_message: str) -> str:
+        """Convert raw tool output into a user-facing answer."""
+        if not is_llm_configured():
+            return tool_output
+        try:
+            um = (user_message or "").strip()[:200]
+            # Strip tool wrappers before asking the LLM to explain.
+            cleaned = re.sub(r"^.*?reader_pdf_structure[::]\s*", "", tool_output, flags=re.S)
+            cleaned = re.sub(r"^以下为 JSON.*?\n", "", cleaned)
+            cleaned = re.sub(r"^\s*\{\s*\"chapters\".*?\n", "", cleaned)
+            cleaned = re.sub(r"reader_pdf_structu\S*$", "", cleaned)
+            cleaned = re.sub(r"[\u007F-\u009F]", "", cleaned)
+            cleaned = cleaned.strip()
+            if not cleaned or len(cleaned) < 50:
+                return "当前文献材料不足以回答该问题。PDF 文本提取不完整,建议确认 PDF 文件是否可读。"
+            cleaned = cleaned[:6000]
+            prompt = (
+                f"用户问:{um}\n\n"
+                f"以下是从论文中提取的相关章节内容:\n\n{cleaned}\n\n"
+                "请用中文为用户解读这段内容。分点说明关键发现、方法和结论。"
+                "用 Markdown 表格对比数据(如有)。\n"
+                "重要:如果上面的内容不足以回答用户问题(如仅有章节标题无正文),"
+                "请直接说明材料不足,严禁编造论文中不存在的数据、方法或结论。"
+            )
+            raw = self._reader_chat_llm(prompt)
+            return raw.strip() or tool_output
+        except Exception:
+            logger.debug("interpret_tool_output_failed", exc_info=True)
+            return tool_output
+
+    def _reader_chat_llm(self, prompt: str) -> str:
+        """Reader chat without tools."""
+        from ..services.llm.llm_service import coerce_hello_agents_llm_output_to_str
+        if not hasattr(self, "_reader_interpreter"):
+            from hello_agents import SimpleAgent
+            self._reader_interpreter = SimpleAgent(
+                name="papergraph_reader_interpreter",
+                llm=self.llm,
+                system_prompt=READER_CHAT_SYSTEM,
+                config=papergraph_agent_config(),
+                enable_tool_calling=False,
+            )
+        return coerce_hello_agents_llm_output_to_str(self._reader_interpreter.run(prompt))
+
+    def _reader_tool_on_found(self, papers: List[Any], source: str) -> None:
+        with self._reader_lookup_lock:
+            for p in papers or []:
+                self._reader_lookup_buffer.append((p, source))
+
+    def _reader_on_pdf_structure(self, obj: Dict[str, Any]) -> None:
+        try:
+            snap = getattr(self, "_reader_snap", None)
+            if not isinstance(snap, dict):
+                return
+            refs = obj.get("references") or {}
+            entries = refs.get("entries")
+            if isinstance(entries, list) and entries:
+                snap["references_from_structure"] = [str(x).strip() for x in entries if str(x).strip()]
+        except Exception:
+            logger.debug("reader_on_pdf_structure_failed", exc_info=True)
+
+    @staticmethod
+    def _dedupe_reader_paper_pairs(buffer: List[Tuple[Any, str]]) -> List[Tuple[Any, str]]:
+        seen: set[str] = set()
+        out: List[Tuple[Any, str]] = []
+        for p, src in buffer:
+            k = str(getattr(p, "title", "") or "").strip().lower()
+            if not k or k in seen:
+                continue
+            seen.add(k)
+            out.append((p, src))
+        return out
+    def _run_task(self, spec: TaskSpec, user: str) -> Any:
+        prompt = _clip(user, spec.max_chars)
+        try:
+
+            raw = spec.agent.run(prompt)
+        except Exception as exc:
+            logger.exception("paper_analysis_llm_failed", extra={"task": spec.name})
+            raise RuntimeError(f"paper_analysis_llm_failed:{spec.name}") from exc
+
+        raw_text = (raw if isinstance(raw, str) else str(raw or "")).strip()
+
+        if not spec.parser:
+            if not raw_text:
+                if spec.name == "paper_reader_reply":
+                    return ""
+                raise RuntimeError(f"paper_analysis_empty_response:{spec.name}")
+
+            # Some tool responses need a final explanation pass.
+            if spec.name == "paper_reader_reply" and self._looks_like_raw_tool_output(raw_text):
+                logger.info("paper_reader: detected raw tool output, invoking interpreter")
+                raw_text = self._interpret_tool_output(raw_text, user)
+
+            return raw_text
+
+        data = spec.parser(raw_text)
+        if data is not None:
+            return data
+
+        try:
+            raw2 = spec.agent.run("请只输出合法 JSON。\n" + prompt)
+            data2 = spec.parser((raw2 or "").strip())
+            if data2 is not None:
+                return data2
+        except Exception:
+            logger.exception("paper_analysis_json_retry_failed", extra={"task": spec.name})
+        raise RuntimeError(f"paper_analysis_parse_failed:{spec.name}")
+
+    def _record_preference_signals(
+        self,
+        *,
+        signal: str,
+        title: Optional[str] = None,
+        tags: Optional[List[str]] = None,
+        category: Optional[str] = None,
+        major: Optional[str] = None,
+        shared: bool = True,
+    ) -> None:
+        try:
+            from ..services.memory.agent_memory import get_agent_memory
+
+            am = get_agent_memory()
+            parts: List[str] = [f"偏好信号({signal})"]
+            if title:
+                parts.append(f"title={str(title).strip()[:120]}")
+            if major:
+                parts.append(f"major={str(major).strip()[:24]}")
+            if category:
+                parts.append(f"cat={str(category).strip()[:40]}")
+            if tags:
+                clean = [str(x).strip() for x in (tags or []) if str(x).strip()][:10]
+                if clean:
+                    parts.append("tags=" + ",".join(clean))
+            line = " | ".join(parts)[:360]
+            am.add(agent_name="paper_analysis", content=line, memory_type="working", importance=0.55, shared=bool(shared))
+        except Exception:
+            return
+
+    def _parse_major_json(self, raw: str) -> Optional[str]:
+        d = parse_llm_json(raw)
+        if not isinstance(d, dict):
+            return None
+        m = str(d.get("major") or d.get("category") or "").strip()
+        if not m:
+            return None
+        return _nearest_major_in(m, self._get_major_whitelist())
+
+    def _parse_fine_classify_json(self, raw: str, major: str) -> Optional[Tuple[str, List[str]]]:
+        d = parse_llm_json(raw)
+        if not isinstance(d, dict):
+            return None
+        cat = normalize_library_category_display(str(d.get("category") or "未分类"))
+        tags_raw = d.get("tags")
+        extra: List[str] = []
+        if isinstance(tags_raw, list):
+            for x in tags_raw:
+                c = _clean_library_tag(str(x))
+                if c:
+                    extra.append(c)
+            extra = _dedupe_tags(extra)
+        if not cat:
+            return None
+        if major and major != "未分类" and not (cat.startswith(major) or major in cat):
+            cat = normalize_library_category_display(f"{major}/{cat.split('/')[-1]}")
+        return cat, extra
+
+    _venue_type_cache: dict[str, str] = {}
+
+    def classify_venue_type(self, journal: str | None) -> str | None:
+        if not journal or not str(journal).strip():
+            return None
+        j = str(journal).strip()
+        if j.startswith("arXiv:"):
+            return "preprint"
+        if j in self._venue_type_cache:
+            return self._venue_type_cache[j]
+        try:
+            prompt = f'判断以下学术来源名称是会议(conference)还是期刊(journal)。只回复一个单词:conference 或 journal。\n\n名称:{j}'
+            resp = self._analysis.run(prompt)
+            result = str(resp).strip().lower()
+            if "conference" in result:
+                vt = "conference"
+            elif "journal" in result:
+                vt = "journal"
+            else:
+                vt = None
+        except Exception:
+            vt = None
+        if vt:
+            self._venue_type_cache[j] = vt
+        return vt
+
+    def classify_for_library(
+        self,
+        title: str,
+        abstract: Optional[str],
+        journal: Optional[str],
+        keywords: Optional[List[str]] = None,
+        existing_categories: Optional[List[str]] = None,
+    ) -> Tuple[str, List[str]]:
+        kw = "、".join(keywords or []) or "(无)"
+        journal = journal or "(无)"
+        abstract = (abstract or "").strip() or "(无摘要)"
+        seed = f"{title}\n{abstract[:800]}"
+
+        cats_all = [str(x).strip() for x in (existing_categories or []) if str(x).strip()]
+        candidates = _top_similar_categories(seed, cats_all, k=18)
+
+        base_user = f"标题:{title}\n摘要:{abstract}\n来源:{journal}\n关键词:{kw}"
+
+        wl = self._get_major_whitelist()
+        major_list_block = "\n".join(f"- {m}" for m in wl)
+        major_user = f"{base_user}\n\n【可选大类列表】\n{major_list_block}"
+
+        major_fb = "未分类"
+        major_user = (
+            "# 任务:归类(大类)\n"
+            "从【可选大类列表】中选一个最匹配的大类\n"
+            '输出: {"major":"大类名"}\n'
+            '- 必须从列表选;优先字面匹配,否则语义最接近;无法判断→"未分类";禁列表外值\n\n'
+            + major_user
+        )
+        major_spec = TaskSpec(
+            name="classify_major",
+            agent=self._analysis,
+            parser=self._parse_major_json,
+            max_chars=5200,
+        )
+        major = self._run_task(major_spec, major_user)
+        if not isinstance(major, str) or not major.strip():
+            major = major_fb
+        major = _nearest_major_in(major, wl)
+
+        if not candidates:
+            cat0 = normalize_library_category_display(major)
+            self._record_preference_signals(signal="classify", title=title, major=major, category=cat0, shared=True)
+            return cat0, []
+
+        prefixed = [c for c in candidates if c.startswith(major) or c.split("/")[0] == major]
+        pool = prefixed if len(prefixed) >= 2 else candidates
+        pool_block = "\n".join(f"- {c}" for c in pool[:18])
+
+        fine_user = (
+            "# 任务:归类(路径与标签)\n"
+            "给定大类,从候选已有路径中选择路径,生成标签\n"
+            '输出: {"category":"路径","tags":["标签1",...]}\n'
+            "- category:优先原样选候选;否则「大类/子类」,子类 2~8 个中文字\n"
+            "- tags:3~8 个,单条 ≤24 字,不重复;无法判断可为 []\n"
+            "- 禁含路径非法字符 \\ / : * ? \" < > |\n\n"
+            f"{base_user}\n\n给定大类:{major}\n\n"
+            f"【候选已有路径】(请优先从中复制一条作为 category)\n{pool_block}"
+        )
+        default_cat = normalize_library_category_display(major)
+
+        def _fine_parser(raw: str) -> Optional[Tuple[str, List[str]]]:
+            return self._parse_fine_classify_json(raw, major)
+
+        fine_spec = TaskSpec(
+            name="classify_fine",
+            agent=self._analysis,
+            parser=_fine_parser,
+            max_chars=5500,
+        )
+        out = self._run_task(fine_spec, fine_user)
+        if isinstance(out, tuple) and len(out) == 2:
+            cat, tags = out[0], out[1]
+            cat = normalize_library_category_display(str(cat or "未分类"))
+            if isinstance(tags, list):
+                cleaned: List[str] = []
+                for x in tags:
+                    c = _clean_library_tag(str(x))
+                    if c:
+                        cleaned.append(c)
+                tags = _dedupe_tags(cleaned)
+            else:
+                tags = []
+            self._record_preference_signals(signal="classify", title=title, major=major, category=cat, tags=tags, shared=True)
+            return cat, tags
+        return default_cat, []
+
+    def paper_reader_reply(
+        self,
+        context_block: str,
+        history_lines: str,
+        user_message: str,
+        reader_snap: Optional[Dict[str, Any]] = None,
+    ) -> Tuple[str, List[Any], List[str]]:
+        snap: Dict[str, Any] = dict(reader_snap or {})
+        self._reader_snap = snap
+        reco_pid: Optional[int] = None
+        try:
+            spid = snap.get("paper_id")
+            if spid is not None and int(spid) > 0:
+                reco_pid = int(spid)
+        except (TypeError, ValueError):
+            reco_pid = None
+        try:
+            with self._reader_lookup_lock:
+                self._reader_lookup_buffer.clear()
+            ctx = _prioritize_reader_context(context_block, max_chars=3600)
+            hist = _clip_reader_history((history_lines or "").strip() or "(尚无此前对话)", max_chars=2200)
+            um = _clip(user_message, 900)
+            want_reco, reco_max = parse_reader_recommendation_intent(um)
+            self._reader_last_user_message = um
+
+            try:
+                from ..services.memory.agent_memory import get_agent_memory
+
+                mem_block = get_agent_memory().build_context_block(agent_name="paper_analysis", query=um)
+            except Exception:
+                mem_block = ""
+            user = (
+                (f"【共享/独立记忆】\n{mem_block}\n\n" if mem_block else "")
+                + f"【当前文献材料】\n{ctx}\n\n"
+                + f"【对话历史】\n{hist}\n\n"
+                + f"【用户最新问题】\n{um}"
+            )
+            spec = TaskSpec(
+                name="paper_reader_reply",
+                agent=self._reader,
+                parser=None,
+                max_chars=7200,
+            )
+            out = self._run_task(spec, user)
+            # Clean up mixed tool/user-facing output.
+            if isinstance(out, str) and out.strip():
+                out = self._cleanup_mixed_reader_response(out, um)
+            with self._reader_lookup_lock:
+                raw_pairs = list(self._reader_lookup_buffer)
+                self._reader_lookup_buffer.clear()
+            pairs = self._dedupe_reader_paper_pairs(raw_pairs)
+            rb_pdf = str(snap.get("references_section_raw") or "").strip()
+
+            if (
+                len(rb_pdf) >= 140
+                and not (snap.get("references") or [])
+            ):
+                _thr_bib = 0.48 if want_reco else 0.54
+                pairs = [
+                    (p, s) for p, s in pairs
+                    if s != READER_RELATED_FROM_BIBLIOGRAPHY
+                    or ground_score_paper_vs_reference_blob(p, rb_pdf) >= _thr_bib
+                ]
+            if user_message_may_need_reference_lookup(um) and len(pairs) == 0:
+                try:
+                    fb_max = reco_max if want_reco else 2
+                    resolve_mr = max(fb_max, min(READER_RECOMMEND_MAX_RESULTS, fb_max * 4)) if want_reco else fb_max
+                    extra: List[Any] = []
+                    if snap.get("references"):
+                        refs_full = [str(x).strip() for x in (snap.get("references") or []) if str(x).strip()]
+                        off = self._reader_reco_ref_offset.get(reco_pid, 0) if reco_pid else 0
+                        snap_res: Dict[str, Any] = snap
+                        if want_reco and reco_pid and refs_full and off >= len(refs_full):
+                            off = 0
+                            self._reader_reco_ref_offset[reco_pid] = 0
+                        if want_reco and reco_pid and off > 0 and off < len(refs_full):
+                            snap_res = dict(snap)
+                            snap_res["references"] = refs_full[off:]
+                        extra = resolve_references_via_openalex(
+                            snap_res,
+                            max_results=resolve_mr,
+                            user_hint=_reader_resolve_user_hint(um, snap, want_reco=want_reco),
+                        )
+                        if extra and want_reco and reco_pid:
+                            self._reader_reco_ref_offset[reco_pid] = off + max(1, len(extra))
+                    elif (snap.get("references_section_raw") or "").strip():
+                        from ..services.reader.paper_reader_context import reference_strings_for_resolve_fallback
+
+                        ref_lines = reference_strings_for_resolve_fallback(
+                            str(snap.get("references_section_raw") or "")
+                        )
+                        rs_struct = snap.get("references_from_structure")
+                        if isinstance(rs_struct, list) and rs_struct:
+                            ref_lines = [str(x).strip() for x in rs_struct if str(x).strip()] or ref_lines
+                        ref_core = list(ref_lines)
+                        if ref_core and is_llm_configured():
+                            try:
+                                from ..services.reader.reader_recommend_llm import merge_ref_lines_with_llm_queries
+
+                                merged = merge_ref_lines_with_llm_queries(
+                                    str(snap.get("references_section_raw") or ""),
+                                    snap,
+                                    ref_core,
+                                    max_queries=12,
+                                )
+                                ref_lines = merged if merged else ref_core
+                            except Exception:
+                                logger.debug("merge_llm_ref_queries_failed", exc_info=True)
+                                ref_lines = ref_core
+                        else:
+                            ref_lines = ref_core
+                        if ref_lines:
+                            off = self._reader_reco_ref_offset.get(reco_pid, 0) if reco_pid else 0
+                            if want_reco and reco_pid and off >= len(ref_lines):
+                                off = 0
+                                self._reader_reco_ref_offset[reco_pid] = 0
+                            if want_reco and reco_pid and off > 0:
+                                ref_lines = ref_lines[off:]
+                            if ref_lines:
+                                snap_fb = dict(snap)
+                                snap_fb["references"] = ref_lines
+                                extra = resolve_references_via_openalex(
+                                    snap_fb,
+                                    max_results=resolve_mr,
+                                    user_hint=_reader_resolve_user_hint(um, snap, want_reco=want_reco),
+                                )
+                                rb = str(snap.get("references_section_raw") or "").strip()
+                                if extra and len(rb) >= 140 and not (snap.get("references") or []):
+                                    raw_extra = list(extra)
+
+                                    def _gf(th: float) -> List[Any]:
+                                        return [
+                                            p
+                                            for p in raw_extra
+                                            if ground_score_paper_vs_reference_blob(p, rb) >= th
+                                        ]
+
+                                    extra = _gf(0.54)
+                                    if not extra and want_reco:
+                                        extra = _gf(0.42)
+                                    if not extra and want_reco and raw_extra:
+                                        extra = list(raw_extra)[: max(1, min(len(raw_extra), fb_max))]
+                                if extra and want_reco and reco_pid:
+                                    self._reader_reco_ref_offset[reco_pid] = off + max(1, len(extra))
+                except Exception:
+                    logger.debug("reader_reference_server_fallback_failed", exc_info=True)
+
+            bib_only = (
+                (want_reco or user_message_may_need_reference_lookup(um))
+                and not reader_user_allows_external_paper_lookup(um)
+            )
+            pairs = [(p, s) for p, s in pairs if not paper_matches_reader_snap(snap, p)]
+            if bib_only:
+                pairs = [
+                    (p, s)
+                    for p, s in pairs
+                    if s in (READER_RELATED_FROM_BIBLIOGRAPHY, READER_RELATED_FROM_REF_BLOCK, READER_RELATED_FROM_PRE_SEARCH)
+                ]
+            if want_reco and pairs:
+                try:
+                    if is_llm_configured():
+                        from ..services.reader.reader_recommend_llm import rerank_reader_recommend_pairs_by_llm
+
+                        pairs = rerank_reader_recommend_pairs_by_llm(
+                            snap,
+                            pairs,
+                            user_message=um,
+                            history_lines=hist,
+                            reco_max_hint=reco_max,
+                        )
+                    else:
+                        pairs = rerank_reader_pairs_by_anchor_refs_first(snap, pairs, k=reco_max)
+                except Exception:
+                    logger.debug("reader_llm_recommend_rerank_failed", exc_info=True)
+                    pairs = rerank_reader_pairs_by_anchor_refs_first(snap, pairs, k=reco_max)
+            if pairs:
+                pairs = prioritize_reader_related_pairs_refs_first(pairs)
+            if want_reco and pairs:
+                cap = max(1, min(int(reco_max or 2), READER_RECOMMEND_MAX_RESULTS, len(pairs)))
+                pairs = pairs[:cap]
+            papers = [p for p, _ in pairs]
+            provenances = [s for _, s in pairs]
+            text_out = (str(out) if out is not None else "").strip()
+            if not text_out:
+                if papers:
+                    if snap.get("references_source") == "pdf_section":
+                        text_out = (
+                            "已根据 PDF 参考文献区摘录检索到相关论文,请点击下方「推荐论文」查看条目;"
+                            "如需结合摘要或方法做对比,请告诉我关注点。"
+                        )
+                    else:
+                        text_out = (
+                            "已根据参考文献检索列出相关论文,请点击下方「推荐论文」查看条目;"
+                            "如需结合摘要或方法做对比,请告诉我关注点。"
+                        )
+                elif user_message_may_need_reference_lookup(um) and not (snap.get("references") or []) and not (
+                    snap.get("references_section_raw") or ""
+                ).strip():
+                    text_out = (
+                        "未在库表中找到 references,且当前 PDF 摘录中未能定位到「参考文献 / References」标题后的文本块,"
+                        "无法从原文区检索。若为扫描版、参考文献不在已抽取页范围内,或版式特殊,会出现此情况。"
+                        "可从带参考文献的数据源重新保存该文,或直接粘贴英文题名 / DOI 以便检索。"
+                    )
+                elif user_message_may_need_reference_lookup(um) and (snap.get("references_section_raw") or "").strip():
+                    text_out = (
+                        "上下文中已含 PDF 参考文献区原文,但本轮未产生可展示的检索命中。"
+                        "你可指定要查的一条英文题名或 DOI;或让我从摘录中逐条用检索工具核对。"
+                    )
+                elif user_message_may_need_reference_lookup(um):
+                    text_out = (
+                        "已按库表参考文献题录在 OpenAlex / arXiv / DBLP 中尝试解析,但未找到与题录足够一致的条目"
+                        "(已过滤明显不符的综述/泛命中)。你可粘贴 DOI 或标准英文题名,我会用 reader_paper_lookup 检索并展示在下方列表。"
+                    )
+                else:
+                    text_out = "(本次未收到模型有效正文。请稍后重试,或缩短问题后再次提问。)"
+
+            try:
+                from ..services.memory.agent_memory import get_agent_memory
+
+                am = get_agent_memory()
+                am.add(agent_name="paper_analysis", content=f"用户问:{um}", memory_type="working", importance=0.55, shared=False)
+                am.add(agent_name="paper_analysis", content=f"助手答:{text_out[:240]}", memory_type="working", importance=0.5, shared=False)
+                am.add(agent_name="paper_analysis", content=f"阅读问答要点:{text_out[:180]}", memory_type="working", importance=0.55, shared=True)
+            except Exception:
+                pass
+
+            logger.info("reader_post: text_out_len=%d papers=%d want_reco=%s",
+                        len(text_out or ""), len(papers), want_reco)
+            if text_out and len(text_out) >= 80:
+                try:
+                    prompt = (
+                        "从以下学术助手的回复中,提取被推荐的论文信息。\n"
+                        "返回纯 JSON 数组,每项可含 title(英文题名)和/或 arxiv_id(如 2307.05973)。\n"
+                        '格式:[{"title": "...", "arxiv_id": "..."}, ...]\n'
+                        "若回复未推荐具体论文,返回 []。\n\n"
+                        "回复原文:\n" + text_out[:3000]
+                    )
+                    raw = self.llm.invoke([{"role": "user", "content": prompt}])
+                    llm_text = coerce_hello_agents_llm_output_to_str(raw)
+                    import json as _json
+                    extracted = _json.loads(llm_text.strip().removeprefix("```json").removesuffix("```").strip())
+                    if isinstance(extracted, list) and extracted:
+                        logger.info("reader_llm_extract: got %d papers from LLM", len(extracted))
+                        try:
+                            from app.api.dependencies import get_searcher as _es
+                            from app.services.papers.papers_converters import litpaper_to_api_paper as _ep
+                            ese = _es()
+                            existing_titles = {str(getattr(p, "title", "") or "").strip().lower() for p in papers}
+                            existing_titles.add(str(snap.get("title") or "").strip().lower())
+                            for item in extracted[:6]:
+                                if not isinstance(item, dict):
+                                    continue
+                                title = str(item.get("title") or "").strip()
+                                axid = str(item.get("arxiv_id") or "").strip()
+
+                                if axid and re.match(r"^\d{4}\.\d{4,5}", axid):
+                                    try:
+                                        for fp in (ese.search_arxiv("", max_results=2, arxiv_id_list=axid,
+                                                http_timeout_sec=8, http_max_attempts=1) or []):
+                                            afp = _ep(fp)
+                                            tafp = str(getattr(afp, "title", "") or "").strip().lower()
+                                            if tafp and tafp not in existing_titles:
+                                                existing_titles.add(tafp)
+                                                papers.insert(0, afp)
+                                                provenances.insert(0, READER_RELATED_FROM_PRE_SEARCH)
+                                                logger.info("reader_llm_extract: added by arxiv %s", axid)
+                                    except Exception:
+                                        continue
+
+                                if title and len(title) >= 4:
+                                    try:
+                                        for fp in (ese.search_openalex(title, max_results=2, venue_proceedings_journal=False) or []):
+                                            afp = _ep(fp)
+                                            tafp = str(getattr(afp, "title", "") or "").strip().lower()
+                                            if tafp and tafp not in existing_titles:
+                                                existing_titles.add(tafp)
+                                                papers.insert(0, afp)
+                                                provenances.insert(0, READER_RELATED_FROM_PRE_SEARCH)
+                                                logger.info("reader_llm_extract: added by title %s", tafp[:80])
+                                    except Exception:
+                                        continue
+                        except Exception as e:
+                            logger.warning("reader_llm_extract_search_failed: %s", e)
+                except Exception as e:
+                    logger.warning("reader_llm_extract_failed: %s", e)
+
+            return (text_out, papers, provenances)
+        finally:
+            self._reader_snap = {}

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/__init__.py


+ 26 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/knowledge_graph.py

@@ -0,0 +1,26 @@
+
+REL_PROMPT = """# Role: 学术知识图谱抽取专家(高精度、稀疏、低噪)
+
+## Input
+- New_Paper: title, abstract, keywords, venue, year, category;可选 pdf_excerpt、related_work_excerpt。
+- Candidates: 每项 paper_id, title, abstract, keywords, venue, year, category。
+
+## Workflow & Task
+遍历每个 candidate,仅在**强证据**下输出一条单向边;无则 `{"edges": []}`。只输出 JSON,无 Markdown、无解释。
+
+## Relation(优先级高→低,每 target 仅一条)
+improves > extends > uses > compares > surveys > references > dataset_overlap > method_overlap > task_overlap
+- improves / extends / uses / compares:须在 pdf_excerpt 或 abstract(或 related_work_excerpt)中有**显式**论文名/方法名/实验对比(表、baseline)之一;否则禁止输出这四类。
+- surveys:new 为 survey/review/tutorial 且系统讨论 candidate 方向/方法。
+- references:背景或相关工作**明确提及**,不满足更强类。
+- *_overlap:仅核心机制/非通用数据资源/特定任务定义的重合;通用骨干(Transformer/CNN/GNN)、常见 benchmark(ImageNet/CIFAR 等)、泛同领域**不构成** overlap。
+
+## Rules
+- 不确定不输出;禁常识/语义相似/venue推测/embedding相似推测。
+- evidence≤80字;须含来源位置+动作+对象;禁笼统句。
+- score<0.55不输出。锚点:0.55-0.65弱/overlap;0.65-0.8明确提及;0.8-1.0深度继承。无显式锚点≤0.7。
+- 弱边数≤强边数;同簇多弱边只留最优。
+
+## Output Format
+{"edges":[{"target_paper_id":123,"relation":"extends|improves|uses|compares|surveys|references|task_overlap|method_overlap|dataset_overlap","score":0.0,"evidence":""}]}
+"""

+ 36 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/paper_analysis.py

@@ -0,0 +1,36 @@
+
+ANALYSIS_SYSTEM = (
+    "你是学术文献分析助手。只输出合法 JSON,无 Markdown、无解释。\n"
+    "【语言】JSON 内字符串用中文(输入可为英文);必要专有缩写可保留。\n"
+    "【诚信】不得编造输入中未出现或未支撑的事实。\n"
+)
+
+READER_CHAT_SYSTEM = """# Role: 学术阅读助手
+# Output: 中文、分点、短句;结构清晰。**凡对比、枚举、两列对照(数据集/指标/方法等),必须用标准 Markdown 管道表格**,禁止仅用 Tab 或空格对齐的「假表格」。
+## Markdown 格式(最终回答须遵守)
+- 章节标题:用 `##` / `###`,勿用裸「1.」当标题(若需编号列表,用 `1. 文字` 同一行写完要点)。
+- 表格示例(表头与分隔行不可省略):
+```
+| 数据集 | 特点 |
+| --- | --- |
+| CERMEP | 脑部 PET,标准临床数据 |
+| AGIEF | 脑部 PET,不同示踪剂 |
+```
+- 列表:`- ` 或 `1. ` 后紧跟内容;小节之间空一行。
+- 忌整段无换行的大块文字;对比数据优先表格,其次再分点说明。
+# 诚信与引用
+- 禁编造实验/数据/结论;禁凭常识补全未出现内容。
+- 若存在【结构化阅读档案】,优先依据其中的章节、摘要、参考文献条目回答;它是当前 PDF 的解析缓存。
+- 若【当前文献材料】只有标题、作者、venue、arXiv/DOI 等元数据,缺少摘要/PDF 正文,不要写系统性论文讲解;先说明当前材料不足,并建议用户保存/下载 PDF 或稍后重试。
+- 引用标出处(Sec 3.1、Figure 2、Table 4)。
+- 无直接证据时写明「当前文档未包含该信息」。
+# 工具使用(重要)
+- 推荐/查找参考文献 → 调用 ``reader_reference_lookup``
+- PDF 章节浏览 → ``reader_pdf_structure``(不加 focus_section,返回目录)
+- PDF 特定章节全文 → ``reader_pdf_structure``(focus_section="experiment"/"method"/"实验" 或用户指定的章节号如 "4.5")
+- PDF 中特定表格(如 Tab. 3 / Table 4 / 消融实验表)→ ``reader_pdf_table``(table_ref="3" 或 table_ref="ImageNet" 等关键词)
+- 从 PDF 摘录中检索特定题名 → ``reader_paper_lookup``
+- **工具返回的是结构化 JSON 或章节原文,你必须用自然语言将其解读后呈现给用户,严禁直接输出工具返回的原始 JSON/标记。**
+- **当回答中引用到表格但数据不完整时,应主动调用 ``reader_pdf_table`` 获取对应表格。**
+- 推荐论文卡片已自动生成,用序号引用即可,勿编造卡片外的论文。
+"""

+ 89 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/prompts/search.py

@@ -0,0 +1,89 @@
+
+INTENT_LLM_PROMPT = """你是一个学术检索意图解析器。根据用户的自然语言查询,提取结构化的检索参数。只输出 JSON。
+
+## 当前日期
+今天是 {current_date_iso},当前公历 {current_year} 年。
+
+## 核心原则
+- **自主决策**:根据查询内容自主决定来源、排序、关键词策略。
+- **不要把会议名/作者名放入 query 或 keywords**。NIPS → NeurIPS 放入 venues。
+- **方法缩写**:query 填缩写,keywords 补全写法。
+- **中文查询**:核心概念翻译英文为 query,keywords 中英文关键术语。
+- **复杂查询**:支持多条件组合——多会议、多作者、多主题、AND/OR/NOT 逻辑。
+  例:"Kaiming He 在 CVPR 或 ICCV 上关于 diffusion 的论文" →
+  authors=["Kaiming He"], venues=["CVPR", "ICCV"], query="diffusion"
+  例:"异常检测但不是工业缺陷检测" → query="anomaly detection", keywords 不含 industrial/defect
+  例:"2024-2025 年关于 LLM 推理的论文,不考虑 fine-tuning" →
+  query="LLM reasoning", keywords 不含 fine-tuning
+
+## 用户查询
+{user_text}
+
+## 检索偏好
+{profile}
+
+## 字段说明
+
+### query(检索主串)
+纯学术英文短语,直接匹配标题/摘要。不放会议名、作者名、年份。
+- 无实质主题 → 留空 ""
+- 中文 → 翻译核心概念为英文
+- 短缩写 → 保留,keywords 补全
+
+### keywords(3-8个)
+同义词、子任务、方法全称。不含作者/会议名。如有排除项,不含排除词。
+
+### authors / venues / year_from / year_to
+- authors: First Last 格式,支持多人
+- venues: 标准名称(NeurIPS/CVPR/ICCV...),支持多个
+- year_from/year_to: 四位年份。「最新+会议」未写年份 → year_from=year_to={suggested_edition_year}
+
+### sources
+- 经典/标题匹配 → ["arxiv", "dblp", "openalex"]
+- 最新/SOTA → ["arxiv", "dblp", "openalex"]
+- 会议检索(最新/主会、无具体方向)→ ["dblp", "openalex"],query="",keywords=[]
+- 作者检索 → ["dblp", "arxiv"]
+- 不确定 → ["arxiv", "dblp", "openalex"]
+
+### ranking_strategy
+- "date":最新/SOTA(wants_recent=true, sort="date")
+- "relevance":经典/奠基(wants_classic=true, sort="relevance")
+- "hybrid":宽泛主题
+
+### flags
+- main_conference_proceedings_only: 会议检索默认 true
+- max_results: 10–30
+- wants_recent/wants_classic: 按意图
+- use_llm_rank: true
+- use_tavily: 缩写/歧义词/需要 web 锚定时 true
+
+## 输出格式(只输出 JSON)
+{{
+  "search": {{
+    "query": "英文检索短语",
+    "keywords": ["关键词1", "关键词2"],
+    "authors": [],
+    "venues": [],
+    "target_titles": [],
+    "year_from": null,
+    "year_to": null,
+    "sort": "relevance",
+    "max_results": 15,
+    "arxiv_categories": [],
+    "arxiv_id_list": []
+  }},
+  "ranking": {{
+    "use_llm_rank": true,
+    "rerank_recall_max": 24,
+    "rationale": "排序策略说明"
+  }},
+  "flags": {{
+    "sources": ["arxiv", "dblp", "openalex"],
+    "ranking_strategy": "hybrid",
+    "wants_classic": false,
+    "wants_recent": false,
+    "use_tavily": null,
+    "confidence_level": "medium",
+    "main_conference_proceedings_only": false
+  }}
+}}"""

+ 196 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/search_agent.py

@@ -0,0 +1,196 @@
+"""搜索智能体 —— 自然语言意图解析 + 多源论文检索编排."""
+
+from __future__ import annotations
+
+import logging
+import time
+from typing import Any, Optional
+
+from ..core.search.paper_searcher import _sanitize_author_list_for_query
+from ..models.schemas import Paper
+from ..services.llm.llm_service import coerce_hello_agents_llm_output_to_str
+from ..services.llm.agent_config import papergraph_agent_config
+from ..services.search_intent import (
+    apply_llm_intent_hygiene,
+    extract_json_object,
+    finalize_llm_intent,
+    search_intent_from_dict,
+)
+from ..services.search_intent.parsing import _ensure_intent_year_window_ordered
+from ..settings import get_settings
+from .base import BaseAgent
+from .prompts.search import INTENT_LLM_PROMPT as _INTENT_LLM_PROMPT_TEMPLATE
+from .support import SearchExplainer as _SearchExplainer
+from .support.search_models import SearchIntent
+
+logger = logging.getLogger(__name__)
+
+_INTENT_CACHE: dict[tuple[str, str], tuple[float, SearchIntent]] = {}
+_INTENT_CACHE_TTL = 300.0
+
+
+class SearchAgent(BaseAgent):
+    INTENT_LLM_PROMPT = _INTENT_LLM_PROMPT_TEMPLATE
+
+    def __init__(self) -> None:
+        super().__init__()
+        self._intent_parser_agent: Optional[Any] = None
+        self.intent_parser = IntentParser(self)
+        self.explainer = _SearchExplainer()
+
+    def _parse_intent_from_llm_step(
+        self,
+        *,
+        message: str,
+        profile: str,
+        prompt_template: Optional[str] = None,
+        correction_hint: Optional[str] = None,
+    ) -> SearchIntent:
+        msg = (message or "").strip()
+        if not msg:
+            raise ValueError("intent_parse_empty_message")
+        if not self.llm:
+            raise RuntimeError("intent_parse_llm_unavailable")
+
+        from ..services.search_intent.parsing import format_intent_llm_prompt
+
+        tmpl = prompt_template or self.INTENT_LLM_PROMPT
+        prompt = format_intent_llm_prompt(
+            tmpl, msg, profile, correction_hint=(correction_hint or "").strip() or None
+        )
+        from hello_agents import SimpleAgent
+
+        parser_agent = self._intent_parser_agent
+        if parser_agent is None:
+            parser_agent = SimpleAgent(
+                name="intent_parser",
+                llm=self.llm,
+                system_prompt="你是学术检索意图解析器。只输出 JSON,不要解释。",
+                config=papergraph_agent_config(),
+            )
+            self._intent_parser_agent = parser_agent
+
+        resp = parser_agent.run(prompt)
+        text = coerce_hello_agents_llm_output_to_str(resp).strip()
+        payload = extract_json_object(text)
+        if not payload:
+            err: ValueError = ValueError("LLM 未返回有效 JSON")
+            setattr(err, "last_llm_output", text)
+            raise err
+
+        intent = search_intent_from_dict(payload)
+        return finalize_llm_intent(intent, profile)
+
+    def understand_intent(self, message: str, profile: str = "accuracy") -> SearchIntent:
+        return self.intent_parser.parse(message, profile=profile)
+
+    def explain_results(self, intent: SearchIntent, papers: list[Paper], mode: str = "accuracy") -> str:
+        _ = mode
+        return self.explainer.format_search_explanation(intent, papers)
+
+    @staticmethod
+    def _normalize_profile(profile: Optional[str]) -> str:
+        prof = (profile or "accuracy").strip().lower()
+        return prof if prof in ("accuracy", "novelty") else "accuracy"
+
+
+class IntentParser:
+    def __init__(self, agent: SearchAgent) -> None:
+        self._agent = agent
+
+    def parse(self, message: str, profile: str = "accuracy") -> SearchIntent:
+        """解析用户自然语言查询为结构化 SearchIntent,带 LRU 缓存."""
+        msg = (message or "").strip()
+        if not msg:
+            return SearchIntent()
+
+        # 5 分钟内相同查询命中缓存,避免重复调用 LLM
+        cache_key = (msg.lower()[:200], (profile or "accuracy").strip().lower())
+        now = time.time()
+        if cache_key in _INTENT_CACHE:
+            ts, cached = _INTENT_CACHE[cache_key]
+            if now - ts < _INTENT_CACHE_TTL:
+                return cached
+
+        intent = self._parse_with_retry(msg, profile)
+        _INTENT_CACHE[cache_key] = (now, intent)
+        # LRU 淘汰:缓存超过 200 条时删除最旧条目
+        if len(_INTENT_CACHE) > 200:
+            oldest = min(_INTENT_CACHE, key=lambda k: _INTENT_CACHE[k][0])
+            del _INTENT_CACHE[oldest]
+        return intent
+
+    def _parse_with_retry(self, msg: str, profile: str) -> SearchIntent:
+        from ..services.search_intent.parsing import build_intent_retry_correction_hint
+
+        prof = self._agent._normalize_profile(profile)
+        if not self._agent.llm:
+            raise RuntimeError("search_agent_llm_unavailable")
+        s = get_settings()
+        outer_retries = max(0, min(5, int(getattr(s, "papergraph_intent_parse_max_retries", 2) or 2)))
+        correction: str | None = None
+        last_exc: Exception | None = None
+        last_output: str | None = None
+        for attempt in range(outer_retries + 1):
+            try:
+                return self._parse_llm_primary(msg, prof, correction_hint=correction)
+            except Exception as e:
+                last_exc = e
+                last_output = getattr(e, "last_llm_output", None) or last_output
+                logger.warning(
+                    "[SearchAgent] intent parse failed (attempt %d/%d): %s",
+                    attempt + 1,
+                    outer_retries + 1,
+                    e,
+                )
+                if attempt >= outer_retries:
+                    break
+                if "connection error" in str(e or "").lower() or "timed out" in str(e or "").lower():
+                    break
+                correction = build_intent_retry_correction_hint(
+                    e, user_message=msg, last_output=last_output
+                )
+        logger.warning("[SearchAgent] LLM intent parse exhausted retries: %s", last_exc)
+        raise RuntimeError("search_agent_intent_failed") from last_exc
+
+    def _parse_llm_primary(
+        self,
+        msg: str,
+        prof: str,
+        *,
+        correction_hint: str | None = None,
+    ) -> SearchIntent:
+        llm_intent = self._agent._parse_intent_from_llm_step(
+            message=msg,
+            profile=prof,
+            prompt_template=self._agent.INTENT_LLM_PROMPT,
+            correction_hint=correction_hint,
+        )
+        out = finalize_llm_intent(llm_intent, prof)
+        if not (out.query or "").strip() and (out.keywords or []):
+            out.query = (out.keywords[0] or "")[:500]
+        if not (out.query or "").strip() and (out.authors or []):
+            out.query = str(out.authors[0]).strip()[:500]
+        if (
+            not (out.query or "").strip()
+            and not (out.venues or [])
+            and not (out.authors or [])
+            and not (out.arxiv_id_list or [])
+            and not (out.target_titles or [])
+        ):
+            raise ValueError("intent_parse_empty_query")
+        apply_llm_intent_hygiene(out, msg)
+        _ensure_intent_year_window_ordered(out)
+        out.raw_user_message = msg.strip()[:3200]
+        out.authors = _sanitize_author_list_for_query(out.query or "", out.authors or [])
+        return out
+
+
+_search_agent_singleton: Optional[SearchAgent] = None
+
+
+def get_search_agent() -> SearchAgent:
+    global _search_agent_singleton
+    if _search_agent_singleton is None:
+        _search_agent_singleton = SearchAgent()
+    return _search_agent_singleton

+ 11 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/__init__.py

@@ -0,0 +1,11 @@
+__all__ = ["SearchExplainer"]
+
+
+class SearchExplainer:
+    def format_search_explanation(self, intent, papers):
+        if not papers:
+            return "未找到匹配的论文"
+        n = len(papers)
+        years = sorted({p.year for p in papers if p.year}, reverse=True)
+        yr = f" ({years[0]}-{years[-1]})" if len(years) >= 2 else f" ({years[0]})" if years else ""
+        return f"找到 {n} 篇相关论文{yr}"

+ 164 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/paper_analysis_helpers.py

@@ -0,0 +1,164 @@
+"""论文分析辅助函数 —— 方法名提取、缩写识别与文本预处理."""
+
+from __future__ import annotations
+
+import re
+
+from ...core.paper_paths import normalize_library_category_display
+from ...utils import parse_llm_json, tokenize_for_keywords, truncate_text
+
+def clip_text(text: str | None, n: int) -> str:
+    t = (text or "").strip()
+    if len(t) <= n:
+        return t
+    t = t[:n]
+    while t:
+        try:
+            t.encode('utf-8')
+            break
+        except UnicodeEncodeError:
+            t = t[:-1]
+    return t
+
+def dedupe_tags(tags: list[str], max_tags: int = 12) -> list[str]:
+    out: list[str] = []
+    seen: set[str] = set()
+    for t in tags:
+        s = str(t).strip()
+        k = s.lower()
+        if s and k not in seen:
+            out.append(s)
+            seen.add(k)
+        if len(out) >= max_tags:
+            break
+    return out
+
+def clean_library_tag(tag: str) -> str | None:
+    s = str(tag).strip()[:24]
+    if not s:
+        return None
+    if any(c in s for c in '|\\:*?"<>/'):
+        return None
+    return s
+
+def sanitize_major_name(raw: str) -> str | None:
+    t = str(raw or "").strip()
+    if not t or len(t) > 16:
+        return None
+    t = "".join(ch for ch in t if ch not in '/\\:*?"<>|')
+    t = t.strip()
+    if len(t) < 2:
+        return None
+    return t[:10]
+
+def nearest_major_in(raw: str, whitelist: tuple[str, ...]) -> str:
+    s = (raw or "").strip()
+    if "/" not in s:
+        s = normalize_library_category_display(s)
+    if s in whitelist:
+        return s
+    for m in whitelist:
+        if m and (m in s or s in m):
+            return m
+    if "未分类" in whitelist:
+        return "未分类"
+    return whitelist[0] if whitelist else "未分类"
+
+def parse_taxonomy_majors(raw: str) -> list[str | None]:
+    d = parse_llm_json(raw)
+    if not isinstance(d, dict):
+        return None
+    arr = d.get("majors")
+    if not isinstance(arr, list):
+        return None
+    out: list[str] = []
+    seen: set[str] = set()
+    for x in arr:
+        c = sanitize_major_name(str(x))
+        if not c:
+            continue
+        k = c.lower()
+        if k in seen:
+            continue
+        seen.add(k)
+        out.append(c)
+    if "未分类" not in out:
+        out.append("未分类")
+    if len(out) < 4:
+        return None
+    return out[:28]
+
+def top_similar_categories(seed: str, categories: list[str], k: int = 18) -> list[str]:
+    cats = [str(x).strip() for x in categories if str(x).strip()]
+    if len(cats) <= k:
+        return cats
+    tokens = tokenize_for_keywords(seed)
+    if not tokens:
+        return cats[:k]
+    scored: list[tuple[float, str]] = []
+    for c in cats:
+        ct = tokenize_for_keywords(c)
+        inter = len(tokens & ct)
+        if inter == 0:
+            continue
+        union = len(tokens | ct) or 1
+        scored.append((inter / union, c))
+    scored.sort(key=lambda x: x[0], reverse=True)
+    picked = [c for _, c in scored[:k]]
+    if len(picked) < max(8, k // 2):
+        for c in cats:
+            if c not in picked:
+                picked.append(c)
+            if len(picked) >= k:
+                break
+    return picked[:k]
+
+def prioritize_reader_context(block: str, max_chars: int) -> str:
+    b = (block or "").strip()
+    if len(b) <= max_chars:
+        return b
+
+    def grab(label_pat: str, cap: int) -> str:
+        m = re.search(label_pat, b, flags=re.IGNORECASE | re.DOTALL)
+        if not m:
+            return ""
+        seg = (m.group(1) if m.groups() else m.group(0)).strip()
+        return truncate_text(seg, cap, suffix="…")
+
+    abstract = grab(
+        r"(?:摘要|Abstract)\s*[::]?\s*([\s\S]{20,}?)(?=\n\s*(?:相关工作|Related\s*work|关键词|Key\s*words|PDF|【)|\Z)",
+        1400,
+    )
+    related = grab(
+        r"(?:相关工作|Related\s*work)\s*[::]?\s*([\s\S]{20,}?)(?=\n\s*(?:关键词|Key\s*words|参考文献|PDF|【)|\Z)",
+        1200,
+    )
+    artifact = grab(
+        r"(【结构化阅读档案[\s\S]{80,}?)(?=\n【PDF 正文|\Z)",
+        min(2600, max(1400, max_chars - 900)),
+    )
+    ref_blob = grab(
+        r"【参考文献区 PDF 原文摘录[^\n]*\n([\s\S]{10,}?)(?=\n【结构化阅读档案|\n【PDF 正文|\Z)",
+        min(10000, max(2400, max_chars - 1200)),
+    )
+    budget = max_chars - len(abstract) - len(related) - len(artifact) - len(ref_blob) - 30
+    if budget < 400:
+        budget = 400
+    head = truncate_text(b, budget, suffix="…")
+    parts = [p for p in (abstract, related, artifact, ref_blob, head) if p]
+    merged = "\n\n---\n\n".join(parts)
+    return truncate_text(merged, max_chars, suffix="…")
+
+def clip_reader_history(hist: str, max_chars: int) -> str:
+    h = (hist or "").strip()
+    if len(h) <= max_chars:
+        return h
+    lines = h.splitlines()
+    out: list[str] = []
+    size = 0
+    for line in reversed(lines):
+        if size + len(line) + 1 > max_chars:
+            break
+        out.append(line)
+        size += len(line) + 1
+    return "\n".join(reversed(out)) if out else truncate_text(h, max_chars, suffix="…")

+ 337 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_paper_lookup_tool.py

@@ -0,0 +1,337 @@
+"""论文检索工具 —— 在本地文献库中按标题/作者/关键词查找论文."""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any
+from collections.abc import Callable
+
+from hello_agents.tools.base import Tool, ToolParameter
+from hello_agents.tools.response import ToolResponse
+
+from .reader_reference_lookup_tool import (
+    READER_RECOMMEND_MAX_RESULTS, READER_RELATED_FROM_EXTERNAL_QUERY, READER_RELATED_FROM_REF_BLOCK,
+    _norm_doi, _norm_arxiv,
+    resolve_references_via_openalex, score_reference_line_against_hint,
+)
+
+logger = logging.getLogger(__name__)
+
+def _normalize_ref_blob_for_match(blob: str) -> str:
+    u = (blob or "").lower()
+    u = re.sub(r"\s+", " ", u)
+    for _ in range(20):
+        u2 = re.sub(r"([a-z])-\s*([a-z])", r"\1\2", u)
+        if u2 == u:
+            break
+        u = u2
+    return u
+
+def ground_score_paper_vs_reference_blob(ap: Any, ref_blob: str) -> float:
+    raw = _normalize_ref_blob_for_match(ref_blob)
+    if len(raw) < 50:
+        return 1.0
+
+    doi = _norm_doi(str(getattr(ap, "doi", None) or ""))
+    if doi and len(doi) > 6 and doi in raw.replace("https://doi.org/", "").replace("http://dx.doi.org/", ""):
+        return 1.0
+
+    ax = _norm_arxiv(str(getattr(ap, "arxiv_id", None) or ""))
+    if ax and len(ax) >= 8:
+        compact = re.sub(r"[^\d.]", "", raw)
+        if ax in raw or ax in compact:
+            return 1.0
+
+    title = str(getattr(ap, "title", "") or "").strip()
+    if len(title) < 8:
+        return 0.2
+    tn = _normalize_ref_blob_for_match(title)
+    if len(tn) >= 10 and tn in raw:
+        return 1.0
+    if len(tn) >= 22:
+        head = tn[: min(88, len(tn))]
+        if len(head) >= 18 and head in raw:
+            return 0.95
+
+    toks = [w for w in re.findall(r"[a-z0-9]{4,}", tn) if len(w) >= 4]
+    if not toks:
+        return 0.22
+    hits = sum(1 for w in toks if w in raw)
+    ratio = hits / max(1, len(toks))
+    return min(1.0, 0.28 + 0.72 * ratio)
+
+class ReaderPaperLookupTool(Tool):
+
+    def __init__(
+        self,
+        on_papers_found: Callable[[list[Any], str], None],
+        *,
+        get_snap: Callable[[], dict[str, Any]] | None = None,
+    ) -> None:
+        super().__init__(
+            name="reader_paper_lookup",
+            description=(
+                "两类用法:(1) 用户粘贴的库外英文题名/DOI/arXiv:from_pdf_references_section=false,"
+                "按 query 在 OpenAlex 快速检索(1~80 条)。"
+                "(2) 仅有「参考文献区 PDF 原文摘录」、无库表列表时:from_pdf_references_section=true;"
+                "优先使用本轮 ``reader_pdf_structure`` 写入的粗分 ``entries`` 选条;若未调用该工具则退回摘录内粗分。"
+                "再按 query 与题录行匹配排序,对最相关若干条做多源解析。"
+                "若已有库表 references 且用户要求按列表解析,优先 reader_reference_lookup。"
+            ),
+        )
+        self._on_papers_found = on_papers_found
+        self._get_snap = get_snap
+
+    def get_parameters(self) -> list[ToolParameter]:
+        return [
+            ToolParameter(
+                name="query",
+                type="string",
+                description=(
+                    "from_pdf=false:OpenAlex 检索串(题名片段、作者+年、DOI/arXiv)。"
+                    "from_pdf=true:与用户问题相关的**提示串**(摘录中的英文题名片段、DOI、arXiv、或作者姓+年份),"
+                    "用于在粗分后的参考文献行里排序选条;勿只用两三个泛词。"
+                ),
+                required=True,
+            ),
+            ToolParameter(
+                name="max_results",
+                type="integer",
+                description="最多返回几条(1~80,默认 3)",
+                required=False,
+                default=3,
+            ),
+            ToolParameter(
+                name="from_pdf_references_section",
+                type="boolean",
+                description=(
+                    "true:当前依赖「参考文献区 PDF 原文摘录」;服务端粗分条后按 query 与题录行匹配,再逐条多源解析。"
+                    "false:库外粘贴题名等,直接 OpenAlex。"
+                ),
+                required=False,
+                default=False,
+            ),
+        ]
+
+    def _run_from_pdf_ref_blob(self, q: str, mr: int) -> ToolResponse:
+        if not callable(self._get_snap):
+            return ToolResponse.error(
+                "NO_SNAPSHOT",
+                "reader_paper_lookup:from_pdf 模式需要阅读上下文快照,当前不可用。",
+            )
+        try:
+            snap = dict(self._get_snap() or {})
+        except Exception as exc:
+            logger.debug("reader_paper_lookup_snap_failed", exc_info=exc)
+            return ToolResponse.error("SNAP_FAILED", f"reader_paper_lookup:读取快照失败:{exc}")
+
+        ref_blob = str(snap.get("references_section_raw") or "").strip()
+        rs_pre = snap.get("references_from_structure")
+        has_struct_entries = isinstance(rs_pre, list) and bool(rs_pre)
+        if len(ref_blob) < 80 and not has_struct_entries:
+            return ToolResponse.success(
+                text=(
+                    "reader_paper_lookup:当前无足够长的「参考文献区 PDF 摘录」,且本轮尚未通过 reader_pdf_structure 得到 entries。"
+                    "请先调用 reader_pdf_structure,或确认已打开带 PDF 的文献;也可改用库外题名(from_pdf_references_section=false)。"
+                ),
+            )
+
+        try:
+            from ...services.reader.paper_reader_context import reference_strings_for_resolve_fallback
+        except Exception as exc:
+            logger.warning("reader_paper_lookup_ref_fallback_import_failed", exc_info=exc)
+            return ToolResponse.error("IMPORT_FAILED", f"reader_paper_lookup:摘录分条模块不可用。{exc}")
+
+        lines: list[str] = []
+        rs = snap.get("references_from_structure")
+        if isinstance(rs, list) and rs:
+            lines = [str(x).strip() for x in rs if str(x).strip()]
+        if not lines:
+            lines = reference_strings_for_resolve_fallback(ref_blob)
+        if not lines:
+            return ToolResponse.success(
+                text=(
+                    "reader_paper_lookup:无法得到参考文献粗分条目(可先调 reader_pdf_structure,"
+                    "或提示用户给出 DOI / 标准英文题名走库外检索)。"
+                ),
+            )
+
+        ref_for_ground = ref_blob
+        if len(ref_for_ground) < 80 and lines:
+            ref_for_ground = "\n".join(lines[:80])
+
+        hint = q.strip()
+        ranked = sorted(lines, key=lambda ln: -score_reference_line_against_hint(ln, hint))
+        pool_n = max(mr * 6, 24)
+        snap["references"] = ranked[:pool_n]
+
+        try:
+            api_papers = list(
+                resolve_references_via_openalex(snap, max_results=mr) or []
+            )
+        except Exception as exc:
+            logger.debug("reader_paper_lookup_resolve_failed", exc_info=exc)
+            return ToolResponse.error("RESOLVE_FAILED", f"reader_paper_lookup:按摘录解析失败:{exc}")
+
+        if not api_papers:
+            return ToolResponse.success(
+                text=(
+                    "reader_paper_lookup:按 PDF 摘录分条解析后无通过锚定校验的命中。"
+                    "请把 query 换成摘录中与目标文献更贴近的英文题名片段、DOI 或 arXiv。"
+                ),
+            )
+
+        try:
+            self._on_papers_found(api_papers, READER_RELATED_FROM_REF_BLOCK)
+        except Exception as exc:
+            logger.debug("reader_paper_lookup_callback_failed", exc_info=exc)
+
+        out_lines = [
+            f"reader_paper_lookup:已按 PDF 参考文献摘录匹配并解析 {len(api_papers)} 条(阅读页卡片与参考文献推荐同列):"
+        ]
+        for i, ap in enumerate(api_papers, start=1):
+            t = str(getattr(ap, "title", "") or "").strip() or "(无标题)"
+            y = getattr(ap, "year", None) or "—"
+            ax = getattr(ap, "arxiv_id", None) or "—"
+            doi = getattr(ap, "doi", None) or "—"
+            out_lines.append(f"{i}. {t} | year={y} | arxiv={ax} | doi={doi}")
+        return ToolResponse.success(text="\n".join(out_lines))
+
+    def run(self, parameters: dict[str, Any]) -> ToolResponse:
+        q = str(parameters.get("query") or parameters.get("input") or "").strip()
+        q = re.sub(r"\s+", " ", q)[:160]
+        if len(q) < 4:
+            return ToolResponse.error("INVALID_PARAM", "reader_paper_lookup:query 过短(至少 4 个字符)。")
+
+        try:
+            mr = int(parameters.get("max_results") or 3)
+        except (TypeError, ValueError):
+            mr = 3
+        mr = max(1, min(READER_RECOMMEND_MAX_RESULTS, mr))
+
+        from_ref = parameters.get("from_pdf_references_section")
+        if isinstance(from_ref, str):
+            from_ref = from_ref.strip().lower() in ("1", "true", "yes", "on")
+        else:
+            from_ref = bool(from_ref)
+
+        if from_ref:
+            return self._run_from_pdf_ref_blob(q, mr)
+
+        fetch_n = max(mr * 2, 6)
+
+        try:
+            from ...api.dependencies import get_searcher
+            from ...services.papers.papers_converters import litpaper_to_api_paper
+        except Exception as exc:
+            logger.warning("reader_paper_lookup_import_failed", exc_info=exc)
+            return ToolResponse.error("IMPORT_FAILED", f"reader_paper_lookup:检索模块不可用。{exc}")
+
+        _REF_ARXIV = re.compile(r"(?:arxiv\.org/(?:abs|pdf)/|\barXiv:\s*)([\w.]+)", re.I)
+        _REF_ARXIV_ID_LOOSE = re.compile(r"(?:^|[^\w])arxiv\s*:?\s*(\d{4}\.\d{4,5}(?:v\d+)?)\b", re.I)
+        ax_match = _REF_ARXIV.search(q) or _REF_ARXIV_ID_LOOSE.search(q)
+        ax_id = ""
+        if ax_match:
+            g1 = ax_match.group(1) if ax_match.lastindex else ""
+            ax_id = (g1 or "").strip().replace(".pdf", "")
+            if ax_id and ax_id[-1].isalpha():
+                vi = ax_id.rfind("v")
+                if vi > 8 and vi < len(ax_id) - 1 and ax_id[vi + 1:].isdigit():
+                    ax_id = ax_id[:vi]
+
+        searcher = get_searcher()
+        merged: list[Any] = []
+        seen_titles: set[str] = set()
+
+        def _merge(batch: Any) -> None:
+            for p in batch or []:
+                t = (getattr(p, "title", None) or "").strip().lower()
+                if t and t not in seen_titles:
+                    seen_titles.add(t)
+                    merged.append(p)
+
+        if ax_id and re.match(r"^\d{4}\.\d{4,5}", ax_id):
+            try:
+                _merge(searcher.search_arxiv(
+                    "", max_results=4, arxiv_id_list=ax_id,
+                    http_timeout_sec=10, http_max_attempts=1,
+                ))
+            except Exception as exc:
+                logger.debug("reader_paper_lookup_arxiv_id_failed", exc_info=exc)
+
+        clean_q = re.sub(r"\b(?:arxiv\.org/(?:abs|pdf)/|arxiv\s*:?\s*)\d{4}\.\d{4,5}(?:v\d+)?\b\.?", " ", q, flags=re.I)
+        clean_q = re.sub(r"\s+", " ", clean_q).strip()
+        search_queries: list[str] = []
+        if clean_q and len(clean_q) >= 6:
+            search_queries.append(clean_q[:160])
+        search_queries.append(q[:160])
+        uniq_q = list(dict.fromkeys(search_queries))
+
+        year_filter: int | None = None
+        year_match = re.search(r"\b(19|20)(\d{2})\b", q)
+        if year_match:
+            y = int(year_match.group(0))
+            if 1990 <= y <= 2030:
+                year_filter = y
+
+        author_hint: str | None = None
+        author_match = re.search(r"\(?([A-Z][a-z]{1,20})\s+(?:et\s+al\.?|and)", q)
+        if author_match:
+            author_hint = author_match.group(1).lower()
+
+        for sq in uniq_q[:2]:
+            if len(merged) >= fetch_n:
+                break
+            try:
+                _merge(searcher.search_openalex(
+                    sq, max_results=max(fetch_n - len(merged), 5),
+                    venue_proceedings_journal=False,
+                    year_from=year_filter,
+                    year_to=year_filter,
+                ))
+            except Exception as exc:
+                logger.debug("reader_paper_lookup_openalex_failed", exc_info=exc)
+
+        def _auth_score(p: Any) -> float:
+            s = 0.0
+            if author_hint:
+                auth_names = [str(getattr(a, "name", "") or "").lower() for a in (getattr(p, "authors", None) or [])]
+                if any(author_hint in an for an in auth_names):
+                    s += 2.0
+            if year_filter and getattr(p, "year", None) == year_filter:
+                s += 1.0
+            return s
+
+        merged.sort(key=_auth_score, reverse=True)
+
+        api_papers: list[Any] = []
+        for p in merged:
+            try:
+                api_papers.append(litpaper_to_api_paper(p))
+            except Exception:
+                continue
+        api_papers = api_papers[:mr]
+
+        if not api_papers:
+            return ToolResponse.success(
+                text=(
+                    f"reader_paper_lookup:未命中条目(query={q[:80]})。"
+                    "可换更标准的英文题名或作者+年份重试。"
+                ),
+            )
+
+        try:
+            self._on_papers_found(api_papers, READER_RELATED_FROM_EXTERNAL_QUERY)
+        except Exception as exc:
+            logger.debug("reader_paper_lookup_callback_failed", exc_info=exc)
+
+        lines = [f"reader_paper_lookup:命中 {len(api_papers)} 条(可在最终回答中对应引用):"]
+        for i, ap in enumerate(api_papers, start=1):
+            t = str(getattr(ap, "title", "") or "").strip() or "(无标题)"
+            y = getattr(ap, "year", None) or "—"
+            ax = getattr(ap, "arxiv_id", None) or "—"
+            doi = getattr(ap, "doi", None) or "—"
+            lines.append(f"{i}. {t} | year={y} | arxiv={ax} | doi={doi}")
+        lines.append("以上条目为库外检索结果;用户若只要参考文献区内的论文,应使用 from_pdf_references_section=true。")
+        return ToolResponse.success(text="\n".join(lines))

+ 148 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_pdf_parse_tool.py

@@ -0,0 +1,148 @@
+"""PDF 解析工具 —— MuPDF 正文提取、分段清洗与结构化输出."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import Any
+from collections.abc import Callable
+
+from hello_agents.tools.base import Tool, ToolParameter
+from hello_agents.tools.response import ToolResponse
+
+logger = logging.getLogger(__name__)
+
+class ReaderPdfParseTool(Tool):
+
+    def __init__(
+        self,
+        *,
+        get_snap: Callable[[], dict[str, Any]],
+        on_parsed: Callable[[dict[str, Any | None], None]] = None,
+    ) -> None:
+        super().__init__(
+            name="reader_pdf_structure",
+            description=(
+                "解析当前文献 PDF 文本为结构化 JSON。两种模式:"
+                "1) 目录模式:返回 chapters 列表(各章节标题+内容摘要)和 references 条目"
+                "2) 聚焦模式:指定 focus_section 关键词(如 'experiment'、'实验'、'method'),"
+                "返回该章节完整正文(max 24000 字符),适合深入分析特定部分"
+                "若用户要按参考文献检索,用 ``reader_paper_lookup``"
+            ),
+        )
+        self._get_snap = get_snap
+        self._on_parsed = on_parsed
+
+    def get_parameters(self) -> list[ToolParameter]:
+        return [
+            ToolParameter(
+                name="max_chapter_chars",
+                type="integer",
+                description="每个章节正文在 JSON 中的最大字符数(默认 8000)",
+                required=False,
+                default=8000,
+            ),
+            ToolParameter(
+                name="max_chapters",
+                type="integer",
+                description="最多返回多少个章节块(默认 20)",
+                required=False,
+                default=20,
+            ),
+            ToolParameter(
+                name="focus_section",
+                type="string",
+                description="只提取匹配此关键词的章节完整内容(如 'experiment'、'Sec 4'、'实验'),忽略 max_chapter_chars 截断限制",
+                required=False,
+                default="",
+            ),
+        ]
+
+    def run(self, parameters: dict[str, Any]) -> ToolResponse:
+        try:
+            mcc = int(parameters.get("max_chapter_chars") or 8000)
+        except (TypeError, ValueError):
+            mcc = 8000
+        mcc = max(800, min(20000, mcc))
+        try:
+            mch = int(parameters.get("max_chapters") or 20)
+        except (TypeError, ValueError):
+            mch = 20
+        mch = max(4, min(40, mch))
+        focus = str(parameters.get("focus_section") or "").strip()
+
+        try:
+            snap = self._get_snap() or {}
+        except Exception as exc:
+            logger.debug("reader_pdf_structure_get_snap_failed", exc_info=exc)
+            return ToolResponse.error("SNAP_FAILED", f"reader_pdf_structure:读取快照失败:{exc}")
+
+        merged = str(snap.get("_pdf_merged_for_structure") or "").strip()
+        if len(merged) < 200:
+            return ToolResponse.success(
+                text=(
+                    "reader_pdf_structure:当前无足够长的合并 PDF 文本(需本地 PDF 且阅读上下文已加载)。"
+                    "请确认文献已入库且可抽取文本;扫描版或缺文件时无法解析。"
+                ),
+            )
+
+        try:
+            from ...services.reader.paper_reader_structure import parse_pdf_merged_text_to_json
+        except Exception as exc:
+            logger.warning("reader_pdf_structure_import_failed", exc_info=exc)
+            return ToolResponse.error("IMPORT_FAILED", f"reader_pdf_structure:解析模块不可用。{exc}")
+
+        if focus:
+            obj = parse_pdf_merged_text_to_json(merged, max_chapter_chars=50000, max_chapters=99)
+            chapters = obj.get("chapters") or []
+            matched = None
+            focus_lower = focus.lower()
+            for ch in chapters:
+                h = (ch.get("heading") or "").lower()
+                t = (ch.get("text") or "")[:200].lower()
+                if focus_lower in h or focus_lower in t:
+                    matched = ch
+                    break
+            if not matched:
+                for ch in chapters:
+                    h = (ch.get("heading") or "").lower()
+                    if any(kw in h for kw in focus_lower.split()):
+                        matched = ch
+                        break
+            if matched:
+                heading = matched.get("heading", "(未命名)")
+                text = matched.get("text", "")
+                result = f"## {heading}\n\n{text}"
+                if len(result) > 24000:
+                    result = result[:24000] + "\n\n…(内容过长已截断)"
+                return ToolResponse.success(text=result)
+            else:
+                avail = ", ".join(ch.get("heading","?") for ch in chapters[:12])
+                return ToolResponse.success(
+                    text=f"未找到匹配「{focus}」的章节。可用章节:{avail}\n请用其中某个名称重试。"
+                )
+
+        try:
+            obj = parse_pdf_merged_text_to_json(merged, max_chapter_chars=mcc, max_chapters=mch)
+        except Exception as exc:
+            logger.debug("reader_pdf_structure_parse_failed", exc_info=exc)
+            return ToolResponse.error("PARSE_FAILED", f"reader_pdf_structure:解析失败:{exc}")
+
+        if callable(self._on_parsed):
+            try:
+                self._on_parsed(obj)
+            except Exception as exc:
+                logger.debug("reader_pdf_structure_on_parsed_failed", exc_info=exc)
+
+        try:
+            payload = json.dumps(obj, ensure_ascii=False)
+        except (TypeError, ValueError) as exc:
+            return ToolResponse.error("JSON_FAILED", f"reader_pdf_structure:序列化失败:{exc}")
+
+        cap = 14000
+        if len(payload) > cap:
+            payload = payload[:cap] + "\n…(json 已截断,完整条目见 references.entries 前几项;可减小 max_chapter_chars)"
+
+        return ToolResponse.success(
+            text="reader_pdf_structure:以下为 JSON(可直接阅读 chapters / references):\n" + payload
+        )

+ 312 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_reference_lookup_tool.py

@@ -0,0 +1,312 @@
+"""参考文献工具 —— 解析论文引用列表并尝试在本库/外部检索被引文献."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from typing import Any
+from collections.abc import Callable
+
+from hello_agents.tools.base import Tool, ToolParameter
+from hello_agents.tools.response import ToolResponse
+
+from ...agents.search_agent import SearchIntent
+
+logger = logging.getLogger(__name__)
+
+READER_RECOMMEND_MAX_RESULTS = 80
+READER_RELATED_FROM_BIBLIOGRAPHY = "bibliography"
+READER_RELATED_FROM_EXTERNAL_QUERY = "external_query"
+READER_RELATED_FROM_REF_BLOCK = "ref_block"
+READER_RELATED_FROM_PRE_SEARCH = "pre_search"
+
+def _norm_doi(d: str | None) -> str:
+    if not d:
+        return ""
+    s = str(d).strip().lower()
+    if "doi.org/" in s:
+        s = s.split("doi.org/", 1)[-1]
+    s = s.replace("https://", "").replace("http://", "")
+    s = re.sub(r"^doi:\s*", "", s)
+    return s.strip().rstrip(".,;)")
+
+def _norm_arxiv(a: str | None) -> str:
+    if not a:
+        return ""
+    s = str(a).strip().lower()
+    s = re.sub(r"^arxiv:\s*", "", s)
+    m = re.search(r"(?:arxiv\.org/(?:abs|pdf)/)([\w.]+)", s)
+    if m:
+        s = m.group(1)
+    s = s.replace(".pdf", "")
+    if re.match(r"^\d{4}\.\d{4,5}", s):
+        vi = s.rfind("v")
+        if vi > 8 and vi < len(s) - 1 and s[vi + 1 :].isdigit():
+            s = s[:vi]
+    return s.strip()
+
+def strip_reader_reco_boilerplate(um: str) -> str:
+    import re as _re
+    s = (um or "").strip()
+    for pat in (r"推荐.*?论文", r"找.*?(相关|类似|参考)", r"search.*?(related|similar)", r"find.*?papers"):
+        s = _re.sub(pat, "", s, flags=_re.IGNORECASE).strip()
+    return s
+
+def parse_reader_recommendation_intent(um: str) -> tuple[bool, int]:
+    s = (um or "").strip().lower()
+    want = any(k in s for k in ("推荐", "相关论文", "类似", "related", "similar", "recommend", "找.*论文"))
+
+    import re as _re
+    m = _re.search(r"(\d+)\s*[篇个本]", s)
+    n = int(m.group(1)) if m else (5 if want else 0)
+    return want, max(1, min(n, 20))
+
+def user_message_may_need_reference_lookup(um: str) -> bool:
+    s = (um or "").strip().lower()
+    return any(k in s for k in ("参考", "引用", "reference", "bibliography", "related", "相关", "类似"))
+
+def reader_user_allows_external_paper_lookup(um: str) -> bool:
+    s = (um or "").strip().lower()
+    if any(k in s for k in ("仅参考文献", "只要引用", "only reference", "just bibliography")):
+        return False
+    return True
+
+def rerank_reader_pairs_by_anchor_refs_first(snap: dict, pairs: list, k: int = 5) -> list:
+    title = str(snap.get("title") or "").strip().lower()
+    if not title or not pairs:
+        return pairs[:k]
+    title_tokens = set(re.split(r"\W+", title)) - {""}
+    if not title_tokens:
+        return pairs[:k]
+    def _score(pair):
+        p, src = pair
+        pt = str(getattr(p, "title", "") or "").strip().lower()
+        pt_tokens = set(re.split(r"\W+", pt)) - {""}
+        overlap = len(title_tokens & pt_tokens)
+        bib_bonus = 2 if src in ("bibliography", "ref_block") else 0
+        return (bib_bonus, overlap)
+    return sorted(pairs, key=_score, reverse=True)[:k]
+
+def prioritize_reader_related_pairs_refs_first(pairs: list) -> list:
+    def _priority(pair):
+        _, src = pair
+        if src in ("bibliography", "ref_block", "pre_search"):
+            return 0
+        return 1
+    return sorted(pairs, key=_priority)
+
+def paper_matches_reader_snap(snap: dict[str, Any], p: Any) -> bool:
+    if not snap:
+        return False
+    pid = snap.get("paper_id")
+    if pid and getattr(p, "id", None) is not None:
+        try:
+            if int(getattr(p, "id", 0) or 0) == int(pid):
+                return True
+        except (TypeError, ValueError):
+            pass
+    st_doi = _norm_doi(str(snap.get("doi") or ""))
+    pt_doi = _norm_doi(str(getattr(p, "doi", None) or ""))
+    if st_doi and pt_doi and st_doi == pt_doi:
+        return True
+    sa = _norm_arxiv(str(snap.get("arxiv_id") or ""))
+    pa = _norm_arxiv(str(getattr(p, "arxiv_id", None) or ""))
+    if sa and pa and sa == pa:
+        return True
+    st = str(snap.get("title") or "").strip().lower()
+    pt = str(getattr(p, "title", "") or "").strip().lower()
+    _noise = re.compile(r"[\s\-_:,\.\(\)\[\]]+")
+    stn = _noise.sub(" ", st).strip()
+    ptn = _noise.sub(" ", pt).strip()
+    if len(stn) >= 14 and len(ptn) >= 14:
+        if stn == ptn or (stn in ptn or ptn in stn):
+            return True
+        from difflib import SequenceMatcher
+
+        if SequenceMatcher(None, stn, ptn).ratio() >= 0.75:
+            return True
+    return False
+
+class ReaderReferenceLookupTool(Tool):
+
+    def __init__(
+        self,
+        get_snap: Callable[[], dict[str, Any]],
+        on_papers_found: Callable[[list[Any], str], None],
+        get_user_message: Callable[[], str],
+    ) -> None:
+        super().__init__(
+            name="reader_reference_lookup",
+            description=(
+                "从当前文献的参考文献中提取搜索查询,检索相关论文。"
+                "接受一条参考文献文本(或用户提示),调用 search_papers() 检索并返回可点击的论文结果。"
+            ),
+        )
+        self._get_snap = get_snap
+        self._on_papers_found = on_papers_found
+        self._get_user_message = get_user_message
+
+    def get_parameters(self) -> list[ToolParameter]:
+        return [
+            ToolParameter(
+                name="max_results",
+                type="integer",
+                description="最多返回几条(1~80,默认 5)",
+                required=False,
+                default=5,
+            ),
+            ToolParameter(
+                name="reference_focus",
+                type="string",
+                description=(
+                    "可选。用户感兴趣的引用方向或文本片段,直接用作文本检索查询。"
+                ),
+                required=False,
+                default="",
+            ),
+        ]
+
+    def run(self, parameters: dict[str, Any]) -> ToolResponse:
+        snap = {}
+        try:
+            snap = self._get_snap() or {}
+        except Exception as exc:
+            logger.debug("reader_reference_lookup_get_snap_failed", exc_info=exc)
+            return ToolResponse.error("SNAP_FAILED", f"reader_reference_lookup: cannot read snap. {exc}")
+
+        refs = [str(x).strip() for x in (snap.get("references") or []) if str(x).strip()]
+        raw = (snap.get("references_section_raw") or "").strip()
+        if not refs:
+            if raw:
+                return ToolResponse.success(
+                    text=(
+                        "reader_reference_lookup: current paper has no parsed references list; "
+                        "the PDF references section text is available. "
+                        "Extract English titles, DOIs, or arXiv IDs from it and call reader_paper_lookup."
+                    ),
+                )
+            return ToolResponse.success(
+                text=(
+                    "reader_reference_lookup: no references available (empty list, no PDF section text)."
+                ),
+            )
+
+        try:
+            mr = int(parameters.get("max_results") or 5)
+        except (TypeError, ValueError):
+            mr = 5
+        mr = max(1, min(READER_RECOMMEND_MAX_RESULTS, mr))
+
+        focus = str(parameters.get("reference_focus") or "").strip()
+        um = ""
+        try:
+            um = (self._get_user_message() or "").strip()
+        except Exception as exc:
+            logger.debug("reader_reference_lookup_get_user_message_failed", exc_info=exc)
+
+        query = (focus or um or (refs[0] if refs else "")).strip()[:300]
+        if not query or len(query) < 4:
+            return ToolResponse.success(
+                text="reader_reference_lookup: no usable query text. Provide a title, DOI, or arXiv ID.",
+            )
+
+        try:
+            from ...api.dependencies import get_searcher
+            from ...services.papers.papers_converters import litpaper_to_api_paper
+            from ...services.retrieval.search_pipeline import run_search_pipeline_async
+            from ...services.retrieval.search_plan import ResolvedSearchPlan
+        except Exception as exc:
+            logger.warning("reader_reference_lookup_import_failed", exc_info=exc)
+            return ToolResponse.error("IMPORT_FAILED", f"import failed: {exc}")
+
+        searcher = get_searcher()
+        intent = SearchIntent(
+            query=query,
+            sources=["arxiv", "openalex"],
+            max_results=mr,
+            sort="relevance",
+        )
+        plan = ResolvedSearchPlan.from_search_intent(intent)
+
+        try:
+            pip = asyncio.run(
+                run_search_pipeline_async(
+                    searcher=searcher,
+                    plan=plan,
+                    max_results=mr,
+                )
+            )
+        except Exception as exc:
+            logger.debug("reader_reference_lookup_search_failed", exc_info=exc)
+            return ToolResponse.error("SEARCH_FAILED", f"search failed: {exc}")
+
+        collected = [litpaper_to_api_paper(rp.paper) for rp in (pip.ranked or [])[:mr]]
+        if not collected:
+            return ToolResponse.success(
+                text=(
+                    f"reader_reference_lookup: no results for query [{query[:80]}]. "
+                    "Try a more specific English title, DOI, or arXiv ID."
+                ),
+            )
+
+        try:
+            self._on_papers_found(collected, READER_RELATED_FROM_BIBLIOGRAPHY)
+        except Exception as exc:
+            logger.debug("reader_reference_lookup_callback_failed", exc_info=exc)
+
+        lines = [
+            f"reader_reference_lookup: {len(collected)} papers found from references:"
+        ]
+        for i, ap in enumerate(collected, start=1):
+            t = str(getattr(ap, "title", "") or "").strip() or "(no title)"
+            y = getattr(ap, "year", None) or "-"
+            lines.append(f"{i}. {t} | year={y}")
+        lines.append("Refer to items by number or short title above.")
+        return ToolResponse.success(text="\n".join(lines))
+
+def score_reference_line_against_hint(ln: str, hint: str) -> float:
+    import re as _re
+    h = (hint or "").lower()
+    l = (ln or "").lower()
+    if not h or not l:
+        return 0.0
+    h_tokens = set(_re.split(r"\W+", h)) - {""}
+    l_tokens = set(_re.split(r"\W+", l)) - {""}
+    if not h_tokens or not l_tokens:
+        return 0.0
+    return len(h_tokens & l_tokens) / max(len(h_tokens), len(l_tokens))
+
+def resolve_references_via_openalex(
+    snap: dict[str, Any], *, max_results: int = 5
+) -> list[Any]:
+    refs = snap.get("references") or []
+    if not refs:
+        return []
+    from app.api.dependencies import get_searcher
+    from app.services.papers.papers_converters import litpaper_to_api_paper
+    from app.utils.async_sync import run_coroutine_sync
+    searcher = get_searcher()
+    results: list[Any] = []
+    seen: set[str] = set()
+    for ref in refs[:max_results * 3]:
+        q = str(ref or "").strip()[:200]
+        if not q or q.lower() in seen:
+            continue
+        seen.add(q.lower())
+        try:
+            papers = run_coroutine_sync(
+                searcher.search_async(q, sources=["openalex", "arxiv"], max_results=2, http_timeout_sec=5),
+                op_name="resolve_refs",
+            )
+            for p in (papers or []):
+                api_p = litpaper_to_api_paper(p)
+                t = str(getattr(api_p, "title", "") or "").strip().lower()
+                if t and t not in seen:
+                    seen.add(t)
+                    results.append(api_p)
+        except Exception:
+            continue
+        if len(results) >= max_results:
+            break
+    return results[:max_results]

+ 140 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/reader_table_tool.py

@@ -0,0 +1,140 @@
+"""表格提取工具 —— PDF 表格检测、解析与上下文关联查询."""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any, Callable
+
+from hello_agents.tools.base import Tool, ToolParameter
+from hello_agents.tools.response import ToolResponse
+
+logger = logging.getLogger(__name__)
+
+
+class ReaderTableTool(Tool):
+    """Extract a specific table from the current paper's PDF."""
+
+    def __init__(self, *, get_snap: Callable[[], dict[str, Any]]) -> None:
+        super().__init__(
+            name="reader_pdf_table",
+            description=(
+                "获取当前论文 PDF 中的指定表格内容。当用户询问表格数据或论文提到 'Tab. 3'/'Table 4' 时调用。"
+                "输入表号(如 '3'、'4')或关键词(如 'ImageNet'、'ablation'),返回对应表格的 Markdown 内容。"
+            ),
+        )
+        self._get_snap = get_snap
+
+    def get_parameters(self) -> list[ToolParameter]:
+        return [
+            ToolParameter(
+                name="table_ref",
+                type="string",
+                description="表格编号(如 '3')或关键词(如 'ImageNet'、'ablation')",
+                required=True,
+            ),
+        ]
+
+    def run(self, parameters: dict[str, Any]) -> ToolResponse:
+        ref = str(parameters.get("table_ref") or "").strip()
+        if not ref:
+            return ToolResponse.error("NO_REF", "请指定表格编号或关键词,如 table_ref='3'")
+
+        try:
+            snap = self._get_snap() or {}
+        except Exception as exc:
+            return ToolResponse.error("SNAP_FAILED", f"读取快照失败:{exc}")
+
+        pdf_path = str(snap.get("_pdf_abspath") or "").strip()
+        if not pdf_path:
+            merged = str(snap.get("_pdf_merged_for_structure") or "").strip()
+            if not merged or len(merged) < 200:
+                return ToolResponse.success(
+                    text="当前文献无可用 PDF。请先确认论文已保存且 PDF 已下载。"
+                )
+            tables = self._extract_tables_from_text(merged)
+        else:
+            try:
+                from ...services.reader.paper_reader_context import extract_pdf_tables_markdown
+                tables_md = extract_pdf_tables_markdown(pdf_path)
+                if tables_md:
+                    tables = self._parse_table_blocks(tables_md)
+                else:
+                    merged = str(snap.get("_pdf_merged_for_structure") or "").strip()
+                    tables = self._extract_tables_from_text(merged) if merged else []
+            except Exception:
+                merged = str(snap.get("_pdf_merged_for_structure") or "").strip()
+                tables = self._extract_tables_from_text(merged) if merged else []
+
+        if not tables:
+            return ToolResponse.success(
+                text="未能从 PDF 中提取到表格。表格可能为图片格式或 PDF 文本提取不完整。"
+            )
+
+        matched = self._find_table(tables, ref)
+        if not matched:
+            available = [t.get("label", f"表{i+1}") for i, t in enumerate(tables[:8])]
+            return ToolResponse.success(
+                text=f"未找到匹配 '{ref}' 的表格。可用表格:{', '.join(available)}"
+            )
+
+        result = f"## {matched['label']}\n\n{matched['content']}"
+        return ToolResponse.success(text=result)
+
+    @staticmethod
+    def _parse_table_blocks(md: str) -> list[dict[str, Any]]:
+        tables: list[dict[str, Any]] = []
+        blocks = re.split(r"\n(?=##|\|)", md)
+        for i, block in enumerate(blocks):
+            block = block.strip()
+            if not block or "|" not in block:
+                continue
+            label = f"表{i + 1}"
+            m = re.match(r"^##\s*(.*)", block)
+            if m:
+                label = m.group(1).strip()
+                block = block[m.end():].strip()
+            if block.startswith("|"):
+                tables.append({"label": label, "content": block[:3000]})
+        return tables
+
+    @staticmethod
+    def _extract_tables_from_text(text: str) -> list[dict[str, Any]]:
+        """Extract Markdown-style table blocks from merged text."""
+        tables: list[dict[str, Any]] = []
+        # Find table-like patterns: lines starting with | that have multiple columns
+        for m in re.finditer(
+            r"(?:^|\n)((?:Table\s*\d+[^\n]*|Tab\.\s*\d+[^\n]*))?\s*\n?"
+            r"((?:\|[^\n]+\|\n){2,})",
+            text, re.MULTILINE,
+        ):
+            caption = (m.group(1) or "").strip()
+            body = m.group(2).strip()
+            if body.count("|") >= 3:
+                label = caption if caption else f"表{len(tables) + 1}"
+                tables.append({"label": label, "content": body[:3000]})
+        return tables
+
+    @staticmethod
+    def _find_table(tables: list[dict[str, Any]], ref: str) -> dict[str, Any] | None:
+        ref_lower = ref.strip().lower()
+        # Exact number match: "3" → "Table 3", "Tab. 3", "表3"
+        if ref_lower.isdigit():
+            patterns = [
+                rf"\b(?:table|tab\.?|表)\s*{ref_lower}\b",
+                rf"^{ref_lower}[\.\)]",
+            ]
+            for pat in patterns:
+                for t in tables:
+                    if re.search(pat, t["label"], re.I):
+                        return t
+                for t in tables:
+                    if re.search(pat, t["content"], re.I):
+                        return t
+
+        # Keyword match in label or first rows
+        for t in tables:
+            blob = (t["label"] + " " + t["content"][:500]).lower()
+            if ref_lower in blob:
+                return t
+        return None

+ 43 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/agents/support/search_models.py

@@ -0,0 +1,43 @@
+"""搜索意图数据模型 —— SearchIntent 及检索参数的数据结构定义."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+@dataclass
+class SearchIntent:
+    query: str = ""
+    raw_user_message: str = ""
+    keywords: list[str] = field(default_factory=list)
+    authors: list[str] = field(default_factory=list)
+    venues: list[str] = field(default_factory=list)
+    year_from: int | None = None
+    year_to: int | None = None
+    sort: str = "relevance"
+    max_results: int = 10
+    target_titles: list[str] = field(default_factory=list)
+    target_authors: list[str] = field(default_factory=list)
+    arxiv_categories: list[str] = field(default_factory=list)
+    arxiv_id_list: list[str] = field(default_factory=list)
+
+    use_llm_rank: bool = True
+    rerank_recall_max: int = 24
+    ranking_rationale: str = ""
+
+    is_short_acronym: bool = False
+    wants_classic: bool = False
+    wants_recent: bool = False
+
+    use_tavily: bool | None = None
+
+    confidence_level: str = "medium"
+    search_strategy: str = "hybrid"
+    sources: list[str] = field(default_factory=list)
+    ranking_strategy: str = "hybrid"
+
+    main_conference_proceedings_only: bool = False
+
+
+__all__ = [
+    "SearchIntent",
+]

+ 46 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/dependencies.py

@@ -0,0 +1,46 @@
+"""API 依赖注入 —— SearchAgent、PaperSearcher 等单例的获取与缓存."""
+
+from __future__ import annotations
+
+import os
+import threading
+
+from app.core.search import PaperSearcher
+from app.core.storage import PaperDatabase
+
+from ..settings import get_settings
+
+_singleton_lock = threading.Lock()
+_searcher: PaperSearcher | None = None
+_database: PaperDatabase | None = None
+
+def get_searcher() -> PaperSearcher:
+    global _searcher
+    if _searcher is not None:
+        return _searcher
+    with _singleton_lock:
+        if _searcher is not None:
+            return _searcher
+        s = get_settings()
+        _searcher = PaperSearcher(
+            email=(s.openalex_mailto or s.ncbi_email) or None,
+            api_key=s.ncbi_api_key or None,
+            download_dir=s.downloads_dir,
+            httpx_trust_env=s.papergraph_httpx_trust_env,
+        )
+        return _searcher
+
+def get_database() -> PaperDatabase:
+    global _database
+    if _database is not None:
+        return _database
+    with _singleton_lock:
+        if _database is not None:
+            return _database
+        s = get_settings()
+        db_path = os.path.join(s.data_dir, "papers.db")
+        _database = PaperDatabase(db_path)
+        return _database
+
+def get_db_path() -> str:
+    return get_database().db_path

+ 137 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/main.py

@@ -0,0 +1,137 @@
+"""PaperGraph 主应用入口 —— FastAPI 实例、中间件、路由挂载与生命周期管理."""
+
+import asyncio
+import logging
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from starlette.middleware.base import BaseHTTPMiddleware
+
+from ..settings import configure_logging, get_settings, print_config, validate_config
+from ..services.graph.kg_relations import get_kg_metrics
+from .routes import paper_routes, paper_reader_routes, search_routes
+
+settings = get_settings()
+logger = logging.getLogger(__name__)
+
+class _MeaningfulActivityMiddleware(BaseHTTPMiddleware):
+
+    async def dispatch(self, request: Request, call_next):
+        response = await call_next(request)
+        try:
+            from ..services.daily.daily_auto_refresh import touch_meaningful_activity_if_needed
+
+            touch_meaningful_activity_if_needed(request.app, request.method, request.url.path)
+        except Exception:
+            pass
+        return response
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    configure_logging(settings.log_level)
+    logger.info("%s", "=" * 60)
+    logger.info("📚 %s v%s", settings.app_name, settings.app_version)
+    logger.info("%s", "=" * 60)
+
+    app.state.last_meaningful_activity_monotonic = None
+    daily_refresh_task: asyncio.Task | None = None
+
+    print_config()
+
+    try:
+        validate_config()
+        logger.info("✅ 配置验证通过")
+    except ValueError as e:
+        logger.error("❌ 配置验证失败: %s", e)
+        raise
+
+    try:
+        from ..services.daily.daily_auto_refresh import spawn_daily_auto_refresh
+
+        daily_refresh_task = spawn_daily_auto_refresh(app)
+    except Exception as exc:
+        logger.warning("每日论文后台自动刷新任务未启动: %s", exc)
+
+    logger.info("%s", "=" * 60)
+
+    yield
+
+    logger.info("%s", "=" * 60)
+    logger.info("👋 应用正在关闭...")
+    if daily_refresh_task is not None:
+        daily_refresh_task.cancel()
+        try:
+            await daily_refresh_task
+        except asyncio.CancelledError:
+            pass
+        except Exception:
+            logger.debug("每日论文后台任务结束异常", exc_info=True)
+    logger.info("%s", "=" * 60)
+
+app = FastAPI(
+    title=settings.app_name,
+    version=settings.app_version,
+    description=settings.description,
+    docs_url=None,
+    redoc_url=None,
+    lifespan=lifespan,
+)
+
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=settings.get_cors_origins_list(),
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+app.add_middleware(_MeaningfulActivityMiddleware)
+
+app.include_router(paper_routes.router, prefix="/api")
+app.include_router(paper_reader_routes.router, prefix="/api")
+app.include_router(search_routes.router, prefix="/api")
+
+@app.get("/")
+async def root():
+    return {
+        "name": settings.app_name,
+        "version": settings.app_version,
+        "status": "running",
+        "docs_enabled": False,
+    }
+
+@app.get("/health")
+async def health():
+    return {
+        "status": "healthy",
+        "service": settings.app_name,
+        "version": settings.app_version,
+        "kg_metrics": get_kg_metrics(),
+    }
+
+@app.exception_handler(Exception)
+async def global_exception_handler(request: Request, exc: Exception):
+    if isinstance(exc, HTTPException):
+        return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
+    logger.exception(
+        "未处理异常: %s %s",
+        request.method,
+        request.url.path,
+        exc_info=exc,
+    )
+    return JSONResponse(
+        status_code=500,
+        content={"success": False, "message": str(exc)},
+    )
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run(
+        "app.api.main:app",
+        host=settings.host,
+        port=settings.port,
+        reload=settings.debug,
+    )

+ 64 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/repo.py

@@ -0,0 +1,64 @@
+"""仓库信息查询 —— 基于仓库地址识别论文元数据的辅助 API."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+@dataclass(frozen=True)
+class RelationRepository:
+    db_path: str
+
+    def fetch_relation_rows(
+        self, *, focus_id: int | None, paper_ids: set[int | None], limit: int,
+    ) -> list[tuple[int, int, str, float, str]]:
+        import sqlite3
+        if int(limit) <= 0:
+            return []
+        rows: list[tuple[int, int, str, float, str]] = []
+        with sqlite3.connect(self.db_path) as conn:
+            cur = conn.cursor()
+            if focus_id is not None:
+                cur.execute(
+                    """SELECT source_paper_id, target_paper_id, relation, score, evidence
+                    FROM paper_relations
+                    WHERE source_paper_id = ? OR target_paper_id = ?
+                    ORDER BY score DESC, updated_at DESC LIMIT ?""",
+                    (int(focus_id), int(focus_id), int(limit)),
+                )
+            else:
+                ids = sorted(int(x) for x in (paper_ids or set()) if int(x) > 0)
+                if not ids:
+                    return []
+                cur.execute("CREATE TEMP TABLE IF NOT EXISTS _kg_pid (id INTEGER PRIMARY KEY)")
+                cur.execute("DELETE FROM _kg_pid")
+                cur.executemany("INSERT OR IGNORE INTO _kg_pid(id) VALUES (?)", [(i,) for i in ids])
+                cur.execute(
+                    """SELECT pr.source_paper_id, pr.target_paper_id, pr.relation, pr.score, pr.evidence
+                    FROM paper_relations pr
+                    INNER JOIN _kg_pid a ON a.id = pr.source_paper_id
+                    INNER JOIN _kg_pid b ON b.id = pr.target_paper_id
+                    ORDER BY pr.score DESC, pr.updated_at DESC LIMIT ?""",
+                    (int(limit),),
+                )
+            for sid, tid, rel, score, evidence in cur.fetchall():
+                rows.append((int(sid), int(tid), str(rel or ""), float(score or 0.0), str(evidence or "")))
+        return rows
+
+    def papers_minimal_by_ids(self, paper_ids: set[int]) -> dict[int, tuple[str, int | None, str | None]]:
+        import sqlite3
+        ids = sorted(int(x) for x in paper_ids if int(x) > 0)
+        if not ids:
+            return {}
+        out: dict[int, tuple[str, int | None, str | None]] = {}
+        with sqlite3.connect(self.db_path) as conn:
+            cur = conn.cursor()
+            cur.execute("CREATE TEMP TABLE IF NOT EXISTS _kg_meta (id INTEGER PRIMARY KEY)")
+            cur.execute("DELETE FROM _kg_meta")
+            cur.executemany("INSERT OR IGNORE INTO _kg_meta(id) VALUES (?)", [(i,) for i in ids])
+            cur.execute(
+                """SELECT p.id, p.title, p.year, p.category
+                FROM papers p INNER JOIN _kg_meta t ON t.id = p.id"""
+            )
+            for rid, title, year, cat in cur.fetchall():
+                out[int(rid)] = (str(title or ""), int(year) if year is not None else None, str(cat) if cat else None)
+        return out

+ 5 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/__init__.py

@@ -0,0 +1,5 @@
+from . import paper_reader as paper_reader_routes
+from . import papers as paper_routes
+from . import search as search_routes
+
+__all__ = ["paper_reader_routes", "paper_routes", "search_routes"]

+ 85 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/paper_reader.py

@@ -0,0 +1,85 @@
+"""论文阅读助手 API 路由 —— PDF 打开、AI 导读、对话问答与阅读历史."""
+
+import logging
+
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
+
+from ...models.schemas import (
+    PaperReaderChatRequest,
+    PaperReaderChatResponse,
+    PaperReaderHistoryItem,
+    PaperReaderHistoryResponse,
+    PaperReaderOpeningRequest,
+    PaperReaderOpeningResponse,
+)
+from ..dependencies import get_database
+from ...utils.common import safe_http_500
+from ...services.reader.paper_reader_service import PaperReaderService
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/ai", tags=["AI 分析"])
+
+def get_paper_reader_service() -> PaperReaderService:
+    db = get_database()
+    return PaperReaderService(db=db)
+
+# 首次打开论文 → 生成 AI 导读和结构化摘要
+@router.post("/paper-reader/opening", response_model=PaperReaderOpeningResponse)
+async def paper_reader_opening(
+    body: PaperReaderOpeningRequest,
+    background_tasks: BackgroundTasks,
+    service: PaperReaderService = Depends(get_paper_reader_service),
+):
+    try:
+        result = await service.get_opening(paper_id=int(body.paper_id), background_tasks=background_tasks)
+        return PaperReaderOpeningResponse(success=True, **result)
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise safe_http_500("paper_reader_opening", e)
+
+# 论文对话:基于 PDF 全文 + 参考文献上下文的问答
+@router.post("/paper-reader/chat", response_model=PaperReaderChatResponse)
+async def paper_reader_chat(
+    body: PaperReaderChatRequest,
+    background_tasks: BackgroundTasks,
+    service: PaperReaderService = Depends(get_paper_reader_service),
+):
+    try:
+        out = await service.process_chat(
+            paper_id=int(body.paper_id),
+            messages=list(body.messages or []),
+            user_message=body.user_message,
+            background_tasks=background_tasks,
+        )
+        return PaperReaderChatResponse(
+            success=True,
+            reply=str(out.get("reply") or "").strip(),
+            pdf_parsing=bool(out.get("pdf_parsing", False)),
+            related_papers=list(out.get("related_papers") or []),
+            related_hints=list(out.get("related_hints") or []),
+            kg_edges=list(out.get("kg_edges") or []),
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise safe_http_500("paper_reader_chat", e)
+
+@router.get("/paper-reader/history", response_model=PaperReaderHistoryResponse)
+async def paper_reader_history(
+    paper_id: int = Query(..., ge=1),
+    limit: int = Query(default=200, ge=1, le=1000),
+    service: PaperReaderService = Depends(get_paper_reader_service),
+):
+    try:
+        turns = await service.get_history(paper_id=int(paper_id), limit=int(limit))
+        return PaperReaderHistoryResponse(
+            success=True,
+            paper_id=int(paper_id),
+            turns=[PaperReaderHistoryItem(**t) for t in turns],
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise safe_http_500("paper_reader_history", e)

+ 218 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/papers.py

@@ -0,0 +1,218 @@
+"""论文库管理 API 路由 —— 论文保存、删除、搜索、分类与阅读状态管理."""
+
+import logging
+
+
+import anyio
+from fastapi import APIRouter, Query, BackgroundTasks, Request, Depends, HTTPException
+from ...utils.common import safe_http_500
+from ...models.schemas import (
+    DeletePaperResponse,
+    LibraryCategoriesResponse,
+    Paper,
+    PapersResponse,
+    ReadStatus,
+    SavePapersRequest,
+    SavePapersResponse,
+    LibraryGraphResponse,
+    UpdatePaperRequest,
+    UpdatePaperResponse,
+    DailyPapersRequest,
+    DailyPapersResponse,
+    DailyRecommendFeedbackRequest,
+    DailyRecommendFeedbackResponse,
+    ReadingCalendarItem,
+    ReadingLogRequest,
+    ReadingCalendarResponse,
+)
+
+from ...services.papers.papers_converters import api_paper_to_litpaper, litpaper_to_api_paper
+
+from ...services.papers.papers_helpers import (
+    daily_paper_identity_sig,
+)
+from ...services.graph.graph_service import build_library_graph
+from ...services.papers.papers_library_service import (
+    build_library_pdf_response_service,
+    delete_paper_by_id,
+    get_library as get_library_service,
+    get_paper_by_id,
+    list_library_categories as list_library_categories_service,
+    save_papers as save_papers_service,
+    update_paper_by_id,
+)
+from ...services.daily.daily_auto_refresh import get_daily_compute_lock
+from ...services.daily.daily_service import (
+    compute_daily_papers as compute_daily_service,
+    read_daily_cached_or_204 as get_daily_cached_or_204_service,
+    record_user_daily_feedback as record_daily_feedback_service,
+)
+from ...settings import get_settings
+from ..dependencies import get_database, get_db_path, get_searcher
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/papers", tags=["文献管理"])
+
+class DailyServices:
+    def __init__(self, db_path=Depends(get_db_path), searcher=Depends(get_searcher)):
+        self.db_path = db_path
+        self.searcher = searcher
+
+# ── 知识图谱 ──
+@router.get("/graph/library", response_model=LibraryGraphResponse)
+def library_graph(
+    limit: int = Query(default=200, ge=1, le=1000),
+    category: str | None = Query(default=None),
+    include_authors: bool = Query(default=False),
+    include_keywords: bool = Query(default=False),
+    relation_edge_limit: int = Query(default=400, ge=0, le=5000),
+    focus_paper_id: int | None = Query(default=None, ge=1),
+    db=Depends(get_database),
+):
+    try:
+        return build_library_graph(
+            db=db,
+            limit=int(limit),
+            category=category,
+            include_authors=bool(include_authors),
+            include_keywords=bool(include_keywords),
+            relation_edge_limit=int(relation_edge_limit),
+            focus_paper_id=focus_paper_id,
+        )
+    except Exception as e:
+        logger.exception("GET /api/papers/graph/library 失败")
+        raise HTTPException(status_code=500, detail=str(e))
+
+# ── 文献库管理 ──
+@router.get("/library/categories", response_model=LibraryCategoriesResponse)
+def list_library_categories(db=Depends(get_database)):
+    return list_library_categories_service(db=db)
+
+@router.get("/library", response_model=PapersResponse)
+def get_library(
+    limit: int = Query(default=50, ge=1, le=1000),
+    offset: int = Query(default=0, ge=0),
+    q: str | None = None,
+    year_from: int | None = None,
+    year_to: int | None = None,
+    read_status: ReadStatus | None = None,
+    tags: str | None = Query(default=None, description="逗号分隔标签"),
+    category: str | None = Query(default=None, description="领域筛选"),
+    db=Depends(get_database),
+):
+    return get_library_service(
+        db=db,
+        litpaper_to_api_paper_fn=litpaper_to_api_paper,
+        limit=limit,
+        offset=offset,
+        q=q,
+        year_from=year_from,
+        year_to=year_to,
+        read_status=read_status,
+        tags=tags,
+        category=category,
+    )
+
+# ── 论文保存 ──
+@router.post("/save", response_model=SavePapersResponse)
+async def save_papers(
+    request: SavePapersRequest,
+    background_tasks: BackgroundTasks,
+    db=Depends(get_database),
+):
+    try:
+        return await save_papers_service(
+            db=db,
+            request=request,
+            background_tasks=background_tasks,
+            api_to_lit_fn=api_paper_to_litpaper,
+            litpaper_to_api_paper_fn=litpaper_to_api_paper,
+        )
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise safe_http_500("save_papers", e)
+
+# ── 每日推荐 ──
+@router.get("/daily")
+async def daily_papers_get(db_path=Depends(get_db_path)):
+    logger.info("HTTP GET /api/papers/daily")
+    return await get_daily_cached_or_204_service(db_path=db_path)
+
+@router.post("/daily", response_model=DailyPapersResponse)
+async def daily_papers(
+    body: DailyPapersRequest,
+    services: DailyServices = Depends(),
+    settings=Depends(get_settings),
+):
+    logger.info(
+        "HTTP POST /api/papers/daily force_refresh=%s",
+        getattr(body, "force_refresh", False),
+    )
+    lock = get_daily_compute_lock()
+    async with lock:
+        try:
+            with anyio.fail_after(180.0):
+                resp = await compute_daily_service(
+                body=body, db_path=services.db_path, searcher=services.searcher,
+                daily_paper_identity_sig_fn=daily_paper_identity_sig,
+                daily_arxiv_cs_categories=settings.get_daily_arxiv_cs_categories(),
+                papergraph_to_api_fn=litpaper_to_api_paper, logger=logger,
+            )
+        except TimeoutError:
+            err_msg = "每日论文计算超时(>180s),请稍后重试或缩小范围"
+            raise HTTPException(status_code=504, detail=err_msg)
+        except HTTPException:
+            raise
+        except Exception as e:
+            raise safe_http_500("daily_papers", e)
+        else:
+            return resp
+
+# ── 阅读日志 ──
+@router.post("/reading/log")
+def log_reading_session(body: ReadingLogRequest, db_path=Depends(get_db_path)):
+    from ...services.reading_log.log import append_session
+    append_session(db_path, paper_id=int(body.paper_id), duration_sec=int(body.duration_sec),
+                   client_ts=int(body.client_ts) if body.client_ts is not None else None)
+    return {"success": True}
+
+@router.get("/reading/calendar", response_model=ReadingCalendarResponse)
+def reading_calendar(days: int = Query(default=180, ge=7, le=366), db_path=Depends(get_db_path)):
+    from ...services.reading_log.log import list_daily_aggregate
+    items = list_daily_aggregate(db_path, days=int(days))
+    return ReadingCalendarResponse(success=True, days=int(days),
+                                   items=[ReadingCalendarItem(**x) for x in items])
+
+@router.get("/{paper_id}/library-pdf")
+async def get_paper_library_pdf(
+    paper_id: int,
+    request: Request,
+    db_path=Depends(get_db_path),
+):
+    return build_library_pdf_response_service(paper_id=paper_id, request=request, db_path=db_path, logger_obj=logger)
+
+@router.get("/{paper_id}", response_model=Paper)
+def get_paper(paper_id: int, db=Depends(get_database)):
+    return get_paper_by_id(db=db, paper_id=paper_id, litpaper_to_api_paper_fn=litpaper_to_api_paper)
+
+@router.put("/{paper_id}", response_model=UpdatePaperResponse)
+def update_paper(paper_id: int, body: UpdatePaperRequest, db=Depends(get_database)):
+    return update_paper_by_id(db=db, paper_id=paper_id, body=body)
+
+@router.delete("/{paper_id}", response_model=DeletePaperResponse)
+def delete_paper(paper_id: int, db=Depends(get_database)):
+    return delete_paper_by_id(db=db, paper_id=paper_id)
+
+@router.post("/daily/feedback", response_model=DailyRecommendFeedbackResponse)
+async def record_daily_recommend_feedback(
+    body: DailyRecommendFeedbackRequest,
+    db_path=Depends(get_db_path),
+):
+    try:
+        return await record_daily_feedback_service(body=body, db_path=db_path)
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise safe_http_500("record_daily_recommend_feedback", e)

+ 284 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/routes/search.py

@@ -0,0 +1,284 @@
+"""智能搜索 API 路由 —— 自然语言论文搜索与 SSE 流式响应."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+import time
+from typing import Any, Dict, List, Optional
+
+import anyio
+from fastapi import APIRouter, Depends, HTTPException
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+
+from ...agents.search_agent import SearchAgent, SearchIntent, get_search_agent
+from ...api.dependencies import get_searcher
+from ...api.search_route_support import (
+    ToolCallInfo,
+    last_pipeline_tool_error,
+    normalize_tool_calls,
+    track_tool_call,
+    user_facing_error_message,
+)
+from ...models.schemas import Paper
+from ...services.papers.papers_converters import litpapers_to_api_papers
+from ...services.retrieval.search_plan import ResolvedSearchPlan
+from ...services.retrieval.search_pipeline import run_search_pipeline_async
+from ..tool_events import ToolCallTracker, sse_pack
+
+router = APIRouter(prefix="/papers", tags=["智能搜索"])
+logger = logging.getLogger(__name__)
+
+_SSE_QUEUE_SIZE = 128
+_SEARCH_AGENT_WALL_SEC = max(
+    120.0,
+    min(900.0, float(os.getenv("PAPERGRAPH_SEARCH_AGENT_WALL_SEC") or 420.0)),
+)
+_SEARCH_AGENT_INIT_SEC = 25.0
+_PREFIX_CONFLICT_MARKER = "为您找到"
+
+
+class SearchAgentMessage(BaseModel):
+    message: str = Field(..., min_length=1, max_length=2000, description="用户搜索需求")
+    mode: str = Field(default="accuracy", description="accuracy=准确性优先, novelty=新颖性优先")
+    use_tavily: bool = Field(default=False, description="是否使用 Tavily 预搜索")
+    history: List[Dict[str, str]] = Field(default_factory=list, description="对话历史")
+
+
+class SearchAgentResponse(BaseModel):
+    success: bool
+    response: str
+    search_params: Optional[Dict[str, Any]] = None
+    tool_calls: List[ToolCallInfo] = Field(default_factory=list)
+    papers: List[Paper] = Field(default_factory=list)
+    total: int = 0
+    message: Optional[str] = None
+
+
+def _search_params_from_intent(intent: SearchIntent, **extra: Any) -> Dict[str, Any]:
+    yf, yt = intent.year_from, intent.year_to
+    if isinstance(yf, int) and isinstance(yt, int) and yf > yt:
+        yf, yt = yt, yf
+    out: Dict[str, Any] = {
+        "query": intent.query,
+        "keywords": intent.keywords,
+        "authors": getattr(intent, "authors", []) or [],
+        "arxiv_id_list": getattr(intent, "arxiv_id_list", []) or [],
+        "venues": intent.venues,
+        "year_from": yf,
+        "year_to": yt,
+        "sort": intent.sort,
+        "use_llm_rank": intent.use_llm_rank,
+        "rerank_recall_max": intent.rerank_recall_max,
+        "ranking_rationale": intent.ranking_rationale or None,
+    }
+    out.update(extra)
+    return out
+
+
+def _generate_suggestions(intent: SearchIntent, papers: List[Paper]) -> List[str]:
+    if len(papers) < 5:
+        return [f"扩大搜索:尝试「{intent.query}」而不限定会议"]
+    return []
+
+
+def _strip_conflicting_search_summary_prefix(text: str) -> str:
+    t = (text or "").strip()
+    if not t or _PREFIX_CONFLICT_MARKER not in t:
+        return t
+    return t[: t.find(_PREFIX_CONFLICT_MARKER)].rstrip()
+
+
+def _explanation_with_suggestions(
+    agent: SearchAgent,
+    intent: SearchIntent,
+    papers: List[Paper],
+    profile_mode: str,
+    *,
+    prefix_plain: str = "",
+) -> str:
+    base = _strip_conflicting_search_summary_prefix((prefix_plain or "").strip())
+    expl = agent.explain_results(intent, papers, profile_mode)
+    explanation = base + "\n\n---\n\n" + expl if (papers and base) else (base or expl)
+    if papers:
+        sug = _generate_suggestions(intent, papers)
+        if sug:
+            explanation += "\n\n🔍 **您可以这样优化**:\n" + "".join(
+                f"{i}. {s}\n" for i, s in enumerate(sug, 1)
+            )
+    return explanation
+
+
+def _error_response(msg: str) -> SearchAgentResponse:
+    return SearchAgentResponse(
+        success=False,
+        response=user_facing_error_message(msg),
+        message=msg,
+    )
+
+
+async def _prepare_agent_and_query(request: SearchAgentMessage) -> tuple[SearchAgent, str]:
+    try:
+        with anyio.fail_after(_SEARCH_AGENT_INIT_SEC):
+            agent = await anyio.to_thread.run_sync(get_search_agent)
+    except TimeoutError as exc:
+        logger.warning("search-agent init timeout after %.0fs", _SEARCH_AGENT_INIT_SEC, exc_info=exc)
+        raise HTTPException(status_code=504, detail="search_agent_init_timeout") from exc
+    return agent, (request.message or "").strip()
+
+
+async def _run_search_agent_core(
+    *,
+    agent: SearchAgent,
+    request: SearchAgentMessage,
+    merged_query: str,
+    searcher: Any,
+) -> SearchAgentResponse:
+    tool_calls: List[ToolCallInfo] = []
+
+    intent = agent.understand_intent(merged_query, request.mode)
+    with track_tool_call(tool_calls, "understand_intent", {"query": merged_query}) as tc:
+        tc.result_summary = f"sort={intent.sort}, venues={intent.venues}, yf={intent.year_from}, kw={intent.keywords}"
+
+    plan = ResolvedSearchPlan.from_search_intent(intent)
+    with track_tool_call(tool_calls, "search_pipeline", {"query": intent.query or merged_query}) as tc:
+        tc.result_summary = "intent→SearchPlan→pipeline"
+        mr = int(getattr(plan, "max_results", None) or intent.max_results or 10)
+        pip = await run_search_pipeline_async(searcher=searcher, plan=plan, max_results=mr)
+        tc.result_summary = f"ranked={len(pip.ranked or [])}"
+
+    papers = litpapers_to_api_papers(rp.paper for rp in (pip.ranked or []))
+    prefix = f"为您找到 {len(papers)} 篇论文。" if papers else "未找到相关论文。"
+
+    pipeline_err = last_pipeline_tool_error(tool_calls)
+    if not papers and pipeline_err:
+        body = (
+            "主检索未成功返回论文(多源召回或精排阶段出错),与「数据库里确实没有匹配文献」不同。\n\n"
+            f"**错误摘要**:{pipeline_err}\n\n"
+            "建议稍后重试,或略微改写查询;若频繁出现请查看服务端日志。"
+        )
+        return SearchAgentResponse(
+            success=False,
+            response=body,
+            search_params=_search_params_from_intent(intent, mode=request.mode),
+            tool_calls=normalize_tool_calls(tool_calls),
+            papers=[],
+            total=0,
+            message="search_pipeline_error",
+        )
+
+    body = _explanation_with_suggestions(agent, intent, papers, request.mode, prefix_plain=prefix)
+    return SearchAgentResponse(
+        success=True,
+        response=body,
+        search_params=_search_params_from_intent(intent, mode=request.mode),
+        tool_calls=normalize_tool_calls(tool_calls),
+        papers=papers,
+        total=len(papers),
+    )
+
+
+async def _search_agent_impl(request: SearchAgentMessage, searcher: Any):
+    try:
+        agent, merged_query = await _prepare_agent_and_query(request)
+        resp = await asyncio.wait_for(
+            _run_search_agent_core(
+                agent=agent,
+                request=request,
+                merged_query=merged_query,
+                searcher=searcher,
+            ),
+            timeout=_SEARCH_AGENT_WALL_SEC,
+        )
+        return resp, None
+    except asyncio.TimeoutError as exc:
+        logger.warning("search-agent timeout after %.0fs", _SEARCH_AGENT_WALL_SEC, exc_info=exc)
+        return _error_response("search_agent_timeout"), HTTPException(status_code=504, detail="search_agent_timeout")
+    except HTTPException as e:
+        return _error_response(str(e.detail or "search_agent_http_error")), e
+    except Exception:
+        logger.exception("search-agent unexpected failure")
+        return _error_response("search_agent_internal_error"), None
+
+
+@router.post("/search-agent/stream")
+async def search_agent_chat_stream(
+    request: SearchAgentMessage,
+    searcher=Depends(get_searcher),
+):
+    async def gen():
+        # SSE 流式生成器:通过 anyio 内存通道实现事件驱动的流式推送
+        send, recv = anyio.create_memory_object_stream(_SSE_QUEUE_SIZE)
+        tracker = ToolCallTracker(sink=lambda ev: send.send_nowait(ev))
+        tracker.emit("status", {"message": "search-agent 已接入,开始处理"})
+
+        async def run_once() -> SearchAgentResponse:
+            tracker.emit("status", {"message": f"初始化 SearchAgent(mode={request.mode})"})
+            t0 = time.time()
+            tracker.emit("status", {"message": "正在检索论文…"})
+            resp, exc = await _search_agent_impl(request, searcher)
+            if exc:
+                code = (
+                    str(exc.detail or "search_agent_http_error")
+                    if isinstance(exc, HTTPException)
+                    else "search_agent_internal_error"
+                )
+                msg = user_facing_error_message(code)
+                tracker.emit("error", {"message": msg})
+                if not isinstance(exc, HTTPException):
+                    logger.exception("search-agent stream run loop failed")
+                return _error_response(msg)
+            tracker.emit(
+                "final",
+                {"elapsed_ms": int((time.time() - t0) * 1000), "success": bool(resp.success)},
+            )
+            return resp
+
+        box: Dict[str, Any] = {"resp": None}
+        cancelled_exc = anyio.get_cancelled_exc_class()
+        try:
+            async with anyio.create_task_group() as tg:
+
+                async def _run() -> None:
+                    try:
+                        box["resp"] = await run_once()
+                    finally:
+                        try:
+                            await send.aclose()
+                        except Exception:
+                            pass
+
+                tg.start_soon(_run)
+
+                async for ev in recv:
+                    try:
+                        yield sse_pack(ev)
+                    except (cancelled_exc, asyncio.CancelledError):
+                        return
+                    except Exception:
+                        return
+        except (cancelled_exc, asyncio.CancelledError):
+            return
+        finally:
+            try:
+                await recv.aclose()
+            except Exception:
+                pass
+
+        resp: Optional[SearchAgentResponse] = box.get("resp")
+        if resp is None:
+            resp = _error_response("search_agent_stream_incomplete")
+            tracker.emit("error", {"message": resp.message or "search_agent_stream_incomplete"})
+        yield sse_pack({"type": "final_result", "result": resp.model_dump(mode="json")})
+
+    return StreamingResponse(
+        gen(),
+        media_type="text/event-stream",
+        headers={
+            "Cache-Control": "no-cache, no-transform",
+            "Connection": "keep-alive",
+            "X-Accel-Buffering": "no",
+        },
+    )

+ 81 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/search_route_support.py

@@ -0,0 +1,81 @@
+"""搜索路由辅助 —— 工具调用追踪、错误解析与用户友好消息构建."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from typing import Any, Iterator, List, Optional
+
+from pydantic import BaseModel
+
+SEARCH_AGENT_ERROR_MESSAGES: dict[str, str] = {
+    "search_agent_init_timeout": "检索服务初始化超时,请稍后重试。",
+    "search_agent_timeout": "检索超时,请稍后重试或缩短描述。",
+    "search_agent_intent_failed": "暂时无法理解检索意图(LLM 不可用或返回异常),请改写为更具体的会议/主题/年份。",
+    "search_agent_internal_error": "检索服务内部错误,请查看后端日志或稍后重试。",
+    "search_agent_stream_incomplete": "检索流未正常结束,请重试。",
+    "search_agent_llm_unavailable": "未配置 LLM,无法解析复杂检索意图;请配置 API Key 或使用更明确的会议+年份查询。",
+}
+
+
+class ToolCallInfo(BaseModel):
+    name: str
+    status: str
+    params: Optional[dict[str, Any]] = None
+    result_summary: Optional[str] = None
+
+
+def user_facing_error_message(code: str) -> str:
+    return SEARCH_AGENT_ERROR_MESSAGES.get(code, code or "search_agent_error")
+
+
+@contextmanager
+def track_tool_call(
+    tool_calls: List[ToolCallInfo],
+    name: str,
+    params: Optional[dict[str, Any]] = None,
+) -> Iterator[ToolCallInfo]:
+    tc = ToolCallInfo(name=name, status="running", params=params)
+    tool_calls.append(tc)
+    try:
+        yield tc
+        if tc.status == "running":
+            tc.status = "success"
+    except Exception as e:
+        tc.status = "error"
+        if not tc.result_summary:
+            tc.result_summary = f"执行失败: {str(e)[:120]}"
+        raise
+
+
+def normalize_tool_calls(tool_calls: List[Any]) -> List[ToolCallInfo]:
+    safe_calls: List[ToolCallInfo] = []
+    for x in tool_calls:
+        if isinstance(x, ToolCallInfo):
+            safe_calls.append(x)
+        elif isinstance(x, dict):
+            try:
+                safe_calls.append(ToolCallInfo(**x))
+            except Exception:
+                safe_calls.append(
+                    ToolCallInfo(
+                        name="tool_call",
+                        status="error",
+                        result_summary=str(x)[:200],
+                    )
+                )
+        else:
+            safe_calls.append(
+                ToolCallInfo(name="tool_call", status="error", result_summary=str(x)[:200])
+            )
+    return safe_calls
+
+
+def last_pipeline_tool_error(tool_calls: List[ToolCallInfo]) -> Optional[str]:
+    for tc in reversed(tool_calls or []):
+        if getattr(tc, "name", None) != "search_pipeline":
+            continue
+        if getattr(tc, "status", None) != "error":
+            continue
+        s = (getattr(tc, "result_summary", None) or "").strip()
+        return s or "search_pipeline_error"
+    return None

+ 35 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/api/tool_events.py

@@ -0,0 +1,35 @@
+"""SSE 事件推送 —— 工具调用追踪器与 SSE 消息编解码."""
+
+from __future__ import annotations
+
+import json
+import time
+from dataclasses import dataclass
+from typing import Any, Callable, Dict, List, Optional
+
+@dataclass
+class ToolEvent:
+    ts_ms: int
+    type: str
+    payload: Dict[str, Any]
+
+class ToolCallTracker:
+
+    def __init__(self, sink: Optional[Callable[[Dict[str, Any]], None]] = None) -> None:
+        self._events: List[ToolEvent] = []
+        self._sink = sink
+
+    def emit(self, type: str, payload: Dict[str, Any]) -> None:
+        ev = ToolEvent(ts_ms=int(time.time() * 1000), type=type, payload=dict(payload or {}))
+        self._events.append(ev)
+        if self._sink:
+            self._sink(self.to_wire(ev))
+
+    def to_wire(self, ev: ToolEvent) -> Dict[str, Any]:
+        return {"type": ev.type, "ts_ms": ev.ts_ms, **(ev.payload or {})}
+
+    def snapshot(self) -> List[Dict[str, Any]]:
+        return [self.to_wire(e) for e in self._events]
+
+def sse_pack(event: Dict[str, Any]) -> str:
+    return "data: " + json.dumps(event, ensure_ascii=False, default=str) + "\n\n"

+ 1 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/__init__.py

@@ -0,0 +1 @@
+from __future__ import annotations

+ 41 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/author.py

@@ -0,0 +1,41 @@
+"""作者领域模型 —— Author 数据类及其序列化/反序列化."""
+
+from dataclasses import dataclass
+from typing import Any
+
+@dataclass
+class Author:
+    name: str
+    affiliation: str | None = None
+    email: str | None = None
+    orcid: str | None = None
+
+    db_id: int | None = None
+
+    def to_dict(self) -> dict[str, Any]:
+        d: dict[str, Any] = {
+            "name": self.name,
+            "affiliation": self.affiliation,
+            "email": self.email,
+            "orcid": self.orcid,
+        }
+        if self.db_id is not None:
+            d["db_id"] = int(self.db_id)
+        return d
+
+    @classmethod
+    def from_dict(cls, data: dict[str, Any]) -> "Author":
+        raw_id = data.get("db_id")
+        db_id: int | None = None
+        if raw_id is not None:
+            try:
+                db_id = int(raw_id)
+            except Exception:
+                db_id = None
+        return cls(
+            name=data.get("name", ""),
+            affiliation=data.get("affiliation"),
+            email=data.get("email"),
+            orcid=data.get("orcid"),
+            db_id=db_id,
+        )

+ 145 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/conference_landing_pdf.py

@@ -0,0 +1,145 @@
+"""会议官网 PDF 探测 —— 从会议 landing page 提取论文 PDF 链接."""
+
+from __future__ import annotations
+
+import re
+from urllib.parse import urljoin, urlparse
+
+import requests
+
+_ABS_PDF_HTTP = re.compile(r"https?://[^'\"<>\s]+?\.pdf", re.I)
+
+_HREF_PDF = re.compile(
+    r'(?:href|src|data-href)\s*=\s*(["\'])([^"\']+?\.pdf)\1',
+    re.I,
+)
+
+_META_CONTENT_PDF = re.compile(
+    r'<meta[^>]+(?:property|name)\s*=\s*["\']?(?:pdf_url|citation_pdf_url|og:pdf_url)["\']?[^>]+content\s*=\s*["\']([^"\']+?\.pdf)["\']',
+    re.I | re.S,
+)
+_META_CONTENT_PDF_ALT = re.compile(
+    r'<meta[^>]+content\s*=\s*["\']([^"\']+?\.pdf)["\'][^>]+(?:property|name)\s*=\s*["\']?(?:pdf_url|citation_pdf_url|og:pdf_url)["\']?',
+    re.I | re.S,
+)
+_GENERIC_CONTENT_PDF = re.compile(
+    r'content\s*=\s*["\']([^"\']+?\.pdf)["\']',
+    re.I,
+)
+
+_OJS_VIEW_OR_DL = re.compile(
+    r'(?:href|data-href)\s*=\s*(["\'])([^"\']*?/article/(?:view|download)/\d+/\d+[^"\']*)\1',
+    re.I,
+)
+
+def _is_relative_pdf_path(rel: str) -> bool:
+    s = (rel or "").strip()
+    if not s or s.startswith("//"):
+        return False
+    low = s.lower()
+    return not low.startswith(("http://", "https://", "javascript:", "mailto:", "#"))
+
+def _same_site_pdf_preference(base_url: str, candidates: list[str]) -> str | None:
+    if not candidates:
+        return None
+    try:
+        base_host = (urlparse(base_url).netloc or "").lower().removeprefix("www.")
+    except ValueError:
+        base_host = ""
+    for c in candidates:
+        try:
+            h = (urlparse(c).netloc or "").lower().removeprefix("www.")
+            if base_host and h == base_host:
+                return c
+        except ValueError:
+            continue
+    return candidates[0]
+
+def fetch_pdf_url_from_html_page(
+    url: str,
+    email: str = "",
+    timeout: int = 35,
+    max_bytes: int = 800_000,
+) -> str | None:
+    u0 = (url or "").strip()
+    if not u0.lower().startswith(("http://", "https://")):
+        return None
+
+    mail = (email or "").strip()
+    ua = f"PaperGraph/0.3 (mailto:{mail})" if mail else "PaperGraph/0.3"
+    headers = {"User-Agent": ua}
+
+    try:
+        with requests.get(u0, timeout=timeout, headers=headers, stream=True) as r:
+            if r.status_code != 200:
+                return None
+            buf = bytearray()
+            for chunk in r.iter_content(65536):
+                if chunk:
+                    buf.extend(chunk)
+                    if len(buf) >= max_bytes:
+                        break
+        text = bytes(buf).decode("utf-8", errors="ignore")
+    except (requests.RequestException, OSError, ValueError):
+        return None
+
+    found: list[str] = []
+    seen: set[str] = set()
+
+    def _add_url(raw_url: str) -> None:
+        s = raw_url.strip().split("?", 1)[0]
+        if not s.lower().endswith(".pdf"):
+            return
+        low = s.lower()
+        if low not in seen:
+            seen.add(low)
+            found.append(s)
+
+    try:
+        for m in _ABS_PDF_HTTP.finditer(text):
+            _add_url(m.group(0))
+
+        for m in _HREF_PDF.finditer(text):
+            path = m.group(2).strip()
+            if path.startswith("//"):
+                _add_url(urljoin(u0, path))
+            elif path.lower().startswith(("http://", "https://")):
+                _add_url(path)
+            elif _is_relative_pdf_path(path):
+                _add_url(urljoin(u0, path))
+
+        # Meta content="..." PDF URLs (e.g., NeurIPS proceedings abstract pages)
+        if not found:
+            for pat in (_META_CONTENT_PDF, _META_CONTENT_PDF_ALT):
+                for m in pat.finditer(text):
+                    path = m.group(1).strip()
+                    if path.lower().startswith(("http://", "https://")):
+                        _add_url(path)
+                    elif _is_relative_pdf_path(path):
+                        _add_url(urljoin(u0, path))
+
+        if not found:
+            for m in _GENERIC_CONTENT_PDF.finditer(text):
+                path = m.group(1).strip()
+                if path.lower().startswith(("http://", "https://")):
+                    _add_url(path)
+                elif _is_relative_pdf_path(path):
+                    _add_url(urljoin(u0, path))
+
+        if not found:
+            for m in _OJS_VIEW_OR_DL.finditer(text):
+                path = m.group(2).strip()
+                abs_u = urljoin(u0, path)
+                if "/article/view/" in abs_u:
+                    abs_u = abs_u.replace("/article/view/", "/article/download/", 1)
+                low = abs_u.lower()
+                if "citationstylelanguage" in low or low in seen:
+                    continue
+                seen.add(low)
+                found.append(abs_u)
+    except re.error:
+        return None
+
+    if not found:
+        return None
+    return _same_site_pdf_preference(u0, found)

+ 174 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/paper.py

@@ -0,0 +1,174 @@
+"""论文领域模型 —— Paper 数据类及其序列化/反序列化."""
+
+from dataclasses import dataclass, field
+from typing import Any
+from datetime import datetime
+
+from .author import Author
+
+@dataclass
+class Paper:
+    title: str
+    authors: list[Author] = field(default_factory=list)
+    abstract: str | None = None
+    doi: str | None = None
+    pmid: str | None = None
+    arxiv_id: str | None = None
+    pmc_id: str | None = None
+    journal: str | None = None
+    venue_type: str | None = None
+    year: int | None = None
+    volume: str | None = None
+    issue: str | None = None
+    pages: str | None = None
+    publisher: str | None = None
+    pdf_url: str | None = None
+    source_url: str | None = None
+    local_pdf_path: str | None = None
+    keywords: list[str] = field(default_factory=list)
+    mesh_terms: list[str] = field(default_factory=list)
+    references: list[str] = field(default_factory=list)
+    citations: int = 0
+    source: str = "unknown"
+    relevance_score: int = 0
+    notes: str | None = None
+    tags: list[str] = field(default_factory=list)
+    category: str | None = None
+    rating: int | None = None
+    read_status: str = "unread"
+    importance: str = "normal"
+    id: int | None = None
+    created_at: datetime = field(default_factory=datetime.now)
+    updated_at: datetime = field(default_factory=datetime.now)
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "id": self.id,
+            "title": self.title,
+            "authors": [a.to_dict() if hasattr(a, "to_dict") else {"name": str(a)} for a in self.authors],
+            "abstract": self.abstract,
+            "doi": self.doi,
+            "pmid": self.pmid,
+            "arxiv_id": self.arxiv_id,
+            "pmc_id": self.pmc_id,
+            "journal": self.journal,
+            "venue_type": self.venue_type,
+            "year": self.year,
+            "volume": self.volume,
+            "issue": self.issue,
+            "pages": self.pages,
+            "publisher": self.publisher,
+            "pdf_url": self.pdf_url,
+            "source_url": self.source_url,
+            "local_pdf_path": self.local_pdf_path,
+            "keywords": self.keywords,
+            "mesh_terms": self.mesh_terms,
+            "references": self.references,
+            "citations": self.citations,
+            "source": self.source,
+            "relevance_score": self.relevance_score,
+            "notes": self.notes,
+            "tags": self.tags,
+            "category": self.category,
+            "rating": self.rating,
+            "read_status": self.read_status,
+            "importance": self.importance,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+        }
+
+    @classmethod
+    def from_dict(cls, data: dict[str, Any]) -> "Paper":
+        def _parse_dt(val: Any) -> datetime | None:
+            if val is None:
+                return None
+            if isinstance(val, datetime):
+                return val
+            if isinstance(val, str):
+                s = val.strip()
+                if not s:
+                    return None
+                try:
+                    if s.endswith("Z"):
+                        s = s[:-1] + "+00:00"
+                    return datetime.fromisoformat(s)
+                except ValueError:
+                    return None
+            return None
+
+        def _safe_int(val: Any, default: int = 0) -> int:
+            if val is None:
+                return default
+            if isinstance(val, bool):
+                return int(val)
+            if isinstance(val, int):
+                return val
+            try:
+                return int(float(val))
+            except (TypeError, ValueError):
+                return default
+
+        def _safe_year(val: Any) -> int | None:
+            if val is None or val == "":
+                return None
+            y = _safe_int(val, -1)
+            return y if 1000 <= y <= 3000 else None
+
+        def _str_list(val: Any) -> list[str]:
+            if not isinstance(val, list):
+                return []
+            out: list[str] = []
+            for x in val:
+                if x is None:
+                    continue
+                if isinstance(x, str):
+                    t = x.strip()
+                    if t:
+                        out.append(t)
+                else:
+                    out.append(str(x).strip())
+            return out
+
+        def _safe_id(val: Any) -> int | None:
+            if val is None or val == "":
+                return None
+            i = _safe_int(val, -1)
+            return i if i >= 0 else None
+
+        created_at = _parse_dt(data.get("created_at"))
+        updated_at = _parse_dt(data.get("updated_at"))
+
+        return cls(
+            title=data.get("title", ""),
+            authors=[Author.from_dict(a) if isinstance(a, dict) else Author(name=str(a)) for a in data.get("authors", [])],
+            abstract=data.get("abstract"),
+            doi=data.get("doi"),
+            pmid=data.get("pmid"),
+            arxiv_id=data.get("arxiv_id"),
+            pmc_id=data.get("pmc_id"),
+            journal=data.get("journal"),
+            venue_type=data.get("venue_type"),
+            year=_safe_year(data.get("year")),
+            volume=data.get("volume"),
+            issue=data.get("issue"),
+            pages=data.get("pages"),
+            publisher=data.get("publisher"),
+            pdf_url=data.get("pdf_url"),
+            source_url=data.get("source_url"),
+            local_pdf_path=data.get("local_pdf_path"),
+            keywords=_str_list(data.get("keywords")),
+            mesh_terms=_str_list(data.get("mesh_terms")),
+            references=_str_list(data.get("references")),
+            citations=_safe_int(data.get("citations"), 0),
+            source=data.get("source", "unknown"),
+            relevance_score=_safe_int(data.get("relevance_score"), 0),
+            notes=data.get("notes"),
+            tags=_str_list(data.get("tags")),
+            category=data.get("category"),
+            rating=_safe_int(data.get("rating"), 0) if data.get("rating") is not None else None,
+            read_status=data.get("read_status", "unread"),
+            importance=data.get("importance", "normal"),
+            id=_safe_id(data.get("id")),
+            created_at=created_at or datetime.now(),
+            updated_at=updated_at or datetime.now(),
+        )

+ 109 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/paper_paths.py

@@ -0,0 +1,109 @@
+"""论文文件路径管理 —— PDF 存储路径生成与文件名规范化."""
+
+import hashlib
+import re
+
+LIBRARY_PDF_ROOT_DIR = "文献库"
+
+_UNCATEGORIZED_ALIASES = frozenset(
+    {
+        "无分类",
+        "无分類",
+        "无类目",
+        "未归类",
+        "不分類",
+        "uncategorized",
+        "uncategorised",
+        "none",
+        "n/a",
+        "na",
+        "null",
+        "-",
+        "—",
+    }
+)
+
+def _segment_is_uncategorized_alias(seg: str) -> bool:
+    t = (seg or "").strip()
+    if not t:
+        return True
+    if t in _UNCATEGORIZED_ALIASES:
+        return True
+    tl = t.lower()
+    return tl in _UNCATEGORIZED_ALIASES or tl in ("no category", "no cat", "not categorized")
+
+def normalize_library_category_display(display: str | None) -> str:
+    raw0 = (display or "").strip()
+    if not raw0 or _segment_is_uncategorized_alias(raw0):
+        return "未分类"
+    parts = [p.strip() for p in raw0.split("/") if p.strip()]
+    cleaned: list[str] = []
+    for p in parts[:4]:
+        s = "".join(ch for ch in p if ch not in '/\\:*?"<>|')
+        s = s.strip()
+        if len(s) > 32:
+            s = s[:32]
+        if _segment_is_uncategorized_alias(s):
+            s = "未分类"
+        if s:
+            cleaned.append(s)
+    if not cleaned:
+        return "未分类"
+    return "/".join(cleaned)
+
+def _sanitize_filename(title: str | None, max_len: int = 80) -> str:
+    if not title:
+        return "untitled"
+
+    safe_chars = []
+    for ch in title.strip():
+        if ch.isalnum() or ("\u4e00" <= ch <= "\u9fff"):
+            safe_chars.append(ch)
+        elif ch in (" ", "-", "_", "."):
+            safe_chars.append("_")
+        else:
+            safe_chars.append("_")
+
+    result = "".join(safe_chars)
+
+    result = re.sub(r"_+", "_", result).strip("_")
+
+    if len(result) > max_len:
+        result = result[:max_len].rsplit("_", 1)[0]
+
+    return result or "untitled"
+
+def category_slug_for_pdf_dir(display: str | None) -> str:
+    seg = (display or "").strip()
+    if _segment_is_uncategorized_alias(seg):
+        seg = "未分类"
+    t = seg or "未分类"
+    raw = []
+    for ch in t:
+        if ch.isalnum() or ("\u4e00" <= ch <= "\u9fff"):
+            raw.append(ch)
+        elif ch in (" ", "-", "_", "."):
+            raw.append("_")
+        else:
+            raw.append("_")
+    s = "".join(raw)
+    s = re.sub(r"_+", "_", s).strip("_")[:48]
+    if len(s) >= 2:
+        return s
+    h = hashlib.sha256(t.encode("utf-8")).hexdigest()[:12]
+    return f"cat_{h}"
+
+def library_pdf_relative_path(
+    category_display: str | None, paper_id: int, title: str | None = None
+) -> str:
+    t = (category_display or "").strip() or "未分类"
+    parts = [p.strip() for p in t.split("/") if p.strip()]
+    if not parts:
+        parts = ["未分类"]
+    segs = [category_slug_for_pdf_dir(p) for p in parts]
+
+    title_part = _sanitize_filename(title)
+    short_id = hashlib.sha256(str(paper_id).encode()).hexdigest()[:6]
+    filename = f"{title_part}_{short_id}.pdf"
+
+    return "/".join([LIBRARY_PDF_ROOT_DIR] + segs + [filename])

+ 227 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/pdf_download.py

@@ -0,0 +1,227 @@
+"""PDF 下载服务 —— 支持 arXiv/DBLP/出版商多源 PDF 获取与本地缓存."""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import os
+import re
+from urllib.parse import urlparse
+
+import requests
+
+from .conference_landing_pdf import fetch_pdf_url_from_html_page
+from .paper import Paper
+
+_log = logging.getLogger(__name__)
+
+def _is_pkp_ojs_article_download_url(url: str) -> bool:
+    return bool(re.search(r"/article/download/\d+/\d+", (url or ""), re.I))
+
+def _is_direct_pdf_url(url: str) -> bool:
+    u = (url or "").strip()
+    if not u:
+        return False
+    ul = u.lower().split("?", 1)[0]
+    return ul.endswith(".pdf") or _is_pkp_ojs_article_download_url(u)
+
+def _derive_proceedings_pdf_url(source_url: str) -> str | None:
+    """Derive PDF URL from known proceedings abstract page URL patterns."""
+    u = (source_url or "").strip()
+    if not u:
+        return None
+    # NeurIPS: hash/XXX-Abstract-Conference.html → file/XXX-Paper-Conference.pdf
+    m = re.match(r"(.*)/hash/([a-f0-9]+)-Abstract(-\w+)?\.html$", u, re.I)
+    if m:
+        suffix = (m.group(3) or "-Conference")
+        return f"{m.group(1)}/file/{m.group(2)}-Paper{suffix}.pdf"
+    # CVF (CVPR/ICCV/ECCV): .../html/PaperName-paper.html → .../papers/PaperName-paper.pdf
+    m = re.match(r"(.*)/html/(.+?)\.html$", u, re.I)
+    if m and ("/content/" in u.lower()):
+        return f"{m.group(1)}/papers/{m.group(2)}.pdf"
+    # OpenReview: forum?id=X → pdf?id=X
+    if "openreview.net/forum" in u:
+        return re.sub(r"/forum\?id=", "/pdf?id=", u)
+    # Generic: .html → .pdf
+    if u.lower().endswith(".html"):
+        return re.sub(r"\.html$", ".pdf", u, flags=re.I)
+    return None
+
+
+def _pdf_download_candidates(paper: Paper, email: str) -> list[str]:
+    out: list[str] = []
+    seen: set[str] = set()
+
+    def _push(u: str | None) -> None:
+        s = (u or "").strip()
+        if s and s.lower().startswith(("http://", "https://")):
+            low = s.lower()
+            if low not in seen:
+                seen.add(low)
+                out.append(s)
+
+    pu = (getattr(paper, "pdf_url", None) or "").strip()
+    if pu and _is_direct_pdf_url(pu):
+        _push(pu)
+
+    su = (getattr(paper, "source_url", None) or "").strip()
+    if su and _is_direct_pdf_url(su):
+        _push(su)
+
+    try:
+        resolved = resolve_paper_pdf_url(paper, email=email)
+        _push(resolved)
+    except Exception as ex:
+        _log.warning("resolve_paper_pdf_url 异常(已忽略): %s", ex, exc_info=True)
+
+    # Last resort: derive PDF URL from abstract page URL pattern
+    if not out and su:
+        derived = _derive_proceedings_pdf_url(su)
+        _push(derived)
+
+    return out
+
+def _headers_for_pdf_get(url: str, paper: Paper, email: str) -> dict:
+    mail = (email or "").strip()
+    ref = (getattr(paper, "source_url", None) or "").strip()
+
+    if not ref or not ref.lower().startswith("http"):
+        try:
+            pr = urlparse(url)
+            if pr.scheme and pr.netloc:
+                ref = f"{pr.scheme}://{pr.netloc}/"
+        except ValueError:
+            ref = ""
+
+    ua = f"PaperGraph/0.3 (mailto:{mail})" if mail else "PaperGraph/0.3"
+    h = {
+        "User-Agent": ua,
+        "Accept": "application/pdf,application/octet-stream,*/*;q=0.8",
+    }
+    if ref and ref.lower().startswith("http"):
+        h["Referer"] = ref[:2048]
+    return h
+
+def _file_looks_like_pdf(path: str) -> bool:
+    try:
+        with open(path, "rb") as f:
+            return f.read(5) == b"%PDF-"
+    except OSError:
+        return False
+
+def _normalize_doi_url(doi: str) -> str | None:
+    d = (doi or "").strip()
+    if not d:
+        return None
+    d = re.sub(r"^https?://(dx\.)?doi\.org/", "", d, flags=re.I).strip().rstrip("/")
+    return f"https://doi.org/{d}" if d else None
+
+def _should_probe_html_for_pdf(url: str) -> bool:
+    u = (url or "").strip().lower()
+    if not u.startswith(("http://", "https://")):
+        return False
+    if _is_pkp_ojs_article_download_url(u):
+        return False
+    return not re.search(r"openalex\.org/(?:w\d+|works/)", u)
+
+def resolve_paper_pdf_url(paper: Paper, email: str = "") -> str | None:
+    u = (getattr(paper, "pdf_url", None) or "").strip()
+    su = (getattr(paper, "source_url", None) or "").strip()
+    doi_u = _normalize_doi_url(getattr(paper, "doi", None) or "")
+
+    if u and _is_direct_pdf_url(u):
+        return u
+
+    from app.core.search import _arxiv_canonical_from_paper, _arxiv_pdf_url_from_id
+
+    ax = _arxiv_pdf_url_from_id(_arxiv_canonical_from_paper(paper))
+    if ax:
+        return ax
+
+    # Recover arXiv PDF URL from DOI/source URL.
+    if not ax:
+        for field_val in (getattr(paper, "doi", None) or "", getattr(paper, "source_url", None) or ""):
+            m = re.search(r"arxiv/([\d.]+)", str(field_val), re.I)
+            if m:
+                ax = f"https://arxiv.org/pdf/{m.group(1)}"
+                return ax
+
+    probe_candidates: list[str] = []
+    seen: set[str] = set()
+    for cand in (u, su, doi_u):
+        if cand and _should_probe_html_for_pdf(cand):
+            norm = cand.rstrip("/").lower()
+            if norm not in seen:
+                seen.add(norm)
+                probe_candidates.append(cand)
+
+    for cand_url in probe_candidates:
+        try:
+            got = fetch_pdf_url_from_html_page(cand_url, email=email)
+            if got:
+                return got
+        except Exception:
+            continue
+
+    # OpenReview forum pages expose PDFs at /pdf?id=...
+    if su and "openreview.net/forum" in su:
+        return re.sub(r"/forum\?id=", "/pdf?id=", su)
+
+    # Some DOI URLs redirect directly to open-access PDFs.
+    if doi_u:
+        try:
+            mail = (email or "").strip()
+            ua = f"PaperGraph/0.3 (mailto:{mail})" if mail else "PaperGraph/0.3"
+            headers = {"User-Agent": ua, "Accept": "application/pdf"}
+            with requests.get(doi_u, timeout=30, headers=headers, allow_redirects=True, stream=True) as r:
+                if r.status_code == 200 and r.headers.get("content-type", "").startswith("application/pdf"):
+                    return doi_u
+        except Exception:
+            pass
+
+    return None
+
+def _cleanup_temp_file(tmp_path: str) -> None:
+    with contextlib.suppress(OSError):
+        if os.path.isfile(tmp_path):
+            os.remove(tmp_path)
+
+def download_paper_pdf_to_path(paper: Paper, dest_abspath: str, email: str = "") -> bool:
+    try:
+        urls = _pdf_download_candidates(paper, email=email)
+        if not urls:
+            return False
+
+        with contextlib.suppress(OSError):
+            os.makedirs(os.path.dirname(dest_abspath) or ".", exist_ok=True)
+
+        for url in urls:
+            tmp = dest_abspath + ".part"
+            try:
+                headers = _headers_for_pdf_get(url, paper, email)
+                with requests.get(url, timeout=90, stream=True, headers=headers, allow_redirects=True) as r:
+                    if r.status_code != 200:
+                        continue
+
+                    with open(tmp, "wb") as f:
+                        for chunk in r.iter_content(chunk_size=65536):
+                            if chunk:
+                                f.write(chunk)
+
+                if os.path.getsize(tmp) < 256 or not _file_looks_like_pdf(tmp):
+                    _cleanup_temp_file(tmp)
+                    continue
+
+                os.replace(tmp, dest_abspath)
+                return True
+
+            except (OSError, requests.RequestException):
+                _cleanup_temp_file(tmp)
+                continue
+
+        return False
+
+    except Exception as ex:
+        _log.warning("download_paper_pdf_to_path 异常: %s", ex, exc_info=True)
+        _cleanup_temp_file(dest_abspath + ".part")
+        return False

+ 11 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/__init__.py

@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from .normalize import _arxiv_canonical_from_paper, _arxiv_pdf_url_from_id, sanitize_pinned_topic_keywords
+from .paper_searcher import PaperSearcher
+
+__all__ = [
+    "PaperSearcher",
+    "_arxiv_canonical_from_paper",
+    "_arxiv_pdf_url_from_id",
+    "sanitize_pinned_topic_keywords",
+]

+ 312 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/normalize.py

@@ -0,0 +1,312 @@
+"""搜索结果标准化 —— arxiv ID 规范化、关键词清洗与去重."""
+
+from __future__ import annotations
+
+import functools
+import re
+from typing import Any
+
+_QUERY_DELIM = re.compile(r"[,;\uFF0C\uFF1B\n]+")
+_RE_JATS_MARKUP = re.compile(r"<[^>]+>")
+_RE_MULTIPLE_SPACES = re.compile(r"\s+")
+_RE_ARXIV_DOI = re.compile(r"10\.48550/arxiv\.?([\d.]+\d)", re.I)
+_RE_ARXIV_URL_IN_DOI = re.compile(r"arxiv\.org/(?:abs|pdf)/([\w./]+)", re.I)
+_RE_ARXIV_VERSION_SUFFIX = re.compile(r"v\d+$", re.I)
+_RE_TITLE_ENTITY = re.compile(r"&amp;|&lt;|&gt;|&quot;|&#\d+;")
+_RE_TITLE_NON_ALNUM = re.compile(r"[^a-z0-9\u4e00-\u9fff]+", re.I)
+
+
+@functools.lru_cache(maxsize=128)
+def _venue_canonical_key(venue: str) -> str:
+    v = (venue or "").strip().lower()
+    v = re.sub(r"[^a-z0-9]", "", v)
+    return v if len(v) >= 2 else ""
+
+
+def _score_name_match(display_name: str, query_name: str) -> int:
+    dl, ql = display_name.strip().lower(), query_name.strip().lower()
+    if not dl or not ql: return 0
+    parts = [x for x in ql.replace(",", " ").split() if x]
+    score = 0
+    if dl == ql: score += 10
+    if ql in dl or dl in ql: score += 6
+    if parts and all(p in dl for p in parts if len(p) >= 2): score += 4
+    return score
+
+
+def split_query_phrases(query: str) -> list[str]:
+    q = (query or "").strip()
+    if not q:
+        return []
+    if not _QUERY_DELIM.search(q):
+        return [(q or "").strip()]
+    parts: list[str] = []
+    for p in _QUERY_DELIM.split(q):
+        t = p.strip()
+        if t:
+            parts.append((t or "").strip())
+    return parts if parts else [(q or "").strip()]
+
+
+def sanitize_search_keyword_list(raw: Any) -> list[str]:
+    if not isinstance(raw, list):
+        return []
+    out: list[str] = []
+    for x in raw:
+        if x is None:
+            continue
+        s = str(x).strip()
+        if s and s not in out:
+            out.append(s)
+        if len(out) >= 24:
+            break
+    return out
+
+
+def _normalize_title_for_dedupe(title: str | None) -> str:
+    if not title:
+        return ""
+    t = str(title).lower()
+    t = _RE_TITLE_ENTITY.sub(" ", t)
+    t = _RE_JATS_MARKUP.sub(" ", t)
+    t = _RE_TITLE_NON_ALNUM.sub(" ", t)
+    return _RE_MULTIPLE_SPACES.sub(" ", t).strip()
+
+
+def arxiv_id_from_doi(doi: str | None) -> str | None:
+    if not doi:
+        return None
+    d = str(doi).strip().lower()
+    if d.startswith("https://doi.org/"):
+        d = d[len("https://doi.org/"):].strip()
+    if not d:
+        return None
+    m = _RE_ARXIV_DOI.search(d)
+    if m:
+        return _RE_ARXIV_VERSION_SUFFIX.sub("", m.group(1)).lower()
+    m2 = _RE_ARXIV_URL_IN_DOI.search(d)
+    if m2:
+        raw = m2.group(1).split("/")[-1]
+        return _RE_ARXIV_VERSION_SUFFIX.sub("", raw).lower()
+    return None
+
+
+def _arxiv_pdf_url_from_id(arxiv_id: str | None) -> str | None:
+    if not arxiv_id:
+        return None
+    raw = str(arxiv_id).strip()
+    if not raw:
+        return None
+    low = raw.lower()
+    if "arxiv.org/pdf/" in low:
+        u = raw if raw.lower().startswith("http") else "https://" + raw.lstrip("/")
+        return u.split("?", 1)[0].rstrip("/")
+    if "arxiv.org/abs/" in low:
+        tail = raw.split("arxiv.org/abs/", 1)[-1].strip().rstrip("/")
+    else:
+        tail = re.sub(r"^arxiv:", "", raw, flags=re.I).strip()
+        tail = tail.split("/")[-1]
+    tail = tail.strip().rstrip("/")
+    if not tail or not re.search(r"\d", tail):
+        return None
+    return f"https://arxiv.org/pdf/{tail}.pdf"
+
+
+def _arxiv_canonical_from_paper(paper: Any) -> str | None:
+    aid = (paper.arxiv_id or "").strip()
+    if aid.lower().startswith("arxiv:"):
+        aid = aid[6:]
+    aid = aid.strip().rstrip("/")
+    aid = _RE_ARXIV_VERSION_SUFFIX.sub("", aid)
+    if aid and re.search(r"\d", aid):
+        return aid.lower()
+    return arxiv_id_from_doi(paper.doi)
+
+
+def _openalex_collect_landing_urls(w: dict[str, Any]) -> list[str]:
+    out: list[str] = []
+    seen: set[str] = set()
+
+    def add(raw: str | None) -> None:
+        if not raw or not isinstance(raw, str):
+            return
+        s = raw.strip().split("?", 1)[0]
+        if not s.lower().startswith("http"):
+            return
+        sl = s.lower()
+        if bool(re.search(r"https?://(api\.)?openalex\.org/works?/", sl)) or bool(
+            re.search(r"https?://(www\.)?openalex\.org/w\d+", sl)
+        ):
+            return
+        if sl in seen:
+            return
+        seen.add(sl)
+        out.append(s)
+
+    for key in ("best_oa_location", "primary_location"):
+        loc = w.get(key) or {}
+        if isinstance(loc, dict):
+            add(loc.get("landing_page_url"))
+    for loc in w.get("locations") or []:
+        if isinstance(loc, dict):
+            add(loc.get("landing_page_url"))
+    oa = w.get("open_access") or {}
+    if isinstance(oa, dict):
+        add(oa.get("oa_url"))
+    return out
+
+
+def _openalex_human_source_url(w: dict[str, Any], doi: str | None) -> str | None:
+    land = _openalex_collect_landing_urls(w)
+    for s in land:
+        if not s.lower().endswith(".pdf"):
+            return s
+    for s in land:
+        if s.lower().endswith(".pdf"):
+            return s
+    if doi:
+        d = str(doi).strip().lstrip("https://doi.org/").lstrip("doi.org/")
+        if d:
+            return f"https://doi.org/{d}"
+    wid = w.get("id")
+    return str(wid).strip() if isinstance(wid, str) and wid.strip() else None
+
+
+def _openalex_resolve_pdf_url(w: dict[str, Any]) -> str | None:
+    if not isinstance(w, dict):
+        return None
+    for key in ("best_oa_location", "primary_location"):
+        loc = w.get(key) or {}
+        if isinstance(loc, dict):
+            u = loc.get("pdf_url")
+            if isinstance(u, str) and u.strip():
+                return u.strip().split("?", 1)[0]
+    for loc in w.get("locations") or []:
+        if not isinstance(loc, dict):
+            continue
+        u = loc.get("pdf_url")
+        if isinstance(u, str) and u.strip():
+            return u.strip().split("?", 1)[0]
+    oa = w.get("open_access") or {}
+    if isinstance(oa, dict):
+        u = oa.get("oa_url")
+        if isinstance(u, str) and u.strip():
+            ul = u.strip().lower()
+            if ul.endswith(".pdf") or "arxiv.org/pdf" in ul:
+                return u.strip().split("?", 1)[0]
+
+    for s in _openalex_collect_landing_urls(w):
+        ul = s.lower()
+        if ul.endswith(".pdf") or "arxiv.org/pdf" in ul:
+            return s
+    return None
+
+
+def normalized_query_for_text_apis(query: str) -> str:
+    parts = split_query_phrases(query)
+    return " ".join(parts).strip()
+
+
+def plain_query_for_text_apis(query: str, kwargs: dict[str, Any]) -> str:
+    lk = sanitize_search_keyword_list(kwargs.get("llm_keywords"))
+    if not lk:
+        return normalized_query_for_text_apis(query)
+    if len(lk) >= 2:
+        q0 = ((query or "").strip() or str(lk[0]).strip())
+        venue_kw = (kwargs.get("venue") or "").strip()
+
+        if venue_kw and len(q0) <= 16:
+            tail: list[str] = []
+            for x in lk[1:8]:
+                t = str(x).strip()
+                if not t or t.lower() == q0.lower():
+                    continue
+                tail.append(t)
+                if len(tail) >= 4:
+                    break
+            if tail:
+                merged = f"{q0} {' '.join(tail)}".strip()
+                return merged[:200]
+        return q0[:200] if q0 else str(lk[0]).strip()[:200]
+    return (str(lk[0]).strip() or normalized_query_for_text_apis(query))[:200]
+
+
+def topic_terms_excluding_venue_year(
+    query: str,
+    *,
+    extra_terms: list[str] | None = None,
+    venue: str | None = None,
+    year: int | None = None,
+) -> str:
+    seen: set[str] = set()
+    out: list[str] = []
+    vlow = (venue or "").strip().lower()
+
+    def _consume(text: str) -> None:
+        for seg in _QUERY_DELIM.split(text or ""):
+            for tok in re.split(r"\s+", seg.strip()):
+                t = tok.strip()
+                if not t or len(t) < 2:
+                    continue
+                if year is not None and re.fullmatch(r"(?:19|20)\d{2}", t):
+                    continue
+                tl = t.lower()
+                if vlow and (tl == vlow or re.sub(r"[^a-z0-9]", "", tl) == re.sub(r"[^a-z0-9]", "", vlow)):
+                    continue
+                if tl in seen:
+                    continue
+                seen.add(tl)
+                out.append(t)
+                if len(out) >= 12:
+                    return
+
+    _consume(query or "")
+    for x in extra_terms or []:
+        if len(out) >= 12:
+            break
+        _consume(str(x))
+    return " ".join(out).strip()
+
+
+def sanitize_pinned_topic_keywords(raw: list[str] | None) -> list[str]:
+    out: list[str] = []
+    seen: set[str] = set()
+    for x in raw or []:
+        s = str(x).strip()
+        if not s or len(s) < 2 or len(s) > 80:
+            continue
+        if s.lower() in seen:
+            continue
+        seen.add(s.lower())
+        out.append(s)
+    return out[:10]
+
+
+def extract_pinned_topic_terms(
+    *,
+    query: str,
+    merged_kw: list[str] | None,
+    venue: str,
+    year: int | None,
+) -> str:
+    seen: set[str] = set()
+    parts: list[str] = []
+
+    def _push(text: str) -> None:
+        t = (text or "").strip()
+        if not t:
+            return
+        tl = t.lower()
+        if tl in seen:
+            return
+        seen.add(tl)
+        parts.append(t)
+
+    t_query = topic_terms_excluding_venue_year(query or "", venue=venue, year=year)
+    if t_query:
+        _push(t_query)
+    for kw in sanitize_pinned_topic_keywords(list(merged_kw or [])):
+        tk = topic_terms_excluding_venue_year(kw, venue=venue, year=year)
+        if tk:
+            _push(tk)
+    return " ".join(parts[:12]).strip()

+ 406 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/paper_searcher.py

@@ -0,0 +1,406 @@
+"""论文搜索引擎 —— 多源适配(arXiv/DBLP/OpenAlex/Tavily)的统一调用入口."""
+
+from __future__ import annotations
+
+import asyncio, hashlib, logging, os, re, time
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import httpx, requests
+
+from ..paper import Paper
+from ...utils.async_sync import run_coroutine_sync
+from ...utils.author_query_match import (
+    normalize_author_names, author_phrase_matches_canonical_line,
+    is_author_centric_search, pick_primary_english_author_for_query,
+)
+from .normalize import (
+    _normalize_title_for_dedupe,
+    _arxiv_canonical_from_paper,
+    plain_query_for_text_apis,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def abbreviate_journal(journal: str | None) -> str | None:
+    if not journal:
+        return None
+    j = str(journal).strip()
+
+    m = re.search(r"\(([A-Z][A-Za-z]{1,12})\)", j)
+    if m:
+        return m.group(1).upper()
+
+    caps = [w for w in re.findall(r"\b[A-Z]{3,8}\b", j)
+            if w not in ("IEEE", "ACM", "SIG", "CVF", "THE", "FOR", "AND", "WITH", "ON", "IN")]
+    if caps:
+        return caps[0]
+    return j
+
+def _venue_appears_in_text(venue: str, text: str) -> bool:
+    v =(venue or "").strip().lower()
+    return len(v) >= 2 and v in (text or "").lower()
+
+
+def _merge_paper_link_fields(dst: Paper, src: Paper) -> None:
+    sp, dp = (src.pdf_url or "").strip(), (dst.pdf_url or "").strip()
+    s_pdf = bool(sp) and sp.lower().split("?", 1)[0].endswith(".pdf")
+    d_pdf = bool(dp) and dp.lower().split("?", 1)[0].endswith(".pdf")
+    if s_pdf and not d_pdf or not dp and sp:
+        dst.pdf_url = sp
+
+    ss, ds = (src.source_url or "").strip(), (dst.source_url or "").strip()
+    if not ds and ss:
+        dst.source_url = ss
+
+    sd, dd = (src.doi or "").strip(), (dst.doi or "").strip()
+    if not dd and sd:
+        dst.doi = sd
+    sa, da = (src.arxiv_id or "").strip(), (dst.arxiv_id or "").strip()
+    if not da and sa:
+        dst.arxiv_id = sa
+
+
+
+class RateLimiter:
+    def __init__(self, *, fixed_delays: dict[str, float]) -> None:
+        self._fixed = dict(fixed_delays)
+        self._last: dict[str, float] = {}
+
+    def _delay_for(self, source: str) -> float:
+        return float(self._fixed.get(source, 0.0))
+
+    async def wait_async(self, source: str) -> None:
+        delay = self._delay_for(source)
+        if delay <= 0:
+            self._last[source] = time.time()
+            return
+        now = time.time()
+        elapsed = now - self._last.get(source, 0.0)
+        if elapsed < delay:
+            await asyncio.sleep(delay - elapsed)
+        self._last[source] = time.time()
+
+_DEFAULT_PAPER_SEARCHER_DOWNLOAD_DIR = str(Path(__file__).resolve().parents[3] / "downloads" / "papers")
+
+def _sanitize_author_list_for_query(query: str, raw_authors: Any) -> List[str]:
+    if raw_authors is None: return []
+    if isinstance(raw_authors, str): seq = [raw_authors]
+    elif isinstance(raw_authors, list): seq = [str(x) for x in raw_authors]
+    else: return []
+    return [str(a).strip() for a in seq if str(a).strip()]
+
+
+
+def _has_any_author(p: Paper, want_raw: list) -> bool:
+    try:
+        names = [str(getattr(a, "name", "") or "").strip() for a in (p.authors or [])
+                 if str(getattr(a, "name", "") or "").strip()]
+    except Exception: return False
+    return bool(names) and any(any(author_phrase_matches_canonical_line(nm, ph) for nm in names) for ph in want_raw)
+
+
+from .sources.arxiv import (
+    search_arxiv as _search_arxiv_src,
+    _search_arxiv_by_author_list,
+)
+from .sources.dblp import (
+    search_dblp as _search_dblp_src,
+)
+from .sources.openalex import (
+    search_openalex as _search_openalex_src,
+    _search_openalex_works_by_author_async,
+)
+
+
+class PaperSearcher:
+    def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None,
+                 download_dir: str = _DEFAULT_PAPER_SEARCHER_DOWNLOAD_DIR, *, httpx_trust_env: bool = True):
+        _email = (email or os.getenv("OPENALEX_MAILTO") or os.getenv("NCBI_EMAIL") or "").strip()
+        if _email.lower() == "user@example.com": _email = ""
+        self.email, self.api_key, self.download_dir = _email, api_key, download_dir
+        os.makedirs(self.download_dir, exist_ok=True)
+        self._rate = RateLimiter(fixed_delays={"arxiv": 0.1, "openalex": 0.15, "dblp": 0.35})
+        self._session = requests.Session()
+        self._async_client: Optional[httpx.AsyncClient] = None
+        self._async_client_loop: Optional[asyncio.AbstractEventLoop] = None
+        self.stats = {"arxiv_requests": 0, "dblp_requests": 0, "openalex_requests": 0,
+                      "total_results": 0, "downloaded_pdfs": 0}
+
+    def _bump_stat(self, key: str, n: int = 1) -> None:
+        self.stats[key] = self.stats.get(key, 0) + n
+
+    async def _ensure_async_client(self) -> httpx.AsyncClient:
+        loop = asyncio.get_running_loop()
+        if self._async_client is not None and self._async_client_loop is loop:
+            return self._async_client
+        if self._async_client is not None:
+            try: await self._async_client.aclose()
+            except Exception: pass
+        self._async_client = httpx.AsyncClient(
+            trust_env=False, limits=httpx.Limits(max_keepalive_connections=20, max_connections=80),
+            timeout=httpx.Timeout(60.0))
+        self._async_client_loop = loop
+        return self._async_client
+
+    async def aclose(self) -> None:
+        try:
+            if self._async_client is not None: await self._async_client.aclose()
+        except Exception: pass
+        self._async_client = self._async_client_loop = None
+
+    def _user_agent(self) -> str:
+        try:
+            from ...settings import get_settings
+            v = str(getattr(get_settings(), "app_version", "0.1") or "0.1")
+        except Exception:
+            v = "0.1"
+        return f"PaperGraph/{v} (mailto:{self.email})"
+
+    def _openalex_headers(self) -> Dict[str, str]:
+        return {"User-Agent": self._user_agent()}
+
+    def _openalex_params(self, base: Dict[str, Any]) -> Dict[str, Any]:
+        out = dict(base or {})
+        if self.email: out["mailto"] = self.email
+        return out
+
+    def _make_paper(self, *, title, authors=None, abstract=None, doi=None, arxiv_id=None,
+                    journal=None, year=None, pdf_url=None, source_url=None, citations=0,
+                    source="unknown", **extra) -> Paper:
+        return Paper(title=title, authors=authors or [], abstract=abstract, doi=doi,
+                     arxiv_id=arxiv_id, journal=journal or source, year=year, pdf_url=pdf_url,
+                     source_url=source_url, citations=citations, source=source, **extra)
+
+    @staticmethod
+    def _resolve_vpj(venue: Optional[str], kwargs: Dict[str, Any]) -> bool:
+        vpj = kwargs.get("venue_proceedings_journal")
+        return vpj if vpj is not None else bool(venue)
+
+    @staticmethod
+    def _paper_matches_venue(paper: Paper, venue: str) -> bool:
+        v = venue.lower().strip()
+        if not v: return True
+        blob = f"{paper.journal or ''} {paper.title or ''}".lower()
+        return v in blob
+
+    @staticmethod
+    def _paper_matches_venue_proceedings(paper: Paper, venue: str) -> bool:
+        raw = (venue or "").strip()
+        if not raw: return True
+        j = (paper.journal or "").lower()
+        t = (paper.title or "").lower()
+        return _venue_appears_in_text(raw, j) or _venue_appears_in_text(raw, t)
+
+
+    async def _rate_limit_async(self, source: str) -> None:
+        await self._rate.wait_async(source)
+
+    async def _async_http_get_with_retry(self, url, params, headers, timeout, max_attempts=3):
+        for attempt in range(max_attempts):
+            try:
+                resp = await self._async_client.get(url, params=params, headers=headers, timeout=timeout)
+                if resp.status_code == 429:
+                    if attempt < max_attempts - 1 and "arxiv" in str(url):
+                        await asyncio.sleep(8 + attempt * 8); continue
+                    raise RuntimeError(f"HTTP 429: {url}")
+                if resp.status_code in (500, 502, 503, 504) and attempt < max_attempts - 1:
+                    await asyncio.sleep(4 + attempt * 4); continue
+                resp.raise_for_status()
+                return resp
+            except Exception:
+                if attempt < max_attempts - 1: await asyncio.sleep(4 + attempt * 4); continue
+                raise
+
+    def _post_process_results(self, all_results: List[Paper], query: str, *, max_results: int,
+                              **kwargs: Any) -> List[Paper]:
+
+        raw_authors = kwargs.get("authors") or kwargs.get("author") or []
+        if isinstance(raw_authors, str): raw_authors = [raw_authors]
+        raw_authors = _sanitize_author_list_for_query(query, raw_authors)
+        want_raw = normalize_author_names([str(a).strip() for a in raw_authors if str(a).strip()])
+        unique_results = [p for p in all_results if _has_any_author(p, want_raw)] if want_raw else list(all_results)
+
+        unique_results = self._smart_deduplicate(unique_results)
+        unique_results = PaperSearcher._collapse_identical_norm_title_prefer_older(unique_results)
+
+        venue_kw = (kwargs.get("venue") or "").strip() or None
+        year_from, year_to = kwargs.get("year_from"), kwargs.get("year_to")
+
+        if venue_kw and unique_results:
+            matched = [p for p in unique_results if self._paper_matches_venue_proceedings(p, venue_kw)]
+            if matched and len(matched) >= max(1, len(unique_results) * 0.15):
+                non_matched = [p for p in unique_results if p not in matched]
+                unique_results = matched + non_matched
+
+        if year_from is not None:
+            unique_results = [p for p in unique_results if (p.year or 0) >= int(year_from)]
+        if year_to is not None:
+            unique_results = [p for p in unique_results if (p.year or 9999) <= int(year_to)]
+
+        sort_mode = (kwargs.get("sort") or "relevance").lower().strip()
+        if sort_mode == "date":
+            unique_results.sort(key=lambda p: (p.year or -1, p.citations or 0), reverse=True)
+        else:
+            unique_results.sort(key=lambda p: (p.citations or 0, p.year or -1), reverse=True)
+        return unique_results[:max_results]
+
+    def search(self, query: str, sources: Optional[List[str]] = None, max_results: int = 10, **kwargs) -> List[Paper]:
+        return run_coroutine_sync(self.search_async(query, sources=sources, max_results=max_results, **kwargs),
+                                  op_name="search")
+
+    async def search_async(self, query: str, sources: Optional[List[str]] = None, max_results: int = 10,
+                           **kwargs) -> List[Paper]:
+        if "max_results" in kwargs:
+            try: max_results = int(kwargs.pop("max_results"))
+            except (TypeError, ValueError): kwargs.pop("max_results", None)
+        if "sources" in kwargs: sources = kwargs.pop("sources")
+        if sources is None: sources = ["arxiv", "dblp", "openalex"]
+        sources = [str(s).strip().lower() for s in sources if str(s).strip()] or ["arxiv", "dblp", "openalex"]
+        # Tavily 网页结果不作为普通 paper 源;会议官网兜底由显式 proceedings 路径处理
+        sources = [s for s in sources if s != "tavily"] or ["arxiv", "dblp", "openalex"]
+
+        raw_authors_kw = kwargs.get("authors") or kwargs.get("author") or []
+        if isinstance(raw_authors_kw, str): raw_authors_kw = [raw_authors_kw]
+        expanded_authors = normalize_author_names([str(x).strip() for x in (raw_authors_kw or []) if str(x).strip()])
+        if expanded_authors: kwargs = {**kwargs, "authors": expanded_authors}
+        author_centric = is_author_centric_search(query, expanded_authors)
+        _query_before_author = query
+        if author_centric:
+            eng_q = pick_primary_english_author_for_query(expanded_authors)
+            if eng_q: _query_before_author, query = query, eng_q
+
+        cap_factor = max(0.5, min(2.0, float(kwargs.get("per_source_cap_factor") or 1.0)))
+        per_src_n = max(5, int(max_results * 2 * cap_factor))
+        venue_bound = bool((kwargs.get("venue") or "").strip())
+        yf_p = kwargs.get("year_from")
+        yt_p = kwargs.get("year_to")
+        pinned_year = (
+            yf_p is not None
+            and yt_p is not None
+            and int(yf_p) == int(yt_p)
+        )
+        if venue_bound and not (plain_query_for_text_apis(query, kwargs) or "").strip():
+            if kwargs.get("venue_browse"):
+                per_src_n = max(per_src_n, 56)
+            else:
+                per_src_n = max(per_src_n, 32) if pinned_year else min(per_src_n, 56)
+
+        if kwargs.get("days_back"):
+            days = kwargs["days_back"]
+            kwargs["date_from"] = (datetime.now() - timedelta(days=days)).strftime("%Y/%m/%d")
+
+
+        await self._ensure_async_client()
+        _src_timeouts = {
+            "dblp": float(kwargs.get("dblp_timeout_sec") or 32.0),
+            "openalex": float(kwargs.get("openalex_timeout_sec") or kwargs.get("http_timeout_sec") or 45.0),
+            "arxiv": float(kwargs.get("arxiv_timeout_sec") or kwargs.get("http_timeout_sec") or 30.0),
+        }
+
+        async def _fetch_src(src: str) -> List[Paper]:
+            if src == "arxiv":
+                if author_centric and expanded_authors:
+                    by_author_ax = await _search_arxiv_by_author_list(self, expanded_authors, per_src_n, **kwargs)
+                    if by_author_ax: return by_author_ax
+                    return await _search_arxiv_src(self, _query_before_author, per_src_n, **kwargs)
+                return await _search_arxiv_src(self, query, per_src_n, **kwargs)
+            elif src == "openalex":
+                if author_centric and expanded_authors:
+                    by_author = await _search_openalex_works_by_author_async(self, expanded_authors, per_src_n, **kwargs)
+                    if by_author: return by_author
+                    return await _search_openalex_src(self, _query_before_author, per_src_n, **kwargs)
+                return await _search_openalex_src(self, query, per_src_n, **kwargs)
+            elif src == "dblp":
+                return await _search_dblp_src(self, query, per_src_n, **kwargs)
+            else:
+                return []
+
+        async def _run_one(src: str) -> List[Paper]:
+            wall = max(5.0, min(60.0, float(_src_timeouts.get(src, 25.0))))
+            try: return await asyncio.wait_for(_fetch_src(src), timeout=wall)
+            except asyncio.TimeoutError:
+                logger.warning("搜索 %s 超时(%.0fs)", src, wall); return []
+            except Exception as e:
+                logger.warning("搜索 %s 时出错: %s", src, str(e)); return []
+
+        results_by_src = await asyncio.gather(*[_run_one(s) for s in sources])
+        all_results: List[Paper] = []
+        for src, res in zip(sources, results_by_src):
+            all_results.extend(res)
+            logger.info("从 %s 获取 %s 篇文献", src, len(res))
+
+        final_results = self._post_process_results(all_results, query, max_results=max_results, **kwargs)
+        self._bump_stat("total_results", len(final_results))
+        return final_results
+
+    def search_arxiv(self, query: str, max_results: int = 10, **kwargs) -> List[Paper]:
+        return run_coroutine_sync(self.search_arxiv_async(query, max_results, **kwargs), op_name="search_arxiv")
+
+    async def search_arxiv_async(self, query: str, max_results: int = 10, **kwargs) -> List[Paper]:
+        return await _search_arxiv_src(self, query, max_results, **kwargs)
+
+    async def search_dblp_async(self, query: str, max_results: int = 10, **kwargs) -> List[Paper]:
+        return await _search_dblp_src(self, query, max_results, **kwargs)
+
+    def search_openalex(self, query: str, max_results: int = 10, **kwargs) -> List[Paper]:
+        return run_coroutine_sync(self.search_openalex_async(query, max_results, **kwargs), op_name="search_openalex")
+
+
+    async def search_openalex_async(self, query: str, max_results: int = 10, **kwargs) -> List[Paper]:
+        return await _search_openalex_src(self, query, max_results, **kwargs)
+
+    @staticmethod
+    def _collapse_identical_norm_title_prefer_older(results: List[Paper]) -> List[Paper]:
+        if not results: return results
+        def _yy(p: Paper) -> int:
+            try: yi = int(p.year) if p.year is not None else 9999
+            except (TypeError, ValueError): return 9999
+            return yi if 1900 <= yi <= 2100 else 9999
+        groups: dict[str, list[Paper]] = {}
+        for p in results:
+            nt = _normalize_title_for_dedupe(p.title)
+            if len(nt) >= 22: groups.setdefault(nt, []).append(p)
+        drop_ids = {id(pp) for grp in groups.values() if len(grp) > 1
+                   for pp in grp if pp is not min(grp, key=_yy)}
+        return [p for p in results if id(p) not in drop_ids] if drop_ids else results
+
+    @staticmethod
+    def _paper_dedupe_key(paper: Paper) -> str:
+        if ax := _arxiv_canonical_from_paper(paper): return f"arxiv:{ax}"
+        if doi := (paper.doi or "").strip().lower(): return f"doi:{doi}"
+        nt = _normalize_title_for_dedupe(paper.title)
+        return f"empty:{id(paper)}" if not nt else "title:" + hashlib.md5(nt.encode("utf-8")).hexdigest()
+
+    @staticmethod
+    def _dedupe_quality_tuple(p: Paper) -> tuple:
+        abst = (p.abstract or "").strip()
+        return (1 if abst else 0, len(abst), int(p.citations or 0), int(p.year or 0))
+
+    def _smart_deduplicate(self, papers: List[Paper]) -> List[Paper]:
+        best: Dict[str, Paper] = {}
+        for paper in papers:
+            k = self._paper_dedupe_key(paper)
+            if k not in best: best[k] = paper; continue
+            if self._dedupe_quality_tuple(paper) > self._dedupe_quality_tuple(best[k]):
+                _merge_paper_link_fields(paper, best[k]); best[k] = paper
+            else: _merge_paper_link_fields(best[k], paper)
+        return list(best.values())
+
+    def download_pdf(self, paper: Paper) -> Optional[str]:
+        if not paper.pdf_url: return None
+        try:
+            safe_title = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in paper.title[:50])
+            filename = f"{paper.arxiv_id or 'paper'}_{safe_title}.pdf"
+            file_path = os.path.join(self.download_dir, filename)
+            if os.path.exists(file_path): return file_path
+            response = self._session.get(paper.pdf_url, timeout=60, stream=True)
+            if response.status_code == 200:
+                with open(file_path, 'wb') as f:
+                    for chunk in response.iter_content(chunk_size=8192): f.write(chunk)
+                self._bump_stat("downloaded_pdfs")
+                return file_path
+        except Exception: pass
+        return None

+ 1 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/__init__.py

@@ -0,0 +1 @@
+from __future__ import annotations

+ 399 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/arxiv.py

@@ -0,0 +1,399 @@
+"""arXiv API 适配器 —— 搜索、ID 解析与 PDF URL 构造."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import re
+from datetime import datetime, timedelta
+from typing import Any, Dict, List, Optional
+from urllib.parse import quote_plus
+
+import feedparser
+
+from ...author import Author
+from ...paper import Paper
+from ..normalize import (
+    _arxiv_pdf_url_from_id,
+    sanitize_search_keyword_list,
+    split_query_phrases,
+)
+from ..paper_searcher import _sanitize_author_list_for_query
+from ....utils.common import text_has_cjk
+
+logger = logging.getLogger(__name__)
+
+
+def arxiv_category_clause(categories: list[str | None]) -> str | None:
+    if not categories:
+        return None
+    cats = [str(c).strip() for c in categories if c and str(c).strip()]
+    if not cats:
+        return None
+    if len(cats) == 1:
+        return f"cat:{cats[0]}"
+    return "+OR+".join(f"cat:{c}" for c in cats)
+
+
+def arxiv_query_string_from_kwargs(query: str, kwargs: dict[str, Any]) -> str:
+    lk = sanitize_search_keyword_list(kwargs.get("llm_keywords"))
+    if not lk:
+        return (query or "").strip()
+    if len(lk) >= 2:
+        q0 = (query or "").strip()
+        return q0 if q0 else str(lk[0]).strip()
+    one = str(lk[0]).strip()
+    if len(one) > 480:
+        one = one[:480].rsplit(" ", 1)[0].strip()
+    return one or ((query or "").strip())
+
+
+def _arxiv_comment_from_feed_entry(entry: Any) -> str:
+    for key in ("arxiv_comment", "comment"):
+        v = getattr(entry, key, None) or (
+            entry.get(key) if hasattr(entry, "get") else None
+        )
+        if isinstance(v, str) and v.strip():
+            return v.replace("\n", " ").strip()
+    return ""
+
+
+def _arxiv_submitted_date_clause(kwargs: Dict[str, Any]) -> Optional[str]:
+    if not bool(kwargs.get("arxiv_use_submitted_date", False)):
+        return None
+    db = kwargs.get("days_back")
+    if db is None:
+        return None
+    try:
+        days = int(db)
+    except (TypeError, ValueError):
+        return None
+    if days <= 0:
+        return None
+    end = datetime.now()
+    start = end - timedelta(days=days)
+    return (
+        f"submittedDate:[{start.strftime('%Y%m%d')}0000"
+        f"+TO+{end.strftime('%Y%m%d')}2359]"
+    )
+
+
+def _arxiv_entry_published_naive(entry: Any) -> Optional[datetime]:
+    t = (
+        (entry.get("published_parsed") or entry.get("updated_parsed"))
+        if hasattr(entry, "get")
+        else (
+            getattr(entry, "published_parsed", None)
+            or getattr(entry, "updated_parsed", None)
+        )
+    )
+    if not t:
+        return None
+    try:
+        return datetime(*t[:6])
+    except (TypeError, ValueError):
+        return None
+
+
+def _arxiv_clause_for_phrase(searcher, phrase: str, style: str) -> str:
+    p = (phrase or "").replace('"', " ").strip()
+    if not p:
+        return ""
+    if style == "ti_abs":
+        return f'all:"{p}"' if " " in p else f'(ti:"{p}"+OR+abs:"{p}")'
+    return f'all:"{p}"' if " " in p else f"all:{quote_plus(p)}"
+
+
+def _arxiv_query_part(
+    searcher, query: str, style: str = "all", *, max_phrases: int = 14
+) -> Optional[str]:
+    q = (query or "").strip().replace('"', " ")
+    if not q:
+        return None
+    st = style if style in ("all", "ti_abs") else "all"
+    phrases = [
+        p.replace('"', " ").strip()
+        for p in split_query_phrases(q)
+        if p.strip()
+    ][: max(1, int(max_phrases))]
+    clauses = [
+        c
+        for c in (_arxiv_clause_for_phrase(searcher, p, st) for p in phrases)
+        if c
+    ]
+    if len(clauses) >= 2:
+        return "(" + "+OR+".join(clauses) + ")"
+    if len(clauses) == 1:
+        return clauses[0]
+    q0 = phrases[0] if phrases else q
+    return _arxiv_clause_for_phrase(searcher, q0, st) or None
+
+
+def _arxiv_build_search_query(
+    searcher, query: str, venue: Optional[str], **kwargs
+) -> str:
+    style = kwargs.get("arxiv_query_style", "ti_abs").lower()
+    if style not in ("all", "ti_abs"):
+        style = "all"
+    v = (str(venue).strip().replace('"', " ")) if venue else ""
+    parts: List[str] = []
+    cat_c = arxiv_category_clause(kwargs.get("arxiv_categories"))
+    if cat_c:
+        parts.append(cat_c)
+    raw_authors = kwargs.get("authors") or kwargs.get("author") or []
+    if isinstance(raw_authors, str):
+        raw_authors = [raw_authors]
+    if isinstance(raw_authors, list):
+        raw_authors = _sanitize_author_list_for_query(query, raw_authors)
+        aus = [
+            str(x).strip().replace('"', " ")
+            for x in raw_authors
+            if str(x).strip()
+        ][:3]
+    else:
+        aus = []
+    if aus:
+        author_clauses: List[str] = []
+        for a in aus:
+            a0 = a.strip()
+            if not a0:
+                continue
+            author_clauses.extend([f'au:"{a0}"'])
+        author_clauses = list(dict.fromkeys([c for c in author_clauses if c]))[:6]
+        if author_clauses:
+            inner = "+OR+".join(author_clauses)
+            parts.append(
+                f"({inner})" if len(author_clauses) > 1 else author_clauses[0]
+            )
+    max_or = int(kwargs.get("arxiv_max_or_clauses", 14) or 14)
+    q_part = _arxiv_query_part(searcher, query, style, max_phrases=max(1, max_or))
+    if q_part:
+        parts.append(q_part)
+    if v:
+        if " " in v:
+            parts.append(f'all:"{v}"')
+        elif style == "ti_abs":
+            vc = _arxiv_clause_for_phrase(searcher, v, "ti_abs")
+            if vc:
+                parts.append(vc)
+        else:
+            parts.append(f"all:{quote_plus(v)}")
+    if not parts:
+        return "cat:cs"
+    elif len(parts) == 1:
+        expr = parts[0]
+    else:
+        expr = "+AND+".join(parts)
+    date_clause = _arxiv_submitted_date_clause(kwargs)
+    return f"{expr}+AND+{date_clause}" if date_clause else expr
+
+
+def _filter_arxiv_entries_by_days_back(
+    searcher, entries: List[Any], days_back: int
+) -> List[Any]:
+    cutoff = datetime.now() - timedelta(days=days_back)
+    return [
+        e
+        for e in entries
+        if (pub := _arxiv_entry_published_naive(e)) is None or pub >= cutoff
+    ]
+
+
+def _arxiv_paper_dedupe_key(searcher, p: Paper) -> str:
+    aid = (p.arxiv_id or "").strip()
+    if aid:
+        return re.sub(r"v\d+$", "", aid, flags=re.I).lower()
+    return hashlib.md5(
+        (p.title or "").strip().lower().encode("utf-8", errors="ignore")
+    ).hexdigest()[:20]
+
+
+async def _search_arxiv_by_keyword_list_async(
+    searcher, query: str, max_results: int, keywords: List[str], **kwargs: Any
+) -> List[Paper]:
+    n = max(1, len(keywords))
+    per = max(8, min(60, (max_results + n - 1) // n + 8))
+    merged, seen = [], set()
+    for kw in keywords[:16]:
+        kwt = str(kw).strip()
+        if not kwt:
+            continue
+        for p in await search_arxiv(
+            searcher, query, per, **{**kwargs, "llm_keywords": [kwt]}
+        ):
+            dk = _arxiv_paper_dedupe_key(searcher, p)
+            if dk in seen:
+                continue
+            seen.add(dk)
+            merged.append(p)
+        if len(merged) >= max_results:
+            break
+    return merged[:max_results]
+
+
+async def _search_arxiv_by_author_list(
+    searcher, author_names: list[str], max_results: int, **kwargs
+) -> list[Paper]:
+    if not author_names:
+        return []
+    merged, seen = [], set()
+    for name in author_names[:3]:
+        nm = name.strip()
+        if not nm or text_has_cjk(nm):
+            continue
+        akw = dict(kwargs)
+        akw["authors"] = [nm]
+        akw["llm_keywords"] = []
+        for p in await search_arxiv(searcher, "", max(5, max_results), **akw):
+            dk = _arxiv_paper_dedupe_key(searcher, p)
+            if dk in seen:
+                continue
+            seen.add(dk)
+            merged.append(p)
+        if len(merged) >= max_results:
+            break
+    return merged[:max_results]
+
+
+async def search_arxiv(
+    searcher, query: str, max_results: int = 10, **kwargs
+) -> List[Paper]:
+    await searcher._ensure_async_client()
+    await searcher._rate_limit_async("arxiv")
+
+    id_list0 = str(kwargs.get("arxiv_id_list") or "").strip()
+    lk0 = sanitize_search_keyword_list(kwargs.get("llm_keywords"))
+    venue_bound = bool((kwargs.get("venue") or "").strip())
+    if not id_list0 and len(lk0) >= 2 and not venue_bound:
+        return await _search_arxiv_by_keyword_list_async(
+            searcher, query, max_results, lk0, **kwargs
+        )
+
+    base_url = "https://export.arxiv.org/api/query"
+    venue = (kwargs.get("venue") or "").strip() or None
+    vpj = searcher._resolve_vpj(venue, kwargs)
+    include_venue_in_arxiv_query = bool(
+        venue and (not vpj) and bool(kwargs.get("strict_venue_match"))
+    )
+    id_list = str(kwargs.get("arxiv_id_list") or "").strip()
+    if id_list:
+        params = {
+            "id_list": id_list,
+            "start": 0,
+            "max_results": min(max_results, 30000),
+            "sortBy": "submittedDate",
+            "sortOrder": "descending",
+        }
+    else:
+        q_arxiv = arxiv_query_string_from_kwargs(query, kwargs)
+        arxiv_kw = {k: v for k, v in kwargs.items() if k != "venue"}
+        search_expr = _arxiv_build_search_query(
+            searcher,
+            q_arxiv,
+            venue if include_venue_in_arxiv_query else None,
+            **arxiv_kw,
+        )
+        sort_by = (
+            "relevance"
+            if (kwargs.get("sort") or "date").lower().strip() == "relevance"
+            else "submittedDate"
+        )
+        params = {
+            "search_query": search_expr,
+            "start": 0,
+            "max_results": min(max_results, 30000),
+            "sortBy": sort_by,
+            "sortOrder": "descending",
+        }
+
+    headers = {"User-Agent": searcher._user_agent()}
+    req_timeout = float(kwargs.get("http_timeout_sec", 30.0))
+    max_attempts = max(1, min(10, int(kwargs.get("http_max_attempts", 4))))
+    resp = await searcher._async_http_get_with_retry(
+        base_url,
+        params=params,
+        headers=headers,
+        timeout=req_timeout,
+        max_attempts=max_attempts,
+    )
+    feed = feedparser.parse(resp.content or b"")
+    entries = list(getattr(feed, "entries", []) or [])
+
+    if not entries and not id_list:
+        from ....settings import get_settings
+
+        if bool(getattr(get_settings(), "arxiv_or_retry_on_empty", True)):
+            or_expr = search_expr.replace("+AND+", "+OR+")
+            if or_expr != search_expr:
+                await searcher._rate_limit_async("arxiv")
+                or_params = dict(params)
+                or_params["search_query"] = or_expr
+                resp2 = await searcher._async_http_get_with_retry(
+                    base_url,
+                    params=or_params,
+                    headers=headers,
+                    timeout=req_timeout,
+                    max_attempts=min(max_attempts, 2),
+                )
+                entries = list(
+                    getattr(feedparser.parse(resp2.content or b""), "entries", [])
+                    or []
+                )
+
+    db_days = kwargs.get("days_back")
+    if db_days:
+        try:
+            if int(db_days) > 0:
+                entries = _filter_arxiv_entries_by_days_back(
+                    searcher, entries, int(db_days)
+                )
+        except (TypeError, ValueError):
+            pass
+
+    papers: List[Paper] = []
+    for entry in entries:
+        try:
+            arxiv_id = entry.get("id", "").split("/")[-1].replace("abs/", "")
+            title = entry.get("title", "Unknown").replace("\n", " ")
+            authors = [
+                Author(name=a.get("name", ""))
+                for a in (entry.get("authors", []) or [])
+                if a.get("name")
+            ]
+            abstract = (entry.get("summary", "") or "").replace("\n", " ")
+            tags = entry.get("tags", [])
+            keywords = [tag.get("term", "") for tag in tags if tag.get("term")]
+            published = entry.get("published_parsed") or entry.get("updated_parsed")
+            year = published[0] if published else None
+            links = entry.get("links", [])
+            pdf_url = abs_url = None
+            for link in links:
+                if link.get("type") == "application/pdf":
+                    pdf_url = link.get("href")
+                elif link.get("type") == "text/html":
+                    abs_url = link.get("href")
+            if not pdf_url and arxiv_id:
+                pdf_url = _arxiv_pdf_url_from_id(
+                    re.sub(r"v\d+$", "", arxiv_id.strip(), flags=re.I)
+                )
+            primary_category = tags[0].get("term", "") if tags else ""
+            papers.append(
+                searcher._make_paper(
+                    title=title,
+                    authors=authors,
+                    abstract=abstract,
+                    arxiv_id=arxiv_id,
+                    journal=f"arXiv:{primary_category}" if primary_category else "arXiv",
+                    year=year,
+                    pdf_url=pdf_url,
+                    source_url=abs_url or f"https://arxiv.org/abs/{arxiv_id}",
+                    keywords=keywords,
+                    source="arxiv",
+                    notes=_arxiv_comment_from_feed_entry(entry) or None,
+                )
+            )
+        except Exception:
+            continue
+
+    searcher._bump_stat("arxiv_requests")
+    return papers

+ 508 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/dblp.py

@@ -0,0 +1,508 @@
+"""DBLP API 适配器 —— 作者 PID 匹配、论文搜索与会议论文集召回."""
+
+from __future__ import annotations
+
+import logging
+import os
+import re
+import xml.etree.ElementTree as ET
+from datetime import datetime
+from typing import Any, List
+
+from ...author import Author
+from ...paper import Paper
+from ....utils.common import text_has_cjk
+from ..normalize import (
+    _QUERY_DELIM,
+    _RE_ARXIV_VERSION_SUFFIX,
+    sanitize_search_keyword_list,
+    topic_terms_excluding_venue_year,
+)
+from .source_common import json_api_headers, normalize_query_authors, pick_best_name_match
+
+logger = logging.getLogger(__name__)
+
+# DBLP adds numeric suffixes to disambiguate same-name authors.
+_DBLP_AUTHOR_SUFFIX_RE = re.compile(r"\s+\d{4}$")
+
+
+def _dblp_parse_ee_urls(ee: Any) -> tuple[str | None, str | None, str | None, str | None]:
+    urls: list[str] = []
+    if isinstance(ee, list):
+        for u in ee:
+            if isinstance(u, str) and u.strip():
+                urls.append(u.strip())
+    elif isinstance(ee, str) and ee.strip():
+        urls.append(ee.strip())
+
+    doi: str | None = None
+    aid: str | None = None
+    pdf_url: str | None = None
+    landing: str | None = None
+
+    for u in urls:
+        lu = u.lower()
+        base = u.split("?", 1)[0].strip()
+
+        if doi is None and "doi.org/" in lu:
+            doi = u.split("doi.org/", 1)[-1].split("?", 1)[0].strip().rstrip("/")
+            # arXiv DOI, e.g. 10.48550/arXiv.2601.22158.
+            if aid is None:
+                m = re.search(r"arxiv/([\d.]+)", lu)
+                if m:
+                    aid = _RE_ARXIV_VERSION_SUFFIX.sub("", m.group(1))
+        if aid is None and "arxiv.org/abs/" in lu:
+            raw = u.split("arxiv.org/abs/", 1)[-1].strip().rstrip("/")
+            aid = _RE_ARXIV_VERSION_SUFFIX.sub("", raw)
+
+        bl = base.lower()
+        if pdf_url is None and (bl.endswith(".pdf") or "arxiv.org/pdf/" in bl):
+            pdf_url = base
+        elif landing is None and bl.startswith("http") and not bl.endswith(".pdf") and "arxiv.org/pdf/" not in bl:
+            landing = base
+
+    first = urls[0] if urls else None
+    source_url = landing or first
+    return source_url, doi, aid, pdf_url
+
+
+def raw_query_for_dblp(query: str, kwargs: dict[str, Any]) -> str:
+    if bool(kwargs.get("dblp_use_llm_keywords")):
+        lk = sanitize_search_keyword_list(kwargs.get("llm_keywords"))
+        if lk:
+            if len(lk) >= 2:
+                q0 = (query or "").strip()
+                return q0 if q0 else str(lk[0]).replace('"', " ").strip()
+            return str(lk[0]).replace('"', " ").strip()
+    q = (query or "").strip()
+    if not q:
+        return ""
+
+    parts = [p.strip() for p in _QUERY_DELIM.split(q) if p and p.strip()]
+    if not parts:
+        return q.replace('"', " ").strip()
+    return " ".join([p.replace('"', " ").strip() for p in parts]).strip()
+
+
+def _dblp_venue_search_queries(venue_only: str, year_from: Any) -> list[str]:
+    raw = (venue_only or "").strip()
+    if not raw:
+        return []
+    vlow = raw.lower()
+    yfi = int(year_from) if year_from is not None and str(year_from).isdigit() else None
+    seen, out = set(), []
+
+    if yfi:
+        q = f"{raw} {yfi}"
+        if q not in seen:
+            seen.add(q)
+            out.append(q)
+
+    if yfi:
+        q = f"conf/{vlow}/{yfi}"
+        if q not in seen:
+            seen.add(q)
+            out.append(q)
+    q = f"conf/{vlow}"
+    if q not in seen:
+        seen.add(q)
+        out.append(q)
+
+    if raw not in seen:
+        seen.add(raw)
+        out.append(raw)
+    return out
+
+
+async def search_dblp(searcher, query, max_results=10, **kwargs):
+    await searcher._ensure_async_client()
+    await searcher._rate_limit_async("dblp")
+
+    author_names = normalize_query_authors(query, kwargs.get("authors") or kwargs.get("author") or [])
+    papers_by_author = None
+    for author_name in author_names:
+        if text_has_cjk(author_name):
+            continue
+        papers_by_author = await _search_dblp_by_author_pid(
+            searcher, author_name, max_results, kwargs
+        )
+        if papers_by_author:
+            break
+    if papers_by_author:
+        searcher._bump_stat("dblp_requests")
+        return papers_by_author
+    if author_names and not papers_by_author:
+        from ....settings import get_settings
+
+        if not bool(getattr(get_settings(), "dblp_author_name_fallback_search", True)):
+            return []
+
+    base_q = (raw_query_for_dblp(query, kwargs) or "").strip()
+    venue_only = (kwargs.get("venue") or "").strip()
+    vpj = searcher._resolve_vpj(venue_only, kwargs)
+    strict_no_fallback = bool(
+        vpj and venue_only and not kwargs.get("venue_fallback_if_empty", True)
+    )
+    dblp_queries: list[str] = []
+    if venue_only:
+        dblp_queries = _dblp_venue_search_queries(venue_only, kwargs.get("year_from"))
+        yf_dblp = (
+            int(kwargs.get("year_from"))
+            if kwargs.get("year_from") is not None
+               and str(kwargs.get("year_from")).isdigit()
+            else None
+        )
+        if base_q:
+            topic = topic_terms_excluding_venue_year(
+                base_q, venue=venue_only, year=yf_dblp
+            )
+            topic_prefix = []
+            if topic:
+                topic_prefix.append(f"{topic} {venue_only}".strip())
+                if yf_dblp is not None:
+                    topic_prefix.insert(
+                        0, f"{topic} {venue_only} {yf_dblp}".strip()
+                    )
+            elif base_q.lower() != venue_only.lower():
+                topic_prefix.append(f"{base_q} {venue_only}".strip())
+            seen_q = set(dblp_queries)
+            dblp_queries = [q for q in topic_prefix if q not in seen_q] + dblp_queries
+        tt_prefix = []
+        raw_tt = kwargs.get("target_titles")
+        if isinstance(raw_tt, list) and raw_tt:
+            t0 = str(raw_tt[0] or "").replace('"', " ").strip()
+            if len(t0) >= 8:
+                tt_prefix.append(t0[:220])
+        if tt_prefix:
+            dblp_queries = (
+                [q for q in tt_prefix if q not in set(dblp_queries)] + dblp_queries
+            )
+        qtext = dblp_queries[0] if dblp_queries else venue_only
+    elif base_q:
+        qtext = base_q
+    else:
+        return []
+
+    n_cap = 1200 if strict_no_fallback else 500
+    if venue_only:
+        yf_db, yt_db = kwargs.get("year_from"), kwargs.get("year_to")
+        pinned_db = (
+            (
+                yf_db is not None
+                and yt_db is not None
+                and int(yf_db) == int(yt_db)
+                and 1900 <= int(yf_db) <= 2100
+            )
+            if yf_db is not None and yt_db is not None
+            else False
+        )
+        has_topic = bool(
+            base_q
+            and topic_terms_excluding_venue_year(
+                base_q,
+                venue=venue_only,
+                year=int(yf_db) if pinned_db else None,
+            )
+        )
+        if has_topic:
+            n_cap = 500 if strict_no_fallback else 300
+            n = min(max(max_results, 40), n_cap)
+        elif not base_q:
+            venue_browse = bool(kwargs.get("venue_browse"))
+            n_cap = 80 if (pinned_db and venue_browse) else (36 if pinned_db else 100)
+            n = min(max(40 if (pinned_db and venue_browse) else (20 if pinned_db else 30), max_results * 2), n_cap)
+        else:
+            n = min(max(1, max_results), n_cap)
+    else:
+        n = min(max(1, max_results), n_cap)
+
+    try:
+        max_attempts = max(
+            1,
+            min(
+                3,
+                int(
+                    kwargs.get("http_max_attempts")
+                    or os.getenv("PAPERGRAPH_DBLP_MAX_ATTEMPTS")
+                    or 2
+                ),
+            ),
+        )
+    except Exception:
+        max_attempts = 2
+    query_chain = dblp_queries if venue_only else [qtext]
+    dblp_req_to = max(
+        10.0,
+        min(
+            90.0,
+            float(
+                kwargs.get("dblp_timeout_sec")
+                or kwargs.get("http_timeout_sec")
+                or 35.0
+            ),
+        ),
+    )
+    headers = json_api_headers(searcher)
+    data, raw_hits = {}, None
+    for qi, cand_q in enumerate(query_chain):
+        qtext = cand_q
+        try:
+            r = await searcher._async_http_get_with_retry(
+                "https://dblp.org/search/publ/api",
+                params={
+                    "q": qtext.strip(),
+                    "format": "json",
+                    "h": n,
+                    "c": "0",
+                },
+                headers=headers,
+                timeout=dblp_req_to,
+                max_attempts=max_attempts,
+            )
+            data = r.json() or {}
+            raw_hits = ((data.get("result") or {}).get("hits") or {}).get("hit")
+            if raw_hits:
+                break
+        except Exception:
+            if qi + 1 >= len(query_chain):
+                logger.warning("[DBLP] async giving up q=%r", qtext)
+                searcher._bump_stat("dblp_requests")
+                return []
+            continue
+    if not raw_hits:
+        searcher._bump_stat("dblp_requests")
+        return []
+
+    raw_list = [raw_hits] if isinstance(raw_hits, dict) else list(raw_hits)
+    y_min = (
+        int(kwargs.get("year_from"))
+        if kwargs.get("year_from") is not None
+        else None
+    )
+    y_max = (
+        int(kwargs.get("year_to"))
+        if kwargs.get("year_to") is not None
+        else None
+    )
+    papers: List[Paper] = []
+    pinned_db = y_min is not None and y_max is not None and y_min == y_max
+
+    for hit in raw_list:
+        try:
+            info = (hit or {}).get("info") or {}
+            if str(info.get("type") or "").strip().lower() == "editorship":
+                continue
+            key = str(info.get("key") or "").strip()
+            if key.startswith("conf/"):
+                parts = key.split("/")
+                if len(parts) >= 3 and re.fullmatch(
+                    r"(?:19|20)\d{2}w?", parts[-1].strip().lower()
+                ):
+                    continue
+            title = (info.get("title") or "").strip() or "Unknown"
+            if not title:
+                continue
+            if bool(kwargs.get("main_conference_proceedings_only")):
+                from ....services.retrieval.paper_filters import is_stale_best_of_special
+                # Hard-reject obvious satellite tracks before LLM ranking.
+                tl = title.lower()
+                if re.search(r"\b(workshops?|symposium|symposia|tutorial|demo\s+track|satellite|challenge|ntire|pbvs|competition|contest)\b", tl):
+                    continue
+                y_pin = (
+                    int(kwargs.get("year_from"))
+                    if kwargs.get("year_from") is not None
+                    and kwargs.get("year_to") is not None
+                    and int(kwargs.get("year_from")) == int(kwargs.get("year_to"))
+                    else None
+                )
+                if is_stale_best_of_special(title, y_pin):
+                    continue
+            year = (
+                int(ys)
+                if (ys := str(info.get("year") or "").strip()).isdigit()
+                else None
+            )
+            if y_min is not None and year is not None and year < y_min:
+                continue
+            if y_max is not None and year is not None and year > y_max:
+                continue
+
+            raw_a = (info.get("authors") or {}).get("author", [])
+            if isinstance(raw_a, dict):
+                raw_a = [raw_a]
+            authors = [
+                Author(
+                    name=_DBLP_AUTHOR_SUFFIX_RE.sub("", (
+                        (a.get("text") or "").strip()
+                        if isinstance(a, dict)
+                        else str(a).strip()
+                    ))
+                )
+                for a in raw_a
+                if (
+                    a.get("text") if isinstance(a, dict) else str(a)
+                ).strip()
+            ]
+
+            ee = info.get("ee")
+            source_url, doi_guess, arxiv_guess, pdf_ee = _dblp_parse_ee_urls(ee)
+            if not source_url:
+                source_url = (info.get("url") or "").strip() or None
+            venue = (
+                str(info.get("venue")[0]).strip().upper()
+                if isinstance(info.get("venue"), list) and info.get("venue")
+                else (str(info.get("venue") or "").strip().upper() or None)
+            )
+            if key.startswith("conf/"):
+                parts = key.split("/")
+                if len(parts) >= 2:
+                    venue = parts[1].strip().upper()
+
+            papers.append(
+                searcher._make_paper(
+                    title=title,
+                    authors=authors,
+                    doi=doi_guess,
+                    arxiv_id=arxiv_guess,
+                    journal=venue or "DBLP",
+                    year=year,
+                    pdf_url=pdf_ee,
+                    source_url=source_url,
+                    source="dblp",
+                )
+            )
+        except Exception:
+            continue
+
+    searcher._bump_stat("dblp_requests")
+
+    # New-year DBLP metadata can lag; retry year-1 unless the year is pinned.
+    if not papers and y_min is not None and y_min >= datetime.now().year - 1:
+        skip_fallback = bool(kwargs.get("wants_recent")) or (
+            pinned_db and bool(kwargs.get("main_conference_proceedings_only"))
+        )
+        if not skip_fallback and y_min >= datetime.now().year:
+            logger.info(
+                "[DBLP] year=%s no results, retry year=%s", y_min, y_min - 1
+            )
+            return await search_dblp(
+                searcher,
+                query,
+                max_results,
+                **{
+                    **kwargs,
+                    "year_from": y_min - 1,
+                    "year_to": (
+                        y_min - 1
+                        if y_max == y_min
+                        else (y_max - 1 if y_max else None)
+                    ),
+                },
+            )
+
+    return papers
+
+
+async def _search_dblp_by_author_pid(searcher, author_name, max_results, kwargs):
+    if not author_name:
+        return None
+    try:
+        headers = json_api_headers(searcher)
+        ar = await searcher._async_client.get(
+            "https://dblp.org/search/author/api",
+            params={"q": author_name, "format": "json", "h": 5, "c": 0},
+            headers=headers,
+            timeout=25.0,
+        )
+        ar.raise_for_status()
+        hits = (
+            (((ar.json() or {}).get("result") or {}).get("hits") or {}).get("hit")
+            or []
+        )
+        if isinstance(hits, dict):
+            hits = [hits]
+
+        def _dblp_author_name(h: Any) -> str:
+            info = (h or {}).get("info") or {}
+            return str(info.get("display_name") or info.get("author") or "").strip()
+
+        def _dblp_author_bonus(h: Any, score: int) -> int:
+            url = str(((h or {}).get("info") or {}).get("url") or "").strip()
+            return score + (2 if "/pid/" in url else 0)
+
+        best_hit, best_pid_score = pick_best_name_match(
+            hits, author_name, display_name=_dblp_author_name, score_bonus=_dblp_author_bonus
+        )
+        pid_url = str(((best_hit or {}).get("info") or {}).get("url") or "").strip() if best_hit else ""
+        if pid_url and "/pid/" in pid_url:
+            from ....settings import get_settings
+
+            min_score = float(
+                getattr(get_settings(), "dblp_author_pid_min_score", 3.0) or 3.0
+            )
+            if best_pid_score < min_score:
+                pid_url = ""
+            else:
+                pr = await searcher._async_client.get(
+                    pid_url + ".xml",
+                    headers={"User-Agent": headers["User-Agent"]},
+                    timeout=25.0,
+                )
+                pr.raise_for_status()
+                root = ET.fromstring(pr.text)
+        else:
+            root = None
+
+        papers: List[Paper] = []
+        if root is not None:
+            for rnode in list(root.findall("./r")):
+                if len(papers) >= max_results:
+                    break
+                try:
+                    pub = next(
+                        (child for child in rnode if child.tag), None
+                    )
+                    if pub is None:
+                        continue
+                    title = (
+                        (pub.findtext("title") or "").strip() or "Unknown"
+                    )
+                    year = (
+                        int(yt)
+                        if (
+                            yt := (pub.findtext("year") or "").strip()
+                        ).isdigit()
+                        else None
+                    )
+                    yf = kwargs.get("year_from")
+                    if yf is not None and year is not None and year < int(yf):
+                        continue
+                    venue = (
+                        pub.findtext("booktitle")
+                        or pub.findtext("journal")
+                        or ""
+                    ).strip().upper() or None
+                    authors = [
+                        Author(name=_DBLP_AUTHOR_SUFFIX_RE.sub("", (a.text or "").strip()))
+                        for a in pub.findall("author")
+                        if (a.text or "").strip()
+                    ]
+                    ee = (pub.findtext("ee") or "").strip() or None
+                    url_text = (
+                        (pub.findtext("url") or "").strip() or None
+                    )
+                    papers.append(
+                        searcher._make_paper(
+                            title=title,
+                            authors=authors,
+                            journal=venue or "DBLP",
+                            year=year,
+                            source_url=ee or url_text,
+                            source="dblp",
+                        )
+                    )
+                except Exception:
+                    continue
+        return papers
+    except Exception:
+        return None

+ 490 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/openalex.py

@@ -0,0 +1,490 @@
+"""OpenAlex source adapter for PaperSearcher."""
+
+from __future__ import annotations
+
+import logging
+import re
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from ...author import Author
+from ...paper import Paper
+from .source_common import author_names_for_api, pick_best_name_match
+from ..normalize import (
+    _arxiv_pdf_url_from_id,
+    _openalex_human_source_url,
+    _openalex_resolve_pdf_url,
+    _venue_canonical_key,
+    arxiv_id_from_doi,
+    extract_pinned_topic_terms,
+    plain_query_for_text_apis,
+    sanitize_search_keyword_list,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _openalex_inverted_index_to_text(inv: dict[str, Any | None]) -> str | None:
+    """Restore OpenAlex inverted-index abstracts."""
+    if not inv:
+        return None
+    try:
+        slot: dict[int, str] = {}
+        for word, positions in inv.items():
+            for pos in positions:
+                slot[int(pos)] = str(word)
+        if not slot:
+            return None
+        return " ".join(slot[i] for i in sorted(slot)).strip() or None
+    except (ValueError, TypeError, AttributeError):
+        return None
+
+
+def _score_openalex_venue_item(v_query: str, it: Dict[str, Any]) -> int:
+    """Score an OpenAlex venue hit against the query."""
+    vq = (v_query or "").strip().lower()
+    dn = str(it.get("display_name") or "").lower()
+    ab = str(it.get("abbreviated_title") or "").lower()
+    score = 3 if (can := _venue_canonical_key(v_query)) and len(str(can)) >= 3 and str(can) in f"{dn} {ab}" else 0
+    return score + 2 if len(vq) >= 4 and vq in dn else score
+
+
+def _pick_openalex_venue_id_from_items(v2: str, items: List[Dict[str, Any]]) -> Optional[str]:
+    """Pick the best venue ID from OpenAlex hits."""
+    if not items:
+        return None
+    best_id, best_sc = None, -1
+    for it in items:
+        try:
+            if not (vid := str(it.get("id") or "").strip()):
+                continue
+            if (sc := _score_openalex_venue_item(v2, it)) > best_sc:
+                best_sc, best_id = sc, vid
+        except Exception:
+            continue
+    if best_id is not None and best_sc > 0:
+        return best_id
+    target = (_venue_canonical_key(v2) or v2.split()[0]).lower().strip()
+    chosen = None
+    for it in items:
+        try:
+            if not (vid := str(it.get("id") or "").strip()):
+                continue
+            dn = str(it.get("display_name") or "").lower()
+            ab = str(it.get("abbreviated_title") or "").lower()
+            if target and (target in dn or target in ab):
+                return vid
+            if not chosen:
+                chosen = vid
+        except Exception:
+            continue
+    return chosen
+
+
+def _title_matches_venue_signals(
+    title: str,
+    venue_canonical: str,
+    venue_raw: str,
+    abstract: Optional[str],
+    notes: str,
+) -> bool:
+    """Fallback venue check across title, abstract, and notes."""
+    v = venue_raw.lower().strip()
+    if not v:
+        return True
+    can = (venue_canonical or "").lower().strip()
+    blob = f"{title} {abstract or ''} {notes}".lower()
+    if can and len(can) >= 2 and can in blob:
+        return True
+    if len(v) >= 4 and v in blob:
+        return True
+    return False
+
+
+def _is_openalex_listing_title(title: str) -> bool:
+    """Reject proceedings/listing records, not individual papers."""
+    t = (title or "").strip().lower()
+    if not t or len(t) < 15:
+        return True
+    if re.search(r"(?i)^(\d{4}\s+)?(ieee/cvf\s+)?conference on computer vision", t):
+        return True
+    if re.search(r"(?i)^computer vision and pattern recognition\b", t) and (
+        "proceedings" in t or len(t) < 90
+    ):
+        return True
+    if re.search(r"(?i)^proceedings of\b", t):
+        return True
+    return False
+
+
+def _extract_openalex_papers(searcher, results: list, max_results: int, **kwargs) -> List[Paper]:
+    """Convert OpenAlex works into Paper objects."""
+    papers: List[Paper] = []
+    for w in results:
+        try:
+            title = (w.get("title") or "").strip() or "Unknown"
+            if _is_openalex_listing_title(title):
+                continue
+            authors = [
+                Author(name=(au.get("display_name") or "").strip())
+                for a in (w.get("authorships") or [])[:80]
+                if (au := (a or {}).get("author") or {}) and (au.get("display_name") or "").strip()
+            ]
+            if not authors:
+                continue
+            if str(w.get("type") or "").strip().lower() in ("proceedings", "book", "report"):
+                continue
+            doi = str(doi_raw).replace("https://doi.org/", "").strip() if (doi_raw := w.get("doi")) else None
+            year = w.get("publication_year")
+            abstract = _openalex_inverted_index_to_text(w.get("abstract_inverted_index"))
+            pl = w.get("primary_location") or {}
+            venue = ((pl.get("source") or {}).get("display_name") or "").strip() or None
+            if not venue:
+                hv = w.get("host_venue") or {}
+                if isinstance(hv, dict):
+                    venue = (hv.get("display_name") or "").strip() or None
+            if not venue:
+                for loc in (w.get("locations") or [])[:20]:
+                    if isinstance(loc, dict) and (lsrc := loc.get("source", {}) if isinstance(loc.get("source"), dict) else {}):
+                        if cand := (lsrc.get("display_name") or "").strip():
+                            venue = cand
+                            break
+            pdf_url = _openalex_resolve_pdf_url(w)
+            arxiv_id_oa = None
+            ids_obj = w.get("ids") or {}
+            if isinstance(ids_obj, dict):
+                axu = ids_obj.get("arxiv")
+                if isinstance(axu, str):
+                    if "arxiv.org/abs/" in axu.lower():
+                        arxiv_id_oa = axu.split("arxiv.org/abs/", 1)[-1].strip().rstrip("/")
+                    elif "arxiv.org/pdf/" in axu.lower():
+                        raw = axu.split("arxiv.org/pdf/", 1)[-1].strip().rstrip("/")
+                        arxiv_id_oa = raw[:-4] if raw.lower().endswith(".pdf") else raw
+            if not arxiv_id_oa:
+                arxiv_id_oa = arxiv_id_from_doi(doi)
+            if not pdf_url and arxiv_id_oa:
+                pdf_url = _arxiv_pdf_url_from_id(arxiv_id_oa)
+            source_url = _openalex_human_source_url(w, doi)
+            citations = int(w.get("cited_by_count") or 0)
+            concepts = [
+                str(c["display_name"])
+                for c in (w.get("concepts") or [])[:10]
+                if isinstance(c, dict) and c.get("display_name")
+            ]
+            papers.append(
+                searcher._make_paper(
+                    title=title,
+                    authors=authors,
+                    abstract=abstract,
+                    doi=doi,
+                    arxiv_id=arxiv_id_oa,
+                    journal=venue or "OpenAlex",
+                    year=year,
+                    pdf_url=pdf_url,
+                    source_url=source_url,
+                    citations=citations,
+                    keywords=concepts,
+                    source="openalex",
+                )
+            )
+            if len(papers) >= max_results:
+                break
+        except Exception:
+            continue
+    return papers
+
+
+async def _search_openalex_works_by_author_async(
+    searcher, author_names: list[str], max_results: int, **kwargs: Any
+) -> list[Paper]:
+    """Resolve author IDs, then fetch their works."""
+    search_candidates = author_names_for_api(author_names)
+    if not search_candidates:
+        return []
+    headers = searcher._openalex_headers()
+
+    from ....services.retrieval.source_plan import openalex_publication_year_filter
+
+    year_filt = openalex_publication_year_filter(kwargs.get("year_from"), kwargs.get("year_to"))
+
+    best_aid, best_score = "", -1
+    for cand in search_candidates[:4]:
+        try:
+            ar = await searcher._async_client.get(
+                "https://api.openalex.org/authors",
+                params=searcher._openalex_params({"search": cand, "per_page": 8}),
+                headers=headers,
+                timeout=22.0,
+            )
+            ar.raise_for_status()
+            hits = [h for h in list((ar.json() or {}).get("results") or []) if isinstance(h, dict)]
+            best_hit, score = pick_best_name_match(
+                hits,
+                cand,
+                display_name=lambda h: str(h.get("display_name") or "").strip(),
+                score_bonus=lambda h, s: s + min(3, int(h.get("works_count") or 0) // 200),
+            )
+            if best_hit and score > best_score:
+                best_score, best_aid = score, str(best_hit.get("id") or "").strip()
+        except Exception:
+            continue
+    if not best_aid:
+        return []
+
+    from ....settings import get_settings
+
+    if best_score < float(getattr(get_settings(), "openalex_author_match_min_score", 2.0) or 2.0):
+        return []
+    short_id = best_aid.rsplit("/", 1)[-1]
+    params = searcher._openalex_params({
+        "filter": f"authorships.author.id:{short_id}",
+        "sort": "publication_date:desc",
+        "per_page": min(max(1, max_results), 200),
+    })
+    if year_filt:
+        params["filter"] = f"{params['filter']},{year_filt}"
+    try:
+        wr = await searcher._async_client.get(
+            "https://api.openalex.org/works",
+            params=params,
+            headers=headers,
+            timeout=28.0,
+        )
+        wr.raise_for_status()
+        results = list((wr.json() or {}).get("results") or [])
+    except Exception:
+        return []
+
+    return _extract_openalex_papers(searcher, results, max_results)
+
+
+async def _openalex_resolve_venue_id_async(
+    searcher, venue_raw: str, headers: Optional[Dict[str, str]] = None
+) -> Optional[str]:
+    """Resolve a venue name to an OpenAlex venue ID."""
+    v = (venue_raw or "").strip().lower()
+    if not v:
+        return None
+    v2 = re.sub(r"\b(19|20)\d{2}\b", "", v).strip() or v
+    try:
+        r = await searcher._async_client.get(
+            "https://api.openalex.org/venues",
+            params=searcher._openalex_params({"search": v2, "per_page": 5}),
+            h=headers or searcher._openalex_headers(),
+            timeout=25.0,
+        )
+        r.raise_for_status()
+        return _pick_openalex_venue_id_from_items(
+            v2, list((r.json() or {}).get("results") or [])
+        )
+    except Exception:
+        return None
+
+
+async def search_openalex(
+    searcher, query: str, max_results: int = 10, **kwargs
+) -> List[Paper]:
+    """Search OpenAlex using pipeline constraints."""
+    await searcher._ensure_async_client()
+    await searcher._rate_limit_async("openalex")
+
+    per_page = min(max(1, max_results), 200)
+    venue_raw = (kwargs.get("venue") or "").strip() or None
+    venue_s = (venue_raw or "").strip()
+    vpj = searcher._resolve_vpj(venue_raw, kwargs)
+
+    from ....services.retrieval.source_plan import openalex_publication_year_filter
+
+    yf_int = int(kwargs.get("year_from")) if kwargs.get("year_from") is not None else None
+    yt_int = int(kwargs.get("year_to")) if kwargs.get("year_to") is not None else None
+    year_filt = openalex_publication_year_filter(yf_int, yt_int)
+    pinned_venue_single_year = bool(
+        venue_s and yf_int and yt_int and yf_int == yt_int and 1900 <= yf_int <= 2100
+    )
+
+    base_q = (plain_query_for_text_apis(query, kwargs) or "").strip()
+    if pinned_venue_single_year:
+        pin_q = f"{venue_s} {yf_int}".strip()
+        topic_raw = kwargs.get("pinned_topic_terms") or []
+        topic_str = (
+            (
+                " ".join(str(x).strip() for x in topic_raw if str(x).strip())[:120]
+                if topic_raw
+                else extract_pinned_topic_terms(
+                    query=query,
+                    merged_kw=sanitize_search_keyword_list(kwargs.get("llm_keywords")),
+                    venue=venue_s,
+                    year=yf_int,
+                )
+            )
+        )
+        if topic_str and topic_str.lower() not in (base_q or "").lower():
+            base_q = f"{topic_str} {base_q or pin_q}".strip()[:180]
+        if not base_q or len(base_q) < 4:
+            base_q = f"{topic_str} {pin_q}".strip()[:180] if topic_str else pin_q
+        elif venue_s.lower() not in base_q.lower():
+            can_key = (_venue_canonical_key(venue_s) or "").lower()
+            if not can_key or can_key not in base_q.lower():
+                base_q = f"{topic_str} {pin_q}".strip()[:180] if topic_str else pin_q
+            elif str(yf_int) not in base_q:
+                base_q = f"{base_q} {yf_int}".strip()[:180]
+    qtext = (base_q if base_q else venue_s or "").strip()
+    if pinned_venue_single_year and (not qtext or qtext == "*"):
+        qtext = f"{venue_s} {yf_int}".strip()
+
+    try:
+        qtext_clean = re.sub(
+            r"\s+", " ",
+            re.sub(r"[^\w\s\-\./]+", " ", qtext, flags=re.UNICODE),
+        ).strip()[:180]
+    except Exception:
+        qtext_clean = (qtext or "").strip()[:180]
+
+    star_q = qtext in ("*", "")
+    main_track_oa = bool(venue_s and vpj and kwargs.get("main_conference_proceedings_only"))
+
+    if pinned_venue_single_year and main_track_oa and venue_s and yf_int:
+        headers = searcher._openalex_headers()
+        vid = await _openalex_resolve_venue_id_async(searcher, venue_s, headers=headers)
+        if vid:
+            try:
+                browse_mult = 5 if kwargs.get("venue_browse") else 3
+                browse_n = min(max(per_page, max_results * browse_mult), 200)
+                if kwargs.get("venue_browse"):
+                    browse_n = max(browse_n, 80)
+                oa_params = searcher._openalex_params({
+                    "filter": f"publication_year:{yf_int},host_venue.id:{vid}",
+                    "per_page": browse_n,
+                    "sort": "cited_by_count:desc",
+                })
+                resp = await searcher._async_http_get_with_retry(
+                    "https://api.openalex.org/works",
+                    params=oa_params,
+                    headers=headers,
+                    timeout=float(kwargs.get("openalex_timeout_sec") or 45.0),
+                    max_attempts=2,
+                )
+                browse_results = list((resp.json() or {}).get("results") or [])
+                browse_papers = _extract_openalex_papers(
+                    searcher, browse_results, max(browse_n, max_results)
+                )
+                from ....services.retrieval.paper_filters import is_obvious_workshop_track
+
+                browse_papers = [
+                    p
+                    for p in browse_papers
+                    if not is_obvious_workshop_track(p, venue_s)
+                ]
+                if browse_papers:
+                    searcher._bump_stat("openalex_requests")
+                    return browse_papers[:max_results]
+            except Exception:
+                logger.warning(
+                    "[OpenAlex] host_venue browse failed venue=%s year=%s",
+                    venue_s,
+                    yf_int,
+                    exc_info=True,
+                )
+
+    if star_q and (pinned_venue_single_year or main_track_oa):
+        star_q = False
+        qtext = (
+            f"{venue_s} {yf_int}".strip()
+            if pinned_venue_single_year and yf_int
+            else (venue_s or qtext)
+        )
+        try:
+            qtext_clean = re.sub(
+                r"\s+", " ",
+                re.sub(r"[^\w\s\-\./]+", " ", qtext, flags=re.UNICODE),
+            ).strip()[:180]
+        except Exception:
+            qtext_clean = (qtext or "").strip()[:180]
+
+    if star_q:
+        if yf_int is None and year_filt is None:
+            yf_int = datetime.now().year - 1
+            year_filt = f"publication_year:>={yf_int}"
+        filt = year_filt or f"publication_year:>={yf_int}"
+        ocid = (kwargs.get("openalex_concept_id") or "").strip()
+        if ocid and filt:
+            filt = f"{filt},concept.id:{ocid if ocid.startswith('http') else f'https://openalex.org/{ocid}'}"
+        params: Dict[str, Any] = searcher._openalex_params({
+            "per_page": per_page,
+            "sort": "publication_date:desc",
+        })
+        if filt:
+            params["filter"] = filt
+    else:
+        params = searcher._openalex_params({
+            "search": qtext_clean or qtext,
+            "per_page": per_page,
+        })
+        if year_filt:
+            params["filter"] = year_filt
+
+    headers = searcher._openalex_headers()
+    oa_retry_without_host_venue = (
+        kwargs.get("venue_fallback_if_empty", True)
+        or kwargs.get("openalex_relax_host_venue_on_empty", True)
+    )
+
+    async def _do_request(with_venue_filter: bool) -> List[Dict[str, Any]]:
+        local_params = dict(params)
+        if with_venue_filter and venue_raw and vpj:
+            vid = await _openalex_resolve_venue_id_async(searcher, venue_raw, headers=headers)
+            if vid:
+                prev = (local_params.get("filter") or "").strip()
+                local_params["filter"] = f"{prev},host_venue.id:{vid}" if prev else f"host_venue.id:{vid}"
+        resp = await searcher._async_client.get(
+            url="https://api.openalex.org/works",
+            params=local_params,
+            headers=headers,
+            timeout=45.0,
+        )
+        if resp.status_code == 400 and (local_params.get("search") or local_params.get("filter")):
+            lp2 = dict(local_params)
+            lp2["search"] = qtext_clean or str(lp2.get("search") or "")
+            lp2.pop("filter", None)
+            if year_filt:
+                lp2["filter"] = year_filt
+            resp = await searcher._async_client.get(
+                url="https://api.openalex.org/works",
+                params=lp2,
+                headers=headers,
+                timeout=45.0,
+            )
+        resp.raise_for_status()
+        return list((resp.json() or {}).get("results") or [])
+
+    results = await _do_request(with_venue_filter=bool(venue_raw and vpj))
+    if venue_raw and vpj and not results and oa_retry_without_host_venue:
+        results = await _do_request(with_venue_filter=False)
+
+    if star_q and not results and (kwargs.get("openalex_concept_id") or "").strip() and "concept.id:" in str(params.get("filter", "")):
+        yf_b = yf_int if yf_int is not None else datetime.now().year - 1
+        params["filter"] = f"publication_year:>={yf_b}"
+        results = await _do_request(with_venue_filter=bool(venue_raw and vpj))
+        if venue_raw and vpj and not results and oa_retry_without_host_venue:
+            results = await _do_request(with_venue_filter=False)
+
+    papers = _extract_openalex_papers(searcher, results, max_results)
+
+    if main_track_oa and venue_raw and papers:
+        matched = [p for p in papers if searcher._paper_matches_venue(p, venue_raw)]
+        if not matched:
+            can = _venue_canonical_key(venue_raw)
+            matched = [
+                p for p in papers
+                if _title_matches_venue_signals(
+                    getattr(p, "title", "") or "",
+                    can,
+                    venue_raw,
+                    getattr(p, "abstract", None),
+                    getattr(p, "notes", None) or "",
+                )
+            ]
+        papers = matched
+
+    searcher._bump_stat("openalex_requests")
+    return papers

+ 57 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/source_common.py

@@ -0,0 +1,57 @@
+"""搜索源公共基类 —— 通用过滤/去重/超时控制与结果标准化."""
+
+from __future__ import annotations
+
+from typing import Any, Callable, TypeVar
+
+from ....utils.author_query_match import normalize_author_names
+from ....utils.common import text_has_cjk
+from ..normalize import _score_name_match
+from ..paper_searcher import _sanitize_author_list_for_query
+
+T = TypeVar("T")
+
+
+def json_api_headers(searcher, *, accept: str = "application/json") -> dict[str, str]:
+    return {"User-Agent": searcher._user_agent(), "Accept": accept}
+
+
+def normalize_query_authors(query: str, raw_authors: Any) -> list[str]:
+    if isinstance(raw_authors, str):
+        raw_authors = [raw_authors]
+    return normalize_author_names(
+        [
+            str(x).strip()
+            for x in (_sanitize_author_list_for_query(query, raw_authors or []) or [])
+            if str(x).strip()
+        ]
+    )
+
+
+def author_names_for_api(names: list[str]) -> list[str]:
+    latin = [n for n in names if n.strip() and not text_has_cjk(n)]
+    return latin or [n for n in names if n.strip()]
+
+
+def pick_best_name_match(
+    items: list[T],
+    query_name: str,
+    *,
+    display_name: Callable[[T], str],
+    score_bonus: Callable[[T, int], int] | None = None,
+) -> tuple[T | None, int]:
+    best: T | None = None
+    best_score = -1
+    for it in items:
+        try:
+            dn = display_name(it)
+            if not dn:
+                continue
+            score = _score_name_match(dn, query_name)
+            if score_bonus:
+                score = score_bonus(it, score)
+            if score > best_score:
+                best_score, best = score, it
+        except Exception:
+            continue
+    return best, best_score

+ 341 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/search/sources/tavily.py

@@ -0,0 +1,341 @@
+"""Tavily 搜索适配器 —— Web 预搜索与会议官网探测."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+def _is_noise_title(title: str) -> bool:
+    """Reject obvious page/listing titles."""
+    t = title.strip()
+    if not t or len(t) < 12 or len(t) > 300:
+        return True
+    low = t.lower()
+    if low in {"proceedings", "list of proceedings", "accepted papers", "neurips", "nips"}:
+        return True
+    if re.match(r"(?i)^(cvpr|iccv|eccv)\s*\d{4}\s*$", t):
+        return True
+    if re.search(r"(?i)open access repository", t):
+        return True
+    return False
+
+
+async def _llm_filter_relevant(
+    papers: list[dict[str, Any]], query: str, venue: str, limit: int,
+) -> list[dict[str, Any]]:
+    """Keep the most query-relevant extracted papers."""
+    if not papers or len(papers) <= limit:
+        return papers
+    try:
+        from ....services.llm.llm_service import get_llm, is_llm_configured
+        from ....services.llm.agent_runtime import run_json_task
+
+        if not is_llm_configured():
+            return papers[:limit]
+    except Exception:
+        return papers[:limit]
+
+    titles = [f"[{i}] {p['title']}" for i, p in enumerate(papers)]
+    prompt = (
+        f"用户搜索:{query}\n"
+        f"会议:{venue}\n"
+        "以下是论文标题列表。选出与搜索主题最相关的论文(最多" + str(limit) + "篇)。\n"
+        "输出 JSON:{\"relevant\":[0,3,5,...]}(保留的索引号,从0开始,按相关度降序)\n\n"
+        + "\n".join(titles)
+    )
+    try:
+        data = await asyncio.to_thread(
+            lambda: run_json_task(
+                task_name="paper_relevance_filter",
+                agent_name="papergraph_relevance_filter",
+                llm=get_llm(),
+                system_prompt="你是学术论文相关性判断器。严格筛选,只输出合法JSON。",
+                user_prompt=prompt,
+                timeout_sec=8,
+                retries=0,
+                default={"relevant": list(range(min(limit, len(papers))))},
+            )
+        )
+    except Exception:
+        return papers[:limit]
+    indices = data.get("relevant") if isinstance(data, dict) else list(range(len(papers)))
+    if not isinstance(indices, list):
+        return papers[:limit]
+    result = [papers[i] for i in indices if isinstance(i, int) and 0 <= i < len(papers)]
+    return result[:limit] if result else papers[:limit]
+
+
+def _clean_abstract(text: str) -> str:
+    """Remove citation/HTML boilerplate from snippets."""
+    t = (text or "").strip()
+    if not t:
+        return ""
+    t = re.sub(r"@\w+\{[^}]*\}?", "", t, flags=re.S)
+    # BibTeX field fragments can appear without closing braces.
+    t = re.sub(r",?\s*\b(author|title|booktitle|journal|year|pages|volume|number|publisher|editor|series|address|month|note|url|doi|isbn)\s*=\s*\{[^}]*\}?,?", "", t, flags=re.I)
+    t = re.sub(r"These (?:CVPR|ICCV|ECCV)\s*(?:20\d{2})?\s*papers are the Open Access versions?, provided by the Computer Vision Foundation\.?", "", t, flags=re.I)
+    t = re.sub(r"Except for the watermark,? they are identical to the accepted versions?;? the final published version of the proceedings is available on IEEE Xplore\.?", "", t, flags=re.I)
+    t = re.sub(r"All persons copying this information are expected to adhere to the terms and constraints invoked by each author'?s? copyright\.?", "", t, flags=re.I)
+    t = re.sub(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", "", t)
+    t = re.sub(r"https?://\S+", "", t)
+    t = re.sub(r"\(?(?:CVPR|ICCV|ECCV|NeurIPS|ICLR|ICML|AAAI|IJCAI|ACL|EMNLP|NAACL|SIGIR|KDD)\)?\s*(?:Workshops?|Conference|Proceedings)?,?\s*(?:19|20)\d{2},?\s*pp?\.?\s*[\d\-–]+\.?", "", t, flags=re.I)
+    t = re.sub(r"\bpp\.\s*[\d\-–]+\b", "", t)
+    t = re.sub(r"\bvol\.?\s*\d+\b", "", t)
+    t = re.sub(r"<[^>]+>", " ", t)
+    t = re.sub(r"&#?\w+;", " ", t)
+    t = re.sub(r"\{\s*\}", "", t)
+    t = re.sub(r"\s{2,}", " ", t)
+    t = re.sub(r",\s*,", ",", t)
+    t = t.strip(" ,;:")
+    if not t or len(t) < 30:
+        return ""
+    if re.match(r"(?i)(all persons copying|these \w+ papers are|except for the watermark)", t):
+        return ""
+    return t[:800] if len(t) > 800 else t
+
+
+async def _llm_extract_papers_from_raw(
+    raw_text: str,
+    *,
+    query: str,
+    venue: str = "",
+    year: Any = None,
+    limit: int = 20,
+) -> list[dict[str, Any]]:
+    """Extract paper records from raw Tavily page text."""
+    try:
+        from ....services.llm.llm_service import get_llm, is_llm_configured
+        from ....services.llm.agent_runtime import run_json_task
+
+        if not is_llm_configured():
+            return []
+    except Exception:
+        return []
+
+    cleaned = re.sub(r"<[^>]+>", " ", raw_text or "")
+    cleaned = re.sub(r"\s+", " ", cleaned).strip()[:14000]
+    if len(cleaned) < 100:
+        return []
+
+    prompt = (
+        "从下面的网页文本中提取学术论文的标题、作者和摘要。\n"
+        "文本中可能混有 BibTeX 引用、版权声明等。只抽取真实论文信息。\n"
+        "标题是研究成果名称,摘要是一段连续的学术描述文字。\n"
+        "不要抽取:论文集名称、导航链接、Workshop/Challenge/Tutorial 论文、\n"
+        "版权声明、BibTeX 字段值、残缺文字。\n"
+        f"用户搜索主题:{query}。只提取与主题相关的论文。\n"
+        f"会议:{venue or '未知'},年份:{year or '未知'},最多 {limit} 篇。\n"
+        '输出 JSON:{"papers":[{"title":"...","authors":["..."],"abstract":"..."}]}\n\n'
+        f"页面文本:\n{cleaned}"
+    )
+    try:
+        data = await asyncio.to_thread(
+            lambda: run_json_task(
+                task_name="tavily_page_extract",
+                agent_name="papergraph_tavily_extractor",
+                llm=get_llm(),
+                system_prompt="你是严格的信息抽取器。只输出合法 JSON,不得编造文本中不存在的论文。",
+                user_prompt=prompt,
+                timeout_sec=15,
+                retries=0,
+                default={"papers": []},
+            )
+        )
+    except Exception:
+        return []
+    arr = data.get("papers") if isinstance(data, dict) else []
+    if not isinstance(arr, list):
+        return []
+    out: list[dict[str, Any]] = []
+    seen: set[str] = set()
+    for it in arr:
+        if not isinstance(it, dict):
+            continue
+        title = str(it.get("title") or "").strip()
+        title = re.sub(r"^\s*\[PDF\]\s*", "", title, flags=re.I)
+        title = re.sub(r"^\s*#+\s*", "", title)
+        title = re.sub(r"\s+", " ", title).strip()
+        if len(title) < 8 or len(title) > 300:
+            continue
+        key = title.lower()
+        if key in seen:
+            continue
+        seen.add(key)
+        authors = it.get("authors") if isinstance(it.get("authors"), list) else []
+        out.append({"title": title, "authors": [str(a)[:120] for a in authors[:12] if str(a).strip()]})
+        if len(out) >= limit:
+            break
+
+    # Keep LLM extraction broad, then trim by relevance.
+    if len(out) > limit // 2:
+        out = await _llm_filter_relevant(out, query, venue, limit)
+    return out
+
+
+async def _tavily_proceedings_fetch(
+    *,
+    api_key: str,
+    query: str,
+    domains: list[str] | None,
+    max_results: int,
+    httpx_client: Any,
+    searcher: Any,
+    year: Any,
+    journal: str = "",
+) -> list:
+    from ....services.retrieval.web_presearch import tavily_search_async as _tv
+
+    n = max(1, min(10, int(max_results or 5)))
+    items = await _tv(
+        api_key=api_key, query=query, max_results=n,
+        include_domains=domains, httpx_client=httpx_client,
+    )
+    if not items and domains:
+        items = await _tv(
+            api_key=api_key, query=query, max_results=n,
+            include_domains=None, httpx_client=httpx_client,
+        )
+
+    papers: list = []
+    for it in items or []:
+        title = str(it.get("title") or "").strip()
+        link = str(it.get("link") or it.get("url") or "").strip()
+        raw = str(it.get("raw_content") or "")
+        snippet = str(it.get("content") or it.get("snippet") or "")
+
+        llm_papers = await _llm_extract_papers_from_raw(
+            raw, query=query or "", venue=journal, year=year, limit=max_results,
+        )
+        for lp in llm_papers:
+            t = lp["title"].strip()
+            if _is_noise_title(t):
+                continue
+            tl = t.lower()
+            if any(w in tl for w in ("workshop", "challenge", "tutorial", "demo track", "competition")):
+                continue
+            abs_text = _clean_abstract(lp.get("abstract", ""))
+            if not abs_text:
+                abs_text = _clean_abstract(snippet)
+            papers.append(
+                searcher._make_paper(
+                    title=t,
+                    authors=lp.get("authors", []),
+                    abstract=abs_text,
+                    source_url=link,
+                    source="tavily",
+                    journal=journal or None,
+                    year=int(year) if year else None,
+                )
+            )
+        # Empty extraction usually means the page is not a paper listing.
+    return papers
+
+
+async def search_tavily_proceedings(
+    searcher: Any,
+    query: str,
+    venue: str,
+    year: Any,
+    max_results: int,
+    *,
+    venue_browse: bool = False,
+) -> list:
+    """会议官网召回:优先配置域名;不足则 Tavily 自动发现站点再搜对应年份论文。"""
+    try:
+        from ....settings import get_settings as _gs
+        from ....services.retrieval.tavily_venue_config import tavily_include_domains_for_venue as _td
+        from ....services.retrieval.proceedings_discovery import discover_proceedings_domains
+
+        _ak = getattr(_gs(), "tavily_api_key", "").strip()
+        if not _ak:
+            return []
+
+        _client = await searcher._ensure_async_client()
+        year_i = int(year) if year is not None else None
+        topic = (query or "").strip()
+        if topic.lower() == (venue or "").strip().lower():
+            topic = ""
+        base_q = f"{venue} {year_i} proceedings {topic}".strip() if year_i else f"{venue} proceedings {topic}".strip()
+        _q = base_q.strip() or f"{venue} {year_i or ''} papers".strip()
+
+        static_dom = list(_td(venue) or [])
+        domains = static_dom[:3]
+
+        if not domains:
+            domains = await discover_proceedings_domains(
+                api_key=_ak, venue=venue, year=year_i,
+                httpx_client=_client, max_domains=3,
+            )
+
+        journal = venue.strip().upper()
+        papers: list = []
+        if venue_browse:
+            browse_queries = [
+                f"{venue} {year_i} main conference accepted papers",
+                f"{venue} {year_i} oral papers proceedings",
+                f"{venue} {year_i} conference papers list",
+            ]
+            seen_titles: set[str] = set()
+            for bq in browse_queries:
+                batch = await _tavily_proceedings_fetch(
+                    api_key=_ak, query=bq, domains=domains or None,
+                    max_results=10, httpx_client=_client,
+                    searcher=searcher, year=year, journal=journal,
+                )
+                for p in batch:
+                    t = (getattr(p, "title", None) or "").strip().lower()
+                    if t and t not in seen_titles:
+                        seen_titles.add(t)
+                        papers.append(p)
+                if len(papers) >= max(20, int(max_results)):
+                    break
+        else:
+            seen_titles: set[str] = set()
+            for tq in [base_q]:
+                batch = await _tavily_proceedings_fetch(
+                    api_key=_ak, query=tq, domains=domains or None,
+                    max_results=max_results, httpx_client=_client,
+                    searcher=searcher, year=year, journal=journal,
+                )
+                for p in batch:
+                    t = (getattr(p, "title", None) or "").strip().lower()
+                    if t and t not in seen_titles:
+                        seen_titles.add(t)
+                        papers.append(p)
+
+        if not papers and not domains:
+            papers = await _tavily_proceedings_fetch(
+                api_key=_ak,
+                query=f"{venue} {year_i or ''} official conference papers accepted".strip(),
+                domains=None, max_results=max_results,
+                httpx_client=_client, searcher=searcher, year=year, journal=journal,
+            )
+
+        if not papers:
+            discovered = await discover_proceedings_domains(
+                api_key=_ak, venue=venue, year=year_i,
+                httpx_client=_client, max_domains=3,
+            )
+            if discovered:
+                extra = await _tavily_proceedings_fetch(
+                    api_key=_ak, query=_q, domains=discovered,
+                    max_results=max_results, httpx_client=_client,
+                    searcher=searcher, year=year, journal=journal,
+                )
+                if len(extra) > len(papers):
+                    papers = extra
+                    logger.info(
+                        "[tavily_proceedings] venue=%s year=%s rediscovered domains=%s -> %d papers",
+                        venue, year_i, discovered, len(papers),
+                    )
+
+        searcher._bump_stat("total_results", len(papers))
+        return papers
+    except Exception as e:
+        logger.warning("[tavily_proceedings] failed venue=%s year=%s: %s", venue, year, e)
+        return []

+ 725 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/core/storage.py

@@ -0,0 +1,725 @@
+"""本地存储服务 —— JSON/SQLite 文件持久化与数据备份."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import sqlite3
+from collections import defaultdict
+from contextlib import contextmanager, suppress
+from typing import Any
+
+from .author import Author
+from .paper import Paper
+from .paper_paths import LIBRARY_PDF_ROOT_DIR, category_slug_for_pdf_dir
+from ..settings import get_settings
+
+logger = logging.getLogger(__name__)
+
+class PaperDatabase:
+    """SQLite 论文数据库 —— 论文 CRUD、全文搜索(FTS)、分类与标签管理."""
+
+    def __init__(self, db_path: str | None = None) -> None:
+        if db_path is None:
+            db_path = os.path.join(os.path.abspath(get_settings().data_dir), "papers.db")
+
+        self.db_path = db_path
+        self._library_fts_ready = False
+        self._ensure_directory()
+        self._init_database()
+        self._library_fts_ready = self._detect_fts_table()
+
+    def _data_root(self) -> str:
+        return os.path.dirname(os.path.abspath(self.db_path))
+
+    def _abs_local_pdf(self, relpath: str | None) -> str | None:
+        if not relpath or not str(relpath).strip():
+            return None
+        return os.path.normpath(os.path.join(self._data_root(), str(relpath).strip()))
+
+    def _ensure_directory(self) -> None:
+        os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
+
+    def _detect_fts_table(self) -> bool:
+        try:
+            return self._query(
+                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='papers_fts' LIMIT 1",
+                fetch='one'
+            ) is not None
+        except Exception:
+            return False
+
+    @contextmanager
+    def _get_connection(self):
+        conn = sqlite3.connect(self.db_path)
+        conn.row_factory = sqlite3.Row
+        try:
+            yield conn
+            conn.commit()
+        except Exception as e:
+            conn.rollback()
+            logger.error("Database transaction failed: %s", e)
+            raise
+        finally:
+            conn.close()
+
+    def _query(self, sql, params=(), fetch='all'):
+        with self._get_connection() as conn:
+            cur = conn.cursor()
+            cur.execute(sql, params)
+            if fetch == 'one':
+                return cur.fetchone()
+            if fetch == 'all':
+                return cur.fetchall()
+            return None
+
+    def _ensure_column(self, conn: sqlite3.Connection, col_name: str, col_type: str = "TEXT") -> None:
+        cur = conn.cursor()
+        cur.execute("PRAGMA table_info(papers)")
+        cols = [r[1] for r in cur.fetchall()]
+        if col_name not in cols:
+            cur.execute(f"ALTER TABLE papers ADD COLUMN {col_name} {col_type}")
+
+    def _init_database(self) -> None:
+        with self._get_connection() as conn:
+            cursor = conn.cursor()
+            cursor.execute("PRAGMA user_version")
+            db_version = int(cursor.fetchone()[0])
+
+            if db_version < 1:
+                cursor.execute(
+                    """
+                    CREATE TABLE IF NOT EXISTS papers (
+                        id INTEGER PRIMARY KEY AUTOINCREMENT,
+                        title TEXT NOT NULL,
+                        abstract TEXT,
+                        doi TEXT UNIQUE,
+                        pmid TEXT UNIQUE,
+                        arxiv_id TEXT UNIQUE,
+                        pmc_id TEXT UNIQUE,
+                        journal TEXT,
+                        year INTEGER,
+                        volume TEXT,
+                        issue TEXT,
+                        pages TEXT,
+                        publisher TEXT,
+                        pdf_url TEXT,
+                        source_url TEXT,
+                        local_pdf_path TEXT,
+                        keywords TEXT,
+                        mesh_terms TEXT,
+                        "references" TEXT,
+                        citations INTEGER DEFAULT 0,
+                        source TEXT DEFAULT 'unknown',
+                        notes TEXT,
+                        tags TEXT,
+                        category TEXT,
+                        rating INTEGER,
+                        read_status TEXT DEFAULT 'unread',
+                        importance TEXT DEFAULT 'normal',
+                        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+                    )
+                    """
+                )
+                self._ensure_column(conn, "local_pdf_path")
+                self._ensure_column(conn, "category")
+                self._ensure_column(conn, "venue_type")
+
+                cursor.execute(
+                    """
+                    CREATE TABLE IF NOT EXISTS authors (
+                        id INTEGER PRIMARY KEY AUTOINCREMENT,
+                        name TEXT NOT NULL,
+                        affiliation TEXT,
+                        email TEXT,
+                        orcid TEXT UNIQUE
+                    )
+                    """
+                )
+                cursor.execute(
+                    """
+                    CREATE TABLE IF NOT EXISTS paper_authors (
+                        paper_id INTEGER NOT NULL,
+                        author_id INTEGER NOT NULL,
+                        author_order INTEGER DEFAULT 0,
+                        PRIMARY KEY (paper_id, author_id),
+                        FOREIGN KEY (paper_id) REFERENCES papers(id) ON DELETE CASCADE,
+                        FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE
+                    )
+                    """
+                )
+                cursor.execute("PRAGMA user_version = 1")
+                db_version = 1
+
+            if db_version < 2:
+                cursor.executescript(
+                    """
+                    CREATE INDEX IF NOT EXISTS idx_papers_category ON papers(category);
+                    CREATE INDEX IF NOT EXISTS idx_papers_year ON papers(year);
+                    CREATE INDEX IF NOT EXISTS idx_papers_read_status ON papers(read_status);
+                    CREATE INDEX IF NOT EXISTS idx_papers_created_at ON papers(created_at);
+                    CREATE INDEX IF NOT EXISTS idx_category_year ON papers(category, year);
+                    """
+                )
+                try:
+                    cursor.executescript(
+                        """
+                        CREATE VIRTUAL TABLE IF NOT EXISTS papers_fts USING fts5(
+                            title, abstract,
+                            content='papers', content_rowid='id'
+                        );
+
+                        CREATE TRIGGER IF NOT EXISTS papers_ai AFTER INSERT ON papers BEGIN
+                            INSERT INTO papers_fts(rowid, title, abstract)
+                            VALUES (new.id, new.title, new.abstract);
+                        END;
+
+                        CREATE TRIGGER IF NOT EXISTS papers_ad AFTER DELETE ON papers BEGIN
+                            INSERT INTO papers_fts(papers_fts, rowid, title, abstract)
+                            VALUES ('delete', old.id, old.title, old.abstract);
+                        END;
+
+                        CREATE TRIGGER IF NOT EXISTS papers_au AFTER UPDATE ON papers BEGIN
+                            INSERT INTO papers_fts(papers_fts, rowid, title, abstract)
+                            VALUES ('delete', old.id, old.title, old.abstract);
+                            INSERT INTO papers_fts(rowid, title, abstract)
+                            VALUES (new.id, new.title, new.abstract);
+                        END;
+                        """
+                    )
+                    cursor.execute("INSERT INTO papers_fts(papers_fts) VALUES('rebuild')")
+                except sqlite3.OperationalError as e:
+                    logger.warning("FTS5 不可用或未启用,跳过全文索引: %s", e)
+                cursor.execute("PRAGMA user_version = 2")
+
+    @staticmethod
+    def _norm_id_field(val: str | None) -> str | None:
+        s = (val or "").strip()
+        return s if s else None
+
+    def _sync_saved_meta(self, cursor: sqlite3.Cursor, paper_id: int, paper: Paper) -> None:
+        cat = getattr(paper, "category", None)
+        doi = self._norm_id_field(paper.doi)
+        arxiv_id = self._norm_id_field(paper.arxiv_id)
+        abs_new = (paper.abstract or "").strip() or None
+        title_new = (paper.title or "").strip() or None
+        cursor.execute(
+            """UPDATE papers SET category = ?, tags = ?, pdf_url = ?, source_url = ?,
+               doi = COALESCE(?, doi),
+               arxiv_id = COALESCE(?, arxiv_id),
+               abstract = COALESCE(?, abstract),
+               title = COALESCE(?, title),
+               venue_type = COALESCE(?, venue_type),
+               updated_at = CURRENT_TIMESTAMP WHERE id = ?""",
+            (
+                cat,
+                json.dumps(paper.tags or [], ensure_ascii=False),
+                paper.pdf_url,
+                paper.source_url,
+                doi,
+                arxiv_id,
+                abs_new,
+                title_new,
+                getattr(paper, "venue_type", None),
+                paper_id,
+            ),
+        )
+
+    def _add_paper_internal(self, conn: sqlite3.Connection, paper: Paper) -> tuple[int, bool]:
+        cursor = conn.cursor()
+        doi = self._norm_id_field(paper.doi)
+        arxiv_id = self._norm_id_field(paper.arxiv_id)
+        pmid = self._norm_id_field(paper.pmid)
+        pmc_id = self._norm_id_field(paper.pmc_id)
+
+        for field, val in (("doi", doi), ("arxiv_id", arxiv_id), ("pmid", pmid), ("pmc_id", pmc_id)):
+            if val:
+                cursor.execute(f"SELECT id FROM papers WHERE {field} = ?", (val,))
+                existing = cursor.fetchone()
+                if existing:
+                    eid = int(existing[0])
+                    self._sync_saved_meta(cursor, eid, paper)
+                    return eid, False
+
+        cursor.execute(
+            """
+            INSERT INTO papers (
+                title, abstract, doi, pmid, arxiv_id, pmc_id,
+                journal, year, volume, issue, pages, publisher,
+                pdf_url, source_url, local_pdf_path, keywords, mesh_terms, "references",
+                citations, source, notes, tags, category, venue_type, rating, read_status, importance
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+            """,
+            (
+                paper.title,
+                paper.abstract,
+                doi,
+                pmid,
+                arxiv_id,
+                pmc_id,
+                paper.journal,
+                paper.year,
+                paper.volume,
+                paper.issue,
+                paper.pages,
+                paper.publisher,
+                paper.pdf_url,
+                paper.source_url,
+                getattr(paper, "local_pdf_path", None),
+                json.dumps(paper.keywords, ensure_ascii=False),
+                json.dumps(paper.mesh_terms, ensure_ascii=False),
+                json.dumps(paper.references, ensure_ascii=False),
+                paper.citations,
+                paper.source,
+                paper.notes,
+                json.dumps(paper.tags, ensure_ascii=False),
+                getattr(paper, "category", None),
+                getattr(paper, "venue_type", None),
+                paper.rating,
+                paper.read_status,
+                paper.importance,
+            ),
+        )
+
+        paper_id = cursor.lastrowid
+        self._add_authors(conn, paper_id, paper.authors)
+        return int(paper_id), True
+
+    def add_paper(self, paper: Paper) -> tuple[int, bool]:
+        with self._get_connection() as conn:
+            return self._add_paper_internal(conn, paper)
+
+    def add_papers(self, papers: list[Paper]) -> tuple[list[int], int, int]:
+        ids: list[int] = []
+        added = 0
+        updated = 0
+        with self._get_connection() as conn:
+            for paper in papers:
+                try:
+                    paper_id, is_new = self._add_paper_internal(conn, paper)
+                    ids.append(int(paper_id))
+                    if is_new:
+                        added += 1
+                    else:
+                        updated += 1
+                except Exception as e:
+                    logger.error("批量添加文献时出错 '%s': %s", getattr(paper, "title", ""), e)
+                    ids.append(-1)
+        return ids, added, updated
+
+    def _add_authors(self, conn: sqlite3.Connection, paper_id: int, authors: list[Author]) -> None:
+        cursor = conn.cursor()
+        for order, author in enumerate(authors):
+            if author.orcid:
+                cursor.execute("SELECT id FROM authors WHERE orcid = ?", (author.orcid,))
+            else:
+                cursor.execute("SELECT id FROM authors WHERE name = ?", (author.name,))
+
+            result = cursor.fetchone()
+            if result:
+                author_id = result[0]
+            else:
+                cursor.execute(
+                    "INSERT INTO authors (name, affiliation, email, orcid) VALUES (?, ?, ?, ?)",
+                    (author.name, author.affiliation, author.email, author.orcid),
+                )
+                author_id = cursor.lastrowid
+
+            with suppress(sqlite3.IntegrityError):
+                cursor.execute(
+                    "INSERT INTO paper_authors (paper_id, author_id, author_order) VALUES (?, ?, ?)",
+                    (paper_id, author_id, order),
+                )
+
+    def _fetch_authors_for_papers(
+        self, conn: sqlite3.Connection, paper_ids: list[int]
+    ) -> dict[int, list[Author]]:
+        if not paper_ids:
+            return {}
+        cursor = conn.cursor()
+        placeholders = ",".join("?" * len(paper_ids))
+        cursor.execute(
+            f"""
+            SELECT pa.paper_id, a.* FROM authors a
+            JOIN paper_authors pa ON a.id = pa.author_id
+            WHERE pa.paper_id IN ({placeholders})
+            ORDER BY pa.paper_id, pa.author_order
+            """,
+            paper_ids,
+        )
+        authors_by_paper: dict[int, list[Author]] = defaultdict(list)
+        for row in cursor.fetchall():
+            authors_by_paper[int(row["paper_id"])].append(
+                Author(
+                    name=row["name"],
+                    affiliation=row["affiliation"],
+                    email=row["email"],
+                    orcid=row["orcid"],
+                    db_id=int(row["id"]) if row["id"] is not None else None,
+                )
+            )
+        return dict(authors_by_paper)
+
+    def _row_to_paper_fast(self, row: sqlite3.Row, authors: list[Author]) -> Paper:
+        keys = row.keys()
+        return Paper(
+            id=row["id"],
+            title=row["title"],
+            authors=authors,
+            abstract=row["abstract"],
+            doi=row["doi"],
+            pmid=row["pmid"],
+            arxiv_id=row["arxiv_id"],
+            pmc_id=row["pmc_id"],
+            journal=row["journal"],
+            year=row["year"],
+            volume=row["volume"],
+            issue=row["issue"],
+            pages=row["pages"],
+            publisher=row["publisher"],
+            pdf_url=row["pdf_url"],
+            source_url=row["source_url"],
+            local_pdf_path=row["local_pdf_path"] if "local_pdf_path" in keys else None,
+            keywords=json.loads(row["keywords"] or "[]"),
+            mesh_terms=json.loads(row["mesh_terms"] or "[]"),
+            references=json.loads(row["references"] or "[]"),
+            citations=row["citations"] or 0,
+            source=row["source"] or "unknown",
+            notes=row["notes"],
+            tags=json.loads(row["tags"] or "[]"),
+            category=row["category"] if "category" in keys else None,
+            venue_type=row["venue_type"] if "venue_type" in keys else None,
+            rating=row["rating"],
+            read_status=row["read_status"] or "unread",
+            importance=row["importance"] or "normal",
+        )
+
+    def count_papers(self) -> int:
+        return self._query("SELECT COUNT(*) FROM papers", fetch='one')[0]
+
+    def get_all_papers(self, limit: int | None = None, offset: int = 0, order_by: str = "created_at DESC") -> list[Paper]:
+        with self._get_connection() as conn:
+            cursor = conn.cursor()
+            query = f"SELECT * FROM papers ORDER BY {order_by}"
+            if limit:
+                query += f" LIMIT {int(limit)}"
+            if offset:
+                query += f" OFFSET {int(offset)}"
+            cursor.execute(query)
+            rows = cursor.fetchall()
+            if not rows:
+                return []
+            paper_ids = [int(r["id"]) for r in rows]
+            authors_map = self._fetch_authors_for_papers(conn, paper_ids)
+            return [
+                self._row_to_paper_fast(row, authors_map.get(int(row["id"]), []))
+                for row in rows
+            ]
+
+    def get_paper_by_id(self, paper_id: int) -> Paper | None:
+        row = self._query("SELECT * FROM papers WHERE id = ?", (paper_id,), fetch='one')
+        if not row:
+            return None
+        with self._get_connection() as conn:
+            authors_map = self._fetch_authors_for_papers(conn, [paper_id])
+        return self._row_to_paper_fast(row, authors_map.get(paper_id, []))
+
+    def search_library(
+        self,
+        query: str | None = None,
+        tags: list[str] | None = None,
+        year_from: int | None = None,
+        year_to: int | None = None,
+        read_status: str | None = None,
+        category: str | None = None,
+        limit: int = 100,
+        offset: int = 0,
+    ) -> list[Paper]:
+        with self._get_connection() as conn:
+            cursor = conn.cursor()
+            clauses: list[str] = ["1=1"]
+            params: list[Any] = []
+            use_fts = False
+            match_expr = ""
+            clean_query = ""
+
+            if query and str(query).strip():
+                clean_query = re.sub(r'["\'*^]', " ", str(query)).strip()
+                if clean_query and self._library_fts_ready:
+                    parts = [w for w in clean_query.split() if w.strip()]
+                    if parts:
+                        match_expr = " AND ".join(f'"{w}"' for w in parts)
+                        use_fts = True
+
+            if use_fts:
+                base_from = "papers p"
+                clauses.append(
+                    "(p.id IN (SELECT rowid FROM papers_fts WHERE papers_fts MATCH ?)"
+                    " OR p.id IN (SELECT pa.paper_id FROM paper_authors pa JOIN authors a ON pa.author_id = a.id WHERE a.name LIKE ?))"
+                )
+                params.append(match_expr)
+                like_author = f"%{clean_query}%"
+                params.append(like_author)
+            elif query and str(query).strip():
+                clauses.append("(p.title LIKE ? OR p.abstract LIKE ? OR p.id IN (SELECT pa.paper_id FROM paper_authors pa JOIN authors a ON pa.author_id = a.id WHERE a.name LIKE ?))")
+                like = f"%{str(query).strip()}%"
+                params.extend([like, like, like])
+                base_from = "papers p"
+            else:
+                base_from = "papers p"
+
+            if category:
+                cat = category.strip()
+                if cat.endswith("/*"):
+                    prefix = cat[:-2].strip()
+                    if prefix == "未分类":
+                        clauses.append(
+                            "(p.category IS NULL OR TRIM(COALESCE(p.category, '')) IN ('', '未分类') "
+                            "OR TRIM(COALESCE(p.category, '')) LIKE '未分类/%')"
+                        )
+                    elif prefix:
+                        clauses.append(
+                            "(TRIM(COALESCE(p.category, '')) = ? OR TRIM(COALESCE(p.category, '')) LIKE ?)"
+                        )
+                        params.extend([prefix, prefix + "/%"])
+                elif cat == "未分类":
+                    clauses.append(
+                        "(p.category IS NULL OR TRIM(COALESCE(p.category, '')) IN ('', '未分类'))"
+                    )
+                else:
+                    clauses.append("TRIM(COALESCE(p.category, '')) = ?")
+                    params.append(cat)
+
+            if year_from is not None:
+                clauses.append("(p.year IS NOT NULL AND p.year >= ?)")
+                params.append(year_from)
+            if year_to is not None:
+                clauses.append("(p.year IS NOT NULL AND p.year <= ?)")
+                params.append(year_to)
+            if read_status:
+                clauses.append("p.read_status = ?")
+                params.append(read_status)
+
+            order_clause = "ORDER BY p.created_at DESC"
+
+            sql = f"SELECT p.* FROM {base_from} WHERE {' AND '.join(clauses)} {order_clause} LIMIT ?"
+            params.append(int(limit))
+            if offset:
+                sql += " OFFSET ?"
+                params.append(int(offset))
+            cursor.execute(sql, params)
+            rows = cursor.fetchall()
+            if not rows:
+                return []
+            paper_ids = [int(r["id"]) for r in rows]
+            authors_map = self._fetch_authors_for_papers(conn, paper_ids)
+            papers = [self._row_to_paper_fast(row, authors_map.get(int(row["id"]), [])) for row in rows]
+            if tags:
+                tag_set = set(tags)
+                papers = [p for p in papers if tag_set.intersection(set(p.tags))]
+            return papers
+
+    def update_paper(self, paper_id: int, **fields) -> bool:
+        allowed = {"notes", "tags", "rating", "read_status", "importance", "category", "abstract"}
+        updates = {k: v for k, v in fields.items() if k in allowed and v is not None}
+        if not updates:
+            return False
+        if "tags" in updates and isinstance(updates["tags"], list):
+            updates["tags"] = json.dumps(updates["tags"], ensure_ascii=False)
+        set_parts = [f"{k} = ?" for k in updates]
+        values = list(updates.values()) + [paper_id]
+        with self._get_connection() as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                f"UPDATE papers SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+                values,
+            )
+            return cursor.rowcount > 0
+
+    def set_local_pdf_path(self, paper_id: int, relative_path: str | None) -> bool:
+        with self._get_connection() as conn:
+            cursor = conn.cursor()
+            cursor.execute(
+                "UPDATE papers SET local_pdf_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+                (relative_path, paper_id),
+            )
+            return cursor.rowcount > 0
+
+    def delete_paper(self, paper_id: int) -> bool:
+        with self._get_connection() as conn:
+            cursor = conn.cursor()
+            cursor.execute("SELECT local_pdf_path FROM papers WHERE id = ?", (paper_id,))
+            row = cursor.fetchone()
+            if row and row[0]:
+                abspath = self._abs_local_pdf(row[0])
+                if abspath and os.path.isfile(abspath):
+                    with suppress(OSError):
+                        os.remove(abspath)
+            cursor.execute("DELETE FROM paper_authors WHERE paper_id = ?", (paper_id,))
+            cursor.execute("DELETE FROM papers WHERE id = ?", (paper_id,))
+            return cursor.rowcount > 0
+
+    def repair_library_local_pdf_paths_batch(self, paper_ids: list[int]) -> dict[int, str]:
+        want = {int(x) for x in paper_ids if x is not None and int(x) >= 0}
+        if not want:
+            return {}
+        data_root = self._data_root()
+        lib_root = os.path.join(data_root, LIBRARY_PDF_ROOT_DIR)
+        if not os.path.isdir(lib_root):
+            return {}
+        candidates: dict[int, list[tuple[float, str]]] = {k: [] for k in want}
+        name_pat = re.compile(r"^(\d+)\.pdf$")
+        for dirpath, _, filenames in os.walk(lib_root):
+            for fn in filenames:
+                m = name_pat.match(fn)
+                if not m:
+                    continue
+                pid = int(m.group(1))
+                if pid not in want:
+                    continue
+                full = os.path.join(dirpath, fn)
+                try:
+                    mt = os.path.getmtime(full)
+                except OSError:
+                    continue
+                rel = os.path.relpath(full, data_root).replace("\\", "/")
+                if rel.startswith(".."):
+                    continue
+                candidates[pid].append((mt, rel))
+        out: dict[int, str] = {}
+        for pid, rows in candidates.items():
+            if not rows:
+                continue
+            rows.sort(key=lambda x: -x[0])
+            best_rel = rows[0][1]
+            if self.set_local_pdf_path(pid, best_rel):
+                out[pid] = best_rel
+        return out
+
+    def get_library_pdf_abspath(self, paper_id: int) -> str | None:
+        p = self.get_paper_by_id(paper_id)
+        if not p or not (getattr(p, "local_pdf_path", None) or "").strip():
+            return None
+        rel = (p.local_pdf_path or "").strip()
+        candidates = [self._abs_local_pdf(rel)]
+        if rel.startswith(f"{LIBRARY_PDF_ROOT_DIR}/"):
+            candidates.append(
+                self._abs_local_pdf("pdfs/" + rel[len(LIBRARY_PDF_ROOT_DIR) + 1 :])
+            )
+        elif rel.startswith("pdfs/"):
+            candidates.append(
+                self._abs_local_pdf(f"{LIBRARY_PDF_ROOT_DIR}/" + rel[len("pdfs/") :])
+            )
+        root = os.path.realpath(self._data_root())
+        for abspath in candidates:
+            if not abspath or not os.path.isfile(abspath):
+                continue
+            real_f = os.path.realpath(abspath)
+            if real_f != root and not real_f.startswith(root + os.sep):
+                continue
+            return real_f
+        return None
+
+    def list_library_category_folders(self) -> list[dict[str, Any]]:
+        rows = self._query(
+            """
+            SELECT COALESCE(NULLIF(TRIM(category), ''), '未分类') AS c, COUNT(*) AS n
+            FROM papers
+            GROUP BY c
+            ORDER BY n DESC, c ASC
+            """
+        )
+
+        standalone: dict[str, int] = {}
+        by_parent: dict[str, list[dict[str, Any]]] = defaultdict(list)
+
+        for row in rows:
+            c = row["c"] or "未分类"
+            n = int(row["n"])
+            if "/" not in c:
+                standalone[c] = standalone.get(c, 0) + n
+                continue
+            parts = [p.strip() for p in c.split("/") if p.strip()]
+            if len(parts) < 2:
+                standalone[c] = standalone.get(c, 0) + n
+                continue
+            parent = parts[0]
+            label = "/".join(parts[1:])
+            by_parent[parent].append(
+                {
+                    "category": c,
+                    "label": label,
+                    "folder": category_slug_for_pdf_dir(c),
+                    "count": n,
+                }
+            )
+
+        consumed_standalone: set[str] = set()
+        out: list[dict[str, Any]] = []
+
+        for parent in sorted(
+            by_parent.keys(),
+            key=lambda p: (-sum(x["count"] for x in by_parent[p]), p),
+        ):
+            ch = sorted(by_parent[parent], key=lambda x: (-x["count"], x["label"]))
+            extra = standalone.get(parent, 0)
+            total = sum(x["count"] for x in ch) + extra
+            children: list[dict[str, Any]] = []
+            if extra > 0:
+                children.append(
+                    {
+                        "category": parent,
+                        "label": "未分子类",
+                        "folder": category_slug_for_pdf_dir(parent),
+                        "count": extra,
+                    }
+                )
+                consumed_standalone.add(parent)
+            children.extend(ch)
+            out.append(
+                {
+                    "category": parent,
+                    "folder": category_slug_for_pdf_dir(parent),
+                    "count": total,
+                    "children": children,
+                }
+            )
+
+        for cat, n in standalone.items():
+            if cat in consumed_standalone:
+                continue
+            out.append(
+                {
+                    "category": cat,
+                    "folder": category_slug_for_pdf_dir(cat),
+                    "count": n,
+                    "children": [],
+                }
+            )
+
+        out.sort(key=lambda x: (-x["count"], x["category"]))
+        return out
+
+    def list_library_categories_by_count(self, limit: int = 80) -> list[str]:
+        limit = int(limit or 0)
+        if limit <= 0:
+            limit = 80
+        rows = self._query(
+            """
+            SELECT COALESCE(NULLIF(TRIM(category), ''), '未分类') AS c, COUNT(*) AS n
+            FROM papers
+            GROUP BY c
+            ORDER BY n DESC, c ASC
+            LIMIT ?
+            """,
+            (limit,),
+        )
+        out: list[str] = []
+        for r in rows:
+            c = (r["c"] or "").strip() or "未分类"
+            if c not in out:
+                out.append(c)
+        return out

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/models/__init__.py


+ 233 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/models/schemas.py

@@ -0,0 +1,233 @@
+"""Pydantic API 模型 —— 请求/响应 schema 定义."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from enum import Enum
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+class PaperSource(str, Enum):
+    ARXIV = "arxiv"
+    OPENALEX = "openalex"
+    DBLP = "dblp"
+    TAVILY = "tavily"
+    UNKNOWN = "unknown"
+
+class ReadStatus(str, Enum):
+    UNREAD = "unread"
+    READING = "reading"
+    READ = "read"
+
+class FeedbackActionEnum(str, Enum):
+
+    CLICK = "click"
+    SAVE = "save"
+    SKIP = "skip"
+    IGNORE = "ignore"
+    READ = "read"
+
+class BaseAPIResponse(BaseModel):
+
+    success: bool
+    message: str | None = None
+
+class Author(BaseModel):
+
+    name: str
+    affiliation: str | None = None
+    email: str | None = None
+    orcid: str | None = None
+    db_id: int | None = Field(default=None, description="本地 authors 表 id,用于区分同名")
+
+class Paper(BaseModel):
+
+    id: int | None = None
+    title: str
+    authors: list[Author] = Field(default_factory=list)
+    abstract: str | None = None
+    doi: str | None = None
+    pmid: str | None = None
+    arxiv_id: str | None = None
+    pmc_id: str | None = None
+    journal: str | None = None
+    venue_type: str | None = Field(default=None, description="会议/期刊类型:conference 或 journal")
+    year: int | None = None
+    volume: str | None = None
+    issue: str | None = None
+    pages: str | None = None
+    publisher: str | None = None
+    pdf_url: str | None = None
+    source_url: str | None = None
+    local_pdf_path: str | None = Field(default=None, description="本地 PDF 相对路径")
+    keywords: list[str] = Field(default_factory=list)
+    mesh_terms: list[str] = Field(default_factory=list)
+    references: list[str] = Field(default_factory=list)
+    citations: int = 0
+    source: PaperSource = PaperSource.UNKNOWN
+    relevance_score: float = 0.0
+    notes: str | None = None
+    tags: list[str] = Field(default_factory=list)
+    category: str | None = Field(
+        default=None,
+        description="文献库领域(保存时由大模型或手写)",
+    )
+    rating: int | None = None
+    read_status: ReadStatus = ReadStatus.UNREAD
+    importance: str = "normal"
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+class PapersResponse(BaseAPIResponse):
+    total: int
+    papers: list[Paper] = Field(default_factory=list)
+
+class LibraryCategoryFolder(BaseModel):
+    category: str
+    folder: str
+    count: int
+    children: list[dict[str, Any]] = Field(default_factory=list)
+
+class LibraryCategoriesResponse(BaseAPIResponse):
+    store_root: str = "文献库"
+    folders: list[LibraryCategoryFolder] = Field(default_factory=list)
+
+class SavePapersRequest(BaseModel):
+    papers: list[Paper]
+    download_pdfs: bool = Field(default=False, description="保存后下载 PDF")
+    llm_classify: bool = Field(default=True, description="大模型划分 category")
+
+class SavePapersResponse(BaseAPIResponse):
+    added: int
+    updated: int = 0
+    ids: list[int] = Field(default_factory=list)
+    pdf_downloaded: int = 0
+    llm_classified: int = 0
+
+class DailyPaperPickHint(BaseModel):
+    identity_key: str = Field(description="身份键,如 arxiv:2401.0001")
+    pick_kind: str = Field(description="personalized | general")
+    explanation: str = Field(default="", description="入选理由")
+
+class DailyPapersRequest(BaseModel):
+    days_back: int = Field(default=5, ge=0, le=30, description="arXiv 最近 N 天")
+    arxiv_max_results: int = Field(default=20, ge=10, le=50, description="arXiv 候选数")
+    arxiv_categories: list[str] | None = Field(default=None, description="arXiv 分类过滤")
+    personalized_k: int = Field(default=20, ge=0, le=40, description="个性化推荐条数")
+    library_limit: int = Field(default=800, ge=50, le=3000, description="库内候选上限")
+    force_refresh: bool = Field(default=False, description="忽略缓存强制刷新")
+    use_llm_rank: bool = Field(default=False, description="是否启用 LLM 精排")
+    rerank_recall_max: int = Field(default=24, ge=8, le=60, description="精排前召回候选上限")
+    use_llm_theme_keywords: bool = Field(default=True, description="LLM 生成主题标签")
+
+class DailyPapersResponse(BaseAPIResponse):
+    date_key: str
+    arxiv_latest_total: int
+    arxiv_selected_total: int
+    personalized_total: int
+    arxiv_latest: list[Paper] = Field(default_factory=list)
+    arxiv_selected: list[Paper] = Field(default_factory=list)
+    personalized: list[Paper] = Field(default_factory=list)
+    memory_keywords_used: list[str] = Field(default_factory=list, description="偏好词摘要")
+    strategy_explanation: str = Field(default="", description="推荐策略摘要(≤2 行中文)")
+    personalized_theme_keywords: list[str] = Field(default_factory=list, description="个性化列表主题标签")
+    general_theme_keywords: list[str] = Field(default_factory=list, description="精选列表主题标签")
+    personalized_pick_hints: list[DailyPaperPickHint] = Field(default_factory=list)
+    general_pick_hints: list[DailyPaperPickHint] = Field(default_factory=list)
+
+class UpdatePaperRequest(BaseModel):
+    notes: str | None = None
+    tags: list[str] | None = None
+    category: str | None = None
+    rating: int | None = None
+    read_status: ReadStatus | None = None
+    importance: str | None = None
+
+class UpdatePaperResponse(BaseAPIResponse):
+    updated_fields: list[str] = Field(default_factory=list)
+
+class DeletePaperResponse(BaseAPIResponse):
+    pass
+
+class GraphNode(BaseModel):
+    id: str
+    type: str
+    label: str
+    paper_id: int | None = None
+    year: int | None = None
+    category: str | None = None
+    journal: str | None = None
+    venue_type: str | None = None
+    weight: float = 1.0
+
+class GraphEdge(BaseModel):
+    source: str
+    target: str
+    type: str
+    weight: float = 1.0
+
+    evidence: str | None = None
+
+class LibraryGraphResponse(BaseAPIResponse):
+    nodes: list[GraphNode] = Field(default_factory=list)
+    edges: list[GraphEdge] = Field(default_factory=list)
+
+class PaperReaderOpeningRequest(BaseModel):
+    paper_id: int = Field(..., ge=1)
+
+class PaperReaderOpeningResponse(BaseAPIResponse):
+    opening: str
+    pdf_parsing: bool = False
+
+class PaperReaderChatRequest(BaseModel):
+    paper_id: int = Field(..., ge=1)
+    messages: list[dict[str, str]] = Field(default_factory=list)
+    user_message: str = Field(..., min_length=1, max_length=12000)
+
+class PaperReaderChatResponse(BaseAPIResponse):
+    reply: str
+    pdf_parsing: bool = False
+    related_papers: list[Paper] = Field(default_factory=list)
+
+    related_hints: list[dict[str, Any]] = Field(default_factory=list)
+
+    kg_edges: list[dict[str, Any]] = Field(default_factory=list)
+
+class PaperReaderHistoryItem(BaseModel):
+    role: str
+    content: str
+    created_at: int
+
+class PaperReaderHistoryResponse(BaseAPIResponse):
+    paper_id: int
+    turns: list[PaperReaderHistoryItem] = Field(default_factory=list)
+
+class ReadingLogRequest(BaseModel):
+    paper_id: int = Field(..., ge=1)
+    duration_sec: int = Field(..., ge=1, le=60 * 60 * 24, description="本次阅读停留时长(秒)")
+    client_ts: int | None = Field(default=None, description="客户端时间戳(秒);缺省则服务端按当前时间落在当天")
+
+class ReadingCalendarItem(BaseModel):
+    date: str = Field(..., description="YYYY-MM-DD")
+    seconds: int = 0
+    sessions: int = 0
+
+class ReadingCalendarResponse(BaseAPIResponse):
+    days: int = 180
+    items: list[ReadingCalendarItem] = Field(default_factory=list)
+
+class DailyRecommendFeedbackRequest(BaseModel):
+
+    identity_key: str = Field(..., description="论文身份标识(如 arxiv:2401.0001 / doi:xxx / title_hash:xxx)")
+    title: str | None = Field(default=None, description="论文标题")
+    action: FeedbackActionEnum = Field(..., description="用户动作")
+    source_list: str | None = Field(default=None, description="推荐来源: personalized 或 general")
+    score_at_recommend: float | None = Field(default=None, description="推荐时的匹配分数")
+    keywords: list[str] | None = Field(default=None, description="论文关键词")
+    category: str | None = Field(default=None, description="论文分类")
+    journal: str | None = Field(default=None, description="论文期刊/会议(用于负反馈建模)")
+    source: str | None = Field(default=None, description="数据源(用于负反馈建模)")
+
+class DailyRecommendFeedbackResponse(BaseAPIResponse):
+    pass

+ 3 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/__init__.py

@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+__all__: list[str] = []

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/__init__.py


+ 115 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_auto_refresh.py

@@ -0,0 +1,115 @@
+"""每日自动刷新 —— 后台定时拉取 arXiv 新论文、智能缓存与用户行为触发."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from typing import TYPE_CHECKING
+
+from ...settings import get_settings
+import contextlib
+
+if TYPE_CHECKING:
+    from fastapi import FastAPI
+
+logger = logging.getLogger(__name__)
+
+_daily_compute_lock: asyncio.Lock | None = None
+
+def get_daily_compute_lock() -> asyncio.Lock:
+    global _daily_compute_lock
+    if _daily_compute_lock is None:
+        _daily_compute_lock = asyncio.Lock()
+    return _daily_compute_lock
+
+_EXCLUDE_MEANINGFUL_PREFIXES: tuple[str, ...] = (
+    "/health",
+    "/api/papers/meta/summary",
+    "/api/papers/reading/calendar",
+)
+
+def request_updates_meaningful_activity(method: str, path: str) -> bool:
+    p = path or ""
+    if p in ("/", "/health"):
+        return False
+    for pref in _EXCLUDE_MEANINGFUL_PREFIXES:
+        if p.startswith(pref):
+            return False
+    return not (method.upper() == "GET" and p.startswith("/api/papers/daily"))
+
+def touch_meaningful_activity_if_needed(app: FastAPI, method: str, path: str) -> None:
+    if not request_updates_meaningful_activity(method, path):
+        return
+    with contextlib.suppress(Exception):
+        app.state.last_meaningful_activity_monotonic = time.monotonic()
+
+async def daily_auto_refresh_loop(app: FastAPI) -> None:
+    s = get_settings()
+    if not s.papergraph_daily_auto_refresh:
+        logger.info("每日论文后台自动刷新已关闭(PAPERGRAPH_DAILY_AUTO_REFRESH=0)")
+        return
+
+    idle = max(15, s.papergraph_daily_auto_refresh_idle_sec)
+    poll = max(30, s.papergraph_daily_auto_refresh_poll_sec)
+    grace = max(10, s.papergraph_daily_auto_refresh_startup_grace_sec)
+    logger.info("每日论文后台自动刷新已启用:idle=%ss poll=%ss startup_grace=%ss", idle, poll, grace)
+    await asyncio.sleep(grace)
+
+    from starlette.concurrency import run_in_threadpool
+    from ...api.dependencies import get_db_path, get_searcher
+    from ...models.schemas import DailyPapersRequest
+    from ...services.daily.daily_cache_store import get_cache
+    from ...services.daily.daily_service import compute_daily_papers as compute_daily
+    import datetime as _dt
+    from ...services.papers.papers_helpers import daily_paper_identity_sig
+
+    def _cache_nonempty(cached) -> bool:
+        if not cached:
+            return False
+        try:
+            return bool(cached.get("arxiv_selected") or []) or bool(cached.get("personalized") or [])
+        except Exception:
+            return False
+
+    lock = get_daily_compute_lock()
+    date_key = _dt.datetime.now().strftime("%Y-%m-%d")
+
+    while True:
+        try:
+            await asyncio.sleep(poll)
+            ts = getattr(app.state, "last_meaningful_activity_monotonic", None)
+            if ts is not None and time.monotonic() - ts < idle:
+                continue
+            db_path = get_db_path()
+            if _cache_nonempty(await run_in_threadpool(get_cache, db_path, date_key=date_key, cache_key='default')):
+                continue
+            if lock.locked():
+                continue
+
+            async with lock:
+                if _cache_nonempty(await run_in_threadpool(get_cache, db_path, date_key=date_key, cache_key='default')):
+                    continue
+                ts2 = getattr(app.state, "last_meaningful_activity_monotonic", None)
+                if ts2 is not None and time.monotonic() - ts2 < idle:
+                    continue
+                settings = get_settings()
+                from ...services.papers import papers_converters
+                body = DailyPapersRequest(force_refresh=False)
+                logger.info("每日论文:后台自动拉取开始(当日无有效缓存且系统空闲)")
+                await compute_daily(
+                    body=body, db_path=db_path, searcher=get_searcher(),
+                    daily_paper_identity_sig_fn=daily_paper_identity_sig,
+                    daily_arxiv_cs_categories=settings.get_daily_arxiv_cs_categories(),
+                    papergraph_to_api_fn=papers_converters.litpaper_to_api_paper,
+                    logger=logger,
+                )
+                logger.info("每日论文:后台自动拉取完成")
+        except asyncio.CancelledError:
+            logger.info("每日论文后台自动刷新任务已取消")
+            raise
+        except Exception:
+            logger.exception("每日论文后台自动拉取失败(将按 poll 间隔重试)")
+
+def spawn_daily_auto_refresh(app: FastAPI) -> asyncio.Task:
+    return asyncio.create_task(daily_auto_refresh_loop(app), name="papergraph_daily_auto_refresh")

+ 73 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_cache_store.py

@@ -0,0 +1,73 @@
+"""每日缓存存储 —— arXiv 原始数据本地缓存以避免重复拉取."""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import time
+from typing import Any
+
+from ...utils.common import exec_sql
+
+def ensure_tables(db_path: str) -> None:
+    exec_sql(db_path,
+        """CREATE TABLE IF NOT EXISTS daily_papers_cache (
+          date_key TEXT NOT NULL,
+          cache_key TEXT NOT NULL,
+          payload_json TEXT NOT NULL,
+          created_at INTEGER NOT NULL,
+          updated_at INTEGER NOT NULL,
+          hit_count INTEGER DEFAULT 0,
+          PRIMARY KEY (date_key, cache_key)
+        )""",
+    )
+
+def get_cache(db_path: str, *, date_key: str, cache_key: str) -> dict[str, Any | None]:
+    ensure_tables(db_path)
+    conn = sqlite3.connect(db_path)
+    try:
+        cur = conn.cursor()
+        cur.execute(
+            "SELECT payload_json FROM daily_papers_cache WHERE date_key=? AND cache_key=?",
+            (str(date_key), str(cache_key)),
+        )
+        row = cur.fetchone()
+        if not row:
+            return None
+        raw = row[0] or ""
+        try:
+            data = json.loads(raw)
+        except Exception:
+            data = None
+
+        try:
+            cur.execute(
+                "UPDATE daily_papers_cache SET hit_count=hit_count+1, updated_at=? WHERE date_key=? AND cache_key=?",
+                (int(time.time()), str(date_key), str(cache_key)),
+            )
+            conn.commit()
+        except Exception:
+            pass
+        return data if isinstance(data, dict) else None
+    finally:
+        conn.close()
+
+def set_cache(db_path: str, *, date_key: str, cache_key: str, payload: dict[str, Any]) -> None:
+    ensure_tables(db_path)
+    now = int(time.time())
+    conn = sqlite3.connect(db_path)
+    try:
+        cur = conn.cursor()
+        cur.execute(
+            """
+            INSERT INTO daily_papers_cache(date_key, cache_key, payload_json, created_at, updated_at, hit_count)
+            VALUES(?,?,?,?,?,?)
+            ON CONFLICT(date_key, cache_key) DO UPDATE SET
+              payload_json=excluded.payload_json,
+              updated_at=excluded.updated_at
+            """,
+            (str(date_key), str(cache_key), json.dumps(payload, ensure_ascii=False), now, now, 0),
+        )
+        conn.commit()
+    finally:
+        conn.close()

+ 292 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_recommend_feedback.py

@@ -0,0 +1,292 @@
+"""推荐反馈收集 —— 用户对每日推荐的评分与偏好记录."""
+
+from __future__ import annotations
+
+import contextlib
+import re
+import sqlite3
+import time
+from collections import Counter
+
+from ...models.schemas import FeedbackActionEnum as FeedbackAction
+from ...utils.common import exec_sql
+
+@contextlib.contextmanager
+def _conn(db_path: str):
+    ensure_tables(db_path)
+    conn = sqlite3.connect(db_path)
+    try:
+        yield conn
+        conn.commit()
+    finally:
+        conn.close()
+
+def ensure_tables(db_path: str) -> None:
+    exec_sql(db_path,
+        """CREATE TABLE IF NOT EXISTS daily_recommend_feedback (
+          id INTEGER PRIMARY KEY AUTOINCREMENT,
+          date_key TEXT NOT NULL,
+          paper_identity_key TEXT NOT NULL,
+          identity_type TEXT NOT NULL,
+          title TEXT,
+          action TEXT NOT NULL,
+          source_list TEXT,
+          score_at_recommend REAL,
+          created_at INTEGER NOT NULL
+        )""",
+        """CREATE TABLE IF NOT EXISTS paper_impressions (
+          paper_identity_key TEXT PRIMARY KEY,
+          identity_type TEXT NOT NULL,
+          title TEXT,
+          first_seen_date TEXT NOT NULL,
+          last_seen_date TEXT NOT NULL,
+          total_impressions INTEGER DEFAULT 0,
+          clicks INTEGER DEFAULT 0,
+          saves INTEGER DEFAULT 0,
+          skips INTEGER DEFAULT 0,
+          reads INTEGER DEFAULT 0,
+          ctr REAL DEFAULT 0.0,
+          save_rate REAL DEFAULT 0.0,
+          skip_rate REAL DEFAULT 0.0,
+          updated_at INTEGER NOT NULL
+        )""",
+        """CREATE TABLE IF NOT EXISTS user_interest_evolution (
+          id INTEGER PRIMARY KEY AUTOINCREMENT,
+          date_key TEXT NOT NULL,
+          keyword TEXT NOT NULL,
+          category TEXT,
+          interaction_weight REAL DEFAULT 0.0,
+          source TEXT,
+          created_at INTEGER NOT NULL,
+          UNIQUE(date_key, keyword, category, source)
+        )""",
+        "CREATE INDEX IF NOT EXISTS idx_feedback_date ON daily_recommend_feedback(date_key, created_at)",
+        "CREATE INDEX IF NOT EXISTS idx_feedback_paper ON daily_recommend_feedback(paper_identity_key, identity_type)",
+        "CREATE INDEX IF NOT EXISTS idx_feedback_action ON daily_recommend_feedback(action)",
+        "CREATE INDEX IF NOT EXISTS idx_interest_date ON user_interest_evolution(date_key)",
+        "CREATE INDEX IF NOT EXISTS idx_interest_kw ON user_interest_evolution(keyword, date_key)",
+    )
+
+def record_feedback(
+    db_path: str,
+    *,
+    date_key: str,
+    paper_identity_key: str,
+    identity_type: str,
+    title: str | None = None,
+    action: FeedbackAction,
+    source_list: str | None = None,
+    score_at_recommend: float | None = None,
+    keywords: list[str | None] = None,
+    category: str | None = None,
+) -> bool:
+    try:
+        now = int(time.time())
+        with _conn(db_path) as conn:
+            cur = conn.cursor()
+            cur.execute(
+                """INSERT INTO daily_recommend_feedback(date_key,paper_identity_key,identity_type,title,action,source_list,score_at_recommend,created_at)
+                VALUES(?,?,?,?,?,?,?,?)""",
+                (str(date_key), str(paper_identity_key), str(identity_type),
+                 (title or "")[:400] if title else None, str(action.value),
+                 source_list, float(score_at_recommend) if score_at_recommend is not None else None, now),
+            )
+            _update_impression_stats(cur, paper_identity_key, identity_type, title or "", date_key, action, now)
+            if keywords:
+                weight = _action_to_weight(action)
+                for kw in keywords[:8]:
+                    if kw and len(kw.strip()) >= 3:
+                        _upsert_interest_evolution(cur, date_key, kw.strip(), category, weight, "feedback", now)
+        return True
+    except Exception as e:
+        import logging
+
+        logging.getLogger(__name__).warning(f"记录推荐反馈失败: {e}")
+        return False
+
+def _action_to_weight(action: FeedbackAction) -> float:
+    weights = {
+        FeedbackAction.SAVE: 3.0,
+        FeedbackAction.READ: 2.5,
+        FeedbackAction.CLICK: 1.5,
+        FeedbackAction.SKIP: -1.0,
+        FeedbackAction.IGNORE: 0.0,
+    }
+    return weights.get(action, 0.0)
+
+def _update_impression_stats(
+    cur: sqlite3.Cursor,
+    paper_identity_key: str,
+    identity_type: str,
+    title: str,
+    date_key: str,
+    action: FeedbackAction,
+    now: int,
+) -> None:
+    cur.execute(
+        """
+        SELECT total_impressions, clicks, saves, skips, reads
+        FROM paper_impressions WHERE paper_identity_key = ?
+        """,
+        (str(paper_identity_key),),
+    )
+    row = cur.fetchone()
+
+    if row:
+        total, clicks, saves, skips, reads = row
+        total = (total or 0) + 1
+        clicks = (clicks or 0) + (1 if action == FeedbackAction.CLICK else 0)
+        saves = (saves or 0) + (1 if action == FeedbackAction.SAVE else 0)
+        skips = (skips or 0) + (1 if action == FeedbackAction.SKIP else 0)
+        reads = (reads or 0) + (1 if action == FeedbackAction.READ else 0)
+    else:
+        total, clicks, saves, skips, reads = 1, 0, 0, 0, 0
+        if action == FeedbackAction.CLICK:
+            clicks = 1
+        elif action == FeedbackAction.SAVE:
+            saves = 1
+        elif action == FeedbackAction.SKIP:
+            skips = 1
+        elif action == FeedbackAction.READ:
+            reads = 1
+
+    ctr = clicks / total if total > 0 else 0.0
+    save_rate = saves / total if total > 0 else 0.0
+    skip_rate = skips / total if total > 0 else 0.0
+
+    cur.execute(
+        """
+        INSERT INTO paper_impressions
+        (paper_identity_key, identity_type, title, first_seen_date, last_seen_date,
+         total_impressions, clicks, saves, skips, reads, ctr, save_rate, skip_rate, updated_at)
+        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        ON CONFLICT(paper_identity_key) DO UPDATE SET
+          title = excluded.title,
+          last_seen_date = excluded.last_seen_date,
+          total_impressions = excluded.total_impressions,
+          clicks = excluded.clicks,
+          saves = excluded.saves,
+          skips = excluded.skips,
+          reads = excluded.reads,
+          ctr = excluded.ctr,
+          save_rate = excluded.save_rate,
+          skip_rate = excluded.skip_rate,
+          updated_at = excluded.updated_at
+        """,
+        (
+            str(paper_identity_key),
+            str(identity_type),
+            title[:400] if title else "",
+            str(date_key),
+            str(date_key),
+            total,
+            clicks,
+            saves,
+            skips,
+            reads,
+            ctr,
+            save_rate,
+            skip_rate,
+            now,
+        ),
+    )
+
+def _upsert_interest_evolution(
+    cur: sqlite3.Cursor,
+    date_key: str,
+    keyword: str,
+    category: str | None,
+    weight: float,
+    source: str,
+    now: int,
+) -> None:
+    cur.execute(
+        """
+        INSERT INTO user_interest_evolution (date_key, keyword, category, interaction_weight, source, created_at)
+        VALUES (?, ?, ?, ?, ?, ?)
+        ON CONFLICT(date_key, keyword, category, source) DO UPDATE SET
+          interaction_weight = interaction_weight + excluded.interaction_weight,
+          created_at = excluded.created_at
+        """,
+        (str(date_key), keyword.lower(), category, weight, source, now),
+    )
+
+def get_skipped_papers(
+    db_path: str,
+    *,
+    days: int = 30,
+    include_shown: bool = True,
+) -> set[str]:
+    cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
+    actions = ("skip", "shown") if include_shown else ("skip",)
+    placeholders = ",".join("?" for _ in actions)
+    with _conn(db_path) as conn:
+        cur = conn.cursor()
+        cur.execute(
+            f"SELECT DISTINCT paper_identity_key FROM daily_recommend_feedback "
+            f"WHERE date_key>=? AND action IN ({placeholders})",
+            (cutoff, *actions),
+        )
+        skipped = {str(row[0]) for row in cur.fetchall()}
+    return skipped
+
+
+def clear_daily_shown_for_date(db_path: str, date_key: str) -> int:
+    """手动刷新时清除当日 shown 记录,避免候选池被永久锁死。"""
+    with _conn(db_path) as conn:
+        cur = conn.cursor()
+        cur.execute(
+            "DELETE FROM daily_recommend_feedback WHERE date_key=? AND action='shown'",
+            (str(date_key),),
+        )
+        return int(cur.rowcount or 0)
+
+def record_daily_shown_papers(
+    db_path: str,
+    date_key: str,
+    papers: list[dict[str, str]],
+) -> None:
+    if not papers:
+        return
+    now = int(time.time())
+    with _conn(db_path) as conn:
+        conn.cursor().executemany(
+            """INSERT OR IGNORE INTO daily_recommend_feedback(date_key,paper_identity_key,identity_type,title,action,source_list,score_at_recommend,created_at)
+            VALUES(?,?,'title_hash',?,'shown','daily',0.0,?)""",
+            [(date_key, p.get("identity_key", ""), p.get("title", ""), now) for p in papers],
+        )
+
+def get_high_value_keywords_from_feedback(
+    db_path: str,
+    *,
+    days: int = 21,
+    top_n: int = 20,
+) -> set[str]:
+    cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - days * 86400))
+    with _conn(db_path) as conn:
+        cur = conn.cursor()
+        cur.execute(
+            "SELECT title FROM daily_recommend_feedback WHERE date_key>=? AND action IN ('click','save','read') AND title IS NOT NULL ORDER BY created_at DESC LIMIT 200",
+            (cutoff,),
+        )
+        titles = [str(row[0]) for row in cur.fetchall() if row[0]]
+
+    def _extract_tokens(text: str) -> list[str]:
+        t = (text or "").lower()
+        t = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", t)
+        tokens = [x.strip() for x in t.split() if x.strip()]
+        stop = {
+            "the", "a", "an", "and", "or", "of", "to", "in", "for", "with", "on",
+            "we", "our", "is", "are", "be", "via", "from", "this", "that",
+            "using", "use", "based", "towards", "paper", "propose", "method",
+            "learning", "network", "model", "deep", "neural",
+        }
+        return [x for x in tokens if x not in stop and len(x) >= 4][:50]
+
+    all_tokens = []
+    for t in titles:
+        all_tokens.extend(_extract_tokens(t))
+
+    freq = Counter(all_tokens)
+    top_keywords = {w for w, _ in freq.most_common(top_n)}
+    return top_keywords

+ 52 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_recommend_store.py

@@ -0,0 +1,52 @@
+"""每日推荐持久化 —— 推荐结果存储、去重与历史查询."""
+
+from __future__ import annotations
+
+import sqlite3
+import time
+from collections.abc import Iterable
+
+from ...utils.common import normalize_arxiv_id as _norm_arxiv_id
+from ...utils.common import exec_sql
+
+def ensure_tables(db_path: str) -> None:
+    exec_sql(db_path,
+        """CREATE TABLE IF NOT EXISTS daily_recommendations (
+          id INTEGER PRIMARY KEY AUTOINCREMENT,
+          date_key TEXT NOT NULL,
+          source TEXT NOT NULL,
+          arxiv_id TEXT,
+          title TEXT,
+          created_at INTEGER NOT NULL
+        )""",
+        "CREATE INDEX IF NOT EXISTS idx_daily_reco_date ON daily_recommendations(date_key, created_at)",
+        "CREATE INDEX IF NOT EXISTS idx_daily_reco_arxiv ON daily_recommendations(source, arxiv_id)",
+    )
+
+def record_arxiv_recommendations(
+    db_path: str,
+    *,
+    date_key: str,
+    items: Iterable[tuple[str | None, str]],
+) -> int:
+    ensure_tables(db_path)
+    now = int(time.time())
+    conn = sqlite3.connect(db_path)
+    try:
+        cur = conn.cursor()
+        n = 0
+        for arxiv_id, title in items:
+            aid = _norm_arxiv_id(arxiv_id)
+            t = (title or "").strip()
+            cur.execute(
+                """
+                INSERT INTO daily_recommendations(date_key, source, arxiv_id, title, created_at)
+                VALUES(?,?,?,?,?)
+                """,
+                (str(date_key), "arxiv", aid, t[:400] if t else None, int(now)),
+            )
+            n += 1
+        conn.commit()
+        return n
+    finally:
+        conn.close()

+ 567 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_service.py

@@ -0,0 +1,567 @@
+"""每日论文推荐服务 —— arXiv 新论文拉取、个性化推荐与缓存管理."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+from typing import Any
+
+from fastapi import HTTPException
+from fastapi.responses import Response
+from starlette.concurrency import run_in_threadpool
+
+from ...agents import get_search_agent
+from ...core.storage import PaperDatabase
+from ...models.schemas import DailyPapersRequest, DailyPapersResponse
+from ..daily.daily_cache_store import get_cache, set_cache
+from ..daily.daily_recommend_store import record_arxiv_recommendations
+from ..daily.daily_recommend_feedback import record_feedback
+from ..daily.daily_support import (
+    extract_library_characteristics,
+    fetch_external_candidates,
+    get_or_load_user_context,
+    llm_arxiv_categories,
+)
+from ..feedback.negative_feedback_memory import (
+    maybe_promote_longterm_from_recent_skips,
+    record_skip_negative_pref,
+)
+from ..llm.llm_service import coerce_hello_agents_llm_output_to_str, get_llm, is_llm_configured
+from ..retrieval.recall_jobs import dedupe_papers
+
+logger = logging.getLogger(__name__)
+
+
+def _select_personalized_and_general(
+    *,
+    candidates: list[Any],
+    external_unique: list[Any],
+    personalized_k: int,
+    general_k: int,
+    skipped_papers: set[str],
+    mem_kw: set[str],
+    daily_paper_identity_sig_fn: Any,
+    diversify: bool = False,
+) -> tuple[list[Any], list[Any]]:
+    _identity = daily_paper_identity_sig_fn
+    skip_sigs = set(skipped_papers or set())
+    pool = [p for p in (candidates or []) if _identity(p) not in skip_sigs]
+    if len(pool) < personalized_k + general_k:
+        for p in external_unique or []:
+            if _identity(p) not in skip_sigs:
+                pool.append(p)
+    if not pool:
+        return [], []
+
+    if diversify:
+        import random
+
+        random.shuffle(pool)
+    else:
+        pool.sort(key=lambda p: getattr(p, "year", 0) or 0, reverse=True)
+    p_idxs = list(range(min(personalized_k, len(pool))))
+    g_idxs = list(range(min(personalized_k, len(pool)), min(personalized_k + general_k, len(pool))))
+    try:
+        if is_llm_configured():
+            papers_info = [
+                {
+                    "idx": i,
+                    "title": str(getattr(p, "title", "") or "")[:200],
+                    "abstract": str(getattr(p, "abstract", "") or "")[:400],
+                    "year": getattr(p, "year", None),
+                }
+                for i, p in enumerate(pool)
+            ]
+            pref_keywords = sorted({str(k).strip().lower() for k in (mem_kw or set()) if str(k).strip()})[:30]
+            prompt = json.dumps(
+                {
+                    "task": f"Select {personalized_k} personalized and {general_k} exploration papers",
+                    "interests": pref_keywords,
+                    "candidates": papers_info,
+                },
+                ensure_ascii=False,
+            )
+            pick_temp = 0.55 if diversify else 0.2
+            raw = coerce_hello_agents_llm_output_to_str(
+                get_llm().invoke([{"role": "user", "content": prompt}], temperature=pick_temp, max_tokens=400)
+            )
+            data = json.loads(raw.strip().lstrip("```json").rstrip("```").strip())
+            llm_p = [int(i) for i in (data.get("personalized") or [])[:personalized_k] if 0 <= int(i) < len(pool)]
+            llm_g = [
+                int(i)
+                for i in (data.get("general") or [])[:general_k]
+                if 0 <= int(i) < len(pool) and int(i) not in llm_p
+            ]
+            if llm_p or llm_g:
+                p_idxs, g_idxs = llm_p, llm_g
+    except Exception:
+        pass
+    return [pool[i] for i in p_idxs], [pool[i] for i in g_idxs]
+
+
+async def _record_daily_recommendations(
+    *,
+    db_path: str,
+    date_key: str,
+    personalized_final: list[Any],
+    general_selected: list[Any],
+    log: Any,
+) -> None:
+    try:
+        items: list[tuple[str | None, str]] = []
+        for p in personalized_final:
+            items.append((getattr(p, "arxiv_id", None), f"[P] {getattr(p, 'title', '') or ''}"))
+        for p in general_selected:
+            items.append((getattr(p, "arxiv_id", None), f"[G] {getattr(p, 'title', '') or ''}"))
+        if items:
+            await run_in_threadpool(
+                record_arxiv_recommendations, db_path, date_key=str(date_key), items=items,
+            )
+    except Exception as ex:
+        log.debug("记录推荐列表失败: %s", ex)
+
+
+_MAX_TITLES = 18
+_TITLE_MAX_CHARS = 220
+_TAG_MAX_LEN = 28
+_TAGS_MAX_EACH = 5
+
+
+def titles_for_daily_theme_prompt(papers: list[Any]) -> list[str]:
+    out: list[str] = []
+    for p in papers[:_MAX_TITLES]:
+        t = str(getattr(p, "title", None) or "").strip()
+        if not t:
+            continue
+        if len(t) > _TITLE_MAX_CHARS:
+            t = t[: _TITLE_MAX_CHARS - 1] + "\u2026"
+        out.append(t)
+    return out
+
+
+def _strip_json_fence(text: str) -> str:
+    s = (text or "").strip()
+    m = re.search(r"```(?:json)?\s*([\s\S]*?)```", s, re.I)
+    return m.group(1).strip() if m else s
+
+
+def _sanitize_tags(raw: Any, *, max_n: int) -> list[str]:
+    if not isinstance(raw, list):
+        return []
+    seen, out = set(), []
+    for x in raw:
+        if not isinstance(x, str):
+            continue
+        s = " ".join(x.split())
+        if len(s) < 2 or len(s) > _TAG_MAX_LEN or s.casefold() in seen:
+            continue
+        seen.add(s.casefold())
+        out.append(s)
+        if len(out) >= max_n:
+            break
+    return out
+
+
+def summarize_daily_theme_keywords_sync(
+    *,
+    personalized_titles: list[str],
+    general_titles: list[str],
+    max_each: int = _TAGS_MAX_EACH,
+) -> tuple[list[str], list[str]]:
+    if not is_llm_configured() or (not personalized_titles and not general_titles):
+        return [], []
+    try:
+        llm = get_llm()
+    except Exception as e:
+        logger.warning("daily_theme_keywords: llm init failed: %s", e)
+        return [], []
+
+    lines_p = "\n".join(f"- {t}" for t in personalized_titles) or "\uff08\u65e0\uff09"
+    lines_g = "\n".join(f"- {t}" for t in general_titles) or "\uff08\u65e0\uff09"
+    prompt = f"""\u4f60\u662f\u5b66\u672f\u6587\u732e\u63a8\u8350\u4ea7\u54c1\u7684\u6587\u6848\u52a9\u624b\u3002\u4e0b\u9762\u4e24\u7ec4\u6807\u9898\u5206\u522b\u6765\u81ea\u300c\u4e2a\u6027\u5316\u63a8\u8350\u300d\u4e0e\u300c\u5f53\u65e5\u7cbe\u9009\uff08\u968f\u673a\u63a2\u7d22\uff09\u300d\u4e24\u680f\u8bba\u6587\u3002
+
+\u3010\u4e2a\u6027\u5316\u63a8\u8350\u3011\u9898\u540d\uff1a
+{lines_p}
+
+\u3010\u5f53\u65e5\u7cbe\u9009\u3011\u9898\u540d\uff1a
+{lines_g}
+
+\u8bf7\u4e3a\u6bcf\u4e00\u7ec4\u5404\u63d0\u70bc\u4e0d\u8d85\u8fc7 {max_each} \u4e2a\u300c\u4e3b\u9898\u6807\u7b7e\u300d\uff0c\u7528\u4e8e\u754c\u9762\u6807\u7b7e\u5c55\u793a\u3002
+\u89c4\u5219\uff1a
+1. \u6bcf\u4e2a\u6807\u7b7e 2\uff5e14 \u4e2a\u6c49\u5b57\u6216\u76f8\u5f53\u957f\u5ea6\uff1b\u53ef\u542b\u5fc5\u8981\u82f1\u6587\u7f29\u7565\u8bcd\uff08\u5982 LLM\u3001RL\u3001GNN\uff09\uff1b\u4e0d\u8981\u6574\u53e5\u590d\u5236\u539f\u6807\u9898\u3002
+2. \u6982\u62ec\u8be5\u7ec4\u5171\u540c\u7684\u7814\u7a76\u65b9\u5411\u6216\u65b9\u6cd5\uff1b\u7ec4\u5185\u6807\u7b7e\u5c3d\u91cf\u4e0d\u91cd\u590d\u3002
+3. \u53ea\u8f93\u51fa\u4e00\u6bb5\u4e25\u683c JSON\uff08\u4e0d\u8981 markdown \u56f4\u680f\u3001\u4e0d\u8981\u524d\u540e\u8bf4\u660e\uff09\uff0c\u683c\u5f0f\u56fa\u5b9a\u4e3a\uff1a
+{{\\"personalized\\":[\\"\u2026\\"],\\"general\\":[\\"\u2026\\"]}}
+\u952e\u540d\u5fc5\u987b\u4e3a\u82f1\u6587\uff1b\u82e5\u67d0\u7ec4\u65e0\u6709\u6548\u6807\u9898\u5219\u5bf9\u5e94\u6570\u7ec4\u4e3a []\u3002"""
+    try:
+        raw = coerce_hello_agents_llm_output_to_str(
+            llm.invoke([{"role": "user", "content": prompt}], temperature=0.15, max_tokens=420)
+        )
+    except Exception as e:
+        logger.warning("daily_theme_keywords: invoke failed: %s", e)
+        return [], []
+
+    try:
+        data = json.loads(_strip_json_fence(raw))
+    except json.JSONDecodeError:
+        logger.warning("daily_theme_keywords: json parse failed, head=%r", (raw or "")[:240])
+        return [], []
+    if not isinstance(data, dict):
+        return [], []
+    return (
+        _sanitize_tags(data.get("personalized"), max_n=max_each),
+        _sanitize_tags(data.get("general"), max_n=max_each),
+    )
+
+
+def _build_strategy_explanation(
+    *, agent: Any, n_personalized: int, n_general: int, n_candidates: int, n_memory_kw: int,
+) -> str:
+    fallback = f"个性化{n_personalized}篇 · 通用{n_general}篇 · 候选{n_candidates}篇"
+    if not is_llm_configured():
+        return fallback
+    prompt = (
+        f"用一句话中文概括今日论文推荐策略:个性化{n_personalized}篇、通用{n_general}篇,"
+        f"候选池{n_candidates}篇,记忆词{n_memory_kw}条参与"
+    )
+    try:
+        raw = agent.llm.invoke([{"role": "user", "content": prompt}], temperature=0.3, max_tokens=80)
+        return coerce_hello_agents_llm_output_to_str(raw).strip()[:200]
+    except Exception:
+        return fallback
+
+
+def _build_pick_hints(personalized_final, general_selected, _identity):
+    from ...models.schemas import DailyPaperPickHint
+
+    hints_p = [
+        DailyPaperPickHint(identity_key=_identity(p), pick_kind="personalized", explanation="基于用户兴趣匹配")
+        for p in personalized_final
+    ]
+    hints_g = [
+        DailyPaperPickHint(identity_key=_identity(p), pick_kind="general", explanation="多样性探索推荐")
+        for p in general_selected
+    ]
+    return hints_p, hints_g
+
+
+async def _build_daily_response(
+    *,
+    date_key,
+    external_unique,
+    general_selected,
+    personalized_final,
+    candidates,
+    personalized_k,
+    mem_kw_n,
+    mem_kw_list,
+    use_llm_theme_keywords,
+    agent,
+    daily_paper_identity_sig_fn,
+    papergraph_to_api_fn,
+    db_path,
+) -> DailyPapersResponse:
+    _to_api = papergraph_to_api_fn
+    _identity = daily_paper_identity_sig_fn
+    strategy_explanation = _build_strategy_explanation(
+        agent=agent,
+        n_personalized=len(personalized_final),
+        n_general=len(general_selected),
+        n_candidates=len(candidates),
+        n_memory_kw=mem_kw_n,
+    )
+    hints_p, hints_g = _build_pick_hints(personalized_final, general_selected, _identity)
+
+    p_theme: list[str] = []
+    g_theme: list[str] = []
+    if use_llm_theme_keywords:
+        try:
+            p_theme, g_theme = await run_in_threadpool(
+                summarize_daily_theme_keywords_sync,
+                personalized_titles=titles_for_daily_theme_prompt(personalized_final),
+                general_titles=titles_for_daily_theme_prompt(general_selected),
+            )
+        except Exception as ex:
+            logger.warning("每日主题关键词 LLM 总结失败: %s", ex)
+
+    resp = DailyPapersResponse(
+        success=True,
+        date_key=date_key,
+        arxiv_latest_total=len(external_unique),
+        arxiv_selected_total=len(general_selected),
+        personalized_total=len(personalized_final),
+        arxiv_latest=[_to_api(p) for p in external_unique[:20]],
+        arxiv_selected=[_to_api(p) for p in general_selected],
+        personalized=[_to_api(p) for p in personalized_final],
+        message=(
+            f"候选 {len(candidates)} · 个性化 {len(personalized_final)}/{personalized_k} · "
+            f"通用 {len(general_selected)}"
+            + (f" · 记忆词 {mem_kw_n}" if mem_kw_n else "")
+        ),
+        memory_keywords_used=mem_kw_list,
+        strategy_explanation=strategy_explanation,
+        personalized_theme_keywords=p_theme,
+        general_theme_keywords=g_theme,
+        personalized_pick_hints=hints_p,
+        general_pick_hints=hints_g,
+    )
+
+    try:
+        from .daily_recommend_feedback import record_daily_shown_papers
+
+        shown: list[dict[str, str]] = []
+        for p in (resp.arxiv_selected or []) + (resp.personalized or []):
+            title = (getattr(p, "title", None) or "").strip()
+            if not title and isinstance(p, dict):
+                title = str(p.get("title", "") or "").strip()
+            if title:
+                shown.append({"identity_key": str(_identity(p)), "title": title})
+        if shown:
+            logger.info("记录 %d 篇已推荐论文,下次刷新将排除", len(shown))
+            await run_in_threadpool(record_daily_shown_papers, str(db_path), date_key, shown)
+    except Exception as ex:
+        logger.warning("记录已推荐论文失败: %s", ex)
+
+    try:
+        await run_in_threadpool(
+            set_cache, db_path, date_key=date_key, cache_key="default", payload=resp.model_dump(mode="json")
+        )
+    except Exception as ex:
+        logger.warning("每日论文缓存写入失败: %s", ex)
+
+    return resp
+
+
+async def read_daily_cached_or_204(*, db_path: str) -> Response:
+    import datetime as _dt
+
+    date_key = _dt.datetime.now().strftime("%Y-%m-%d")
+    try:
+        cached = await run_in_threadpool(get_cache, db_path, date_key=date_key, cache_key="default")
+        if not cached:
+            return Response(status_code=204)
+        return DailyPapersResponse(**cached)
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+async def compute_daily_papers(
+    *,
+    body: DailyPapersRequest,
+    db_path: str,
+    searcher: Any,
+    daily_paper_identity_sig_fn: Any,
+    logger: Any,
+    daily_arxiv_cs_categories: list[str],
+    papergraph_to_api_fn: Any,
+) -> DailyPapersResponse:
+    _identity = daily_paper_identity_sig_fn
+    try:
+        import datetime
+
+        date_key = datetime.datetime.now().strftime("%Y-%m-%d")
+        force_refresh = bool(getattr(body, "force_refresh", False))
+        try:
+            _dbv = int(body.days_back if body.days_back is not None else 0)
+        except (TypeError, ValueError):
+            _dbv = 0
+        days_back = max(0, min(9999, _dbv)) if _dbv > 0 else 1
+        total_target = 30
+        try:
+            personalized_k = max(0, min(int(body.personalized_k if body.personalized_k is not None else 20), total_target))
+        except (TypeError, ValueError):
+            personalized_k = 20
+        general_k = max(0, min(25, total_target - personalized_k))
+
+        agent = get_search_agent()
+        try:
+            lib_lim = max(50, min(3000, int(body.library_limit if body.library_limit is not None else 800)))
+        except (TypeError, ValueError):
+            lib_lim = 800
+
+        logger.info(
+            "每日论文:开始计算 date=%s force_refresh=%s lib_limit=%s",
+            date_key, force_refresh, lib_lim,
+        )
+        if force_refresh:
+            from .daily_support import invalidate_user_profile_cache
+            from .daily_recommend_feedback import clear_daily_shown_for_date
+
+            invalidate_user_profile_cache()
+            cleared = await run_in_threadpool(clear_daily_shown_for_date, str(db_path), date_key)
+            if cleared:
+                logger.info("每日论文:force_refresh 已清除当日 shown 记录 %s 条", cleared)
+
+        library_papers = await run_in_threadpool(
+            PaperDatabase(db_path).get_all_papers, limit=lib_lim, order_by="created_at DESC",
+        )
+        lib_ids = [int(getattr(p, "id", 0) or 0) for p in library_papers if int(getattr(p, "id", 0) or 0) > 0]
+
+        (mem_kw, mem_kw_n, mem_kw_list, skipped_papers), (_, lib_kw) = await asyncio.gather(
+            get_or_load_user_context(
+                db_path=db_path,
+                lib_ids=lib_ids,
+                log=logger,
+                force_reload=force_refresh,
+                include_shown_exclusions=not force_refresh,
+            ),
+            run_in_threadpool(extract_library_characteristics, library_papers),
+        )
+        llm_categories = llm_arxiv_categories(agent, mem_kw_list, daily_arxiv_cs_categories)
+
+        all_external, source_counts, _arxiv_query = await fetch_external_candidates(
+            searcher=searcher,
+            mem_kw=mem_kw,
+            lib_kw=lib_kw,
+            days_back=days_back,
+            daily_arxiv_cs_categories=llm_categories,
+            log=logger,
+            exclude_sigs=skipped_papers,
+        )
+        logger.info(
+            "每日论文:外源拉取完成 arxiv=%s external_raw=%s",
+            source_counts.get("arxiv"),
+            len(all_external),
+        )
+        external_unique = [
+            p for p in dedupe_papers(all_external, identity_fn=_identity) if getattr(p, "title", None)
+        ]
+        candidates = list(external_unique)
+
+        if len(candidates) < 30:
+            logger.info("每日论文:候选不足(%s),放宽条件重新拉取", len(candidates))
+            all_external2, _, _ = await fetch_external_candidates(
+                searcher=searcher,
+                mem_kw=mem_kw,
+                lib_kw=lib_kw,
+                days_back=max(7, days_back * 2),
+                daily_arxiv_cs_categories=llm_categories,
+                log=logger,
+                exclude_sigs=skipped_papers,
+            )
+            seen_sigs = {_identity(p) for p in candidates}
+            for p in dedupe_papers(all_external2, identity_fn=_identity):
+                if getattr(p, "title", None) and _identity(p) not in seen_sigs:
+                    seen_sigs.add(_identity(p))
+                    candidates.append(p)
+            logger.info("每日论文:补充拉取后候选=%s", len(candidates))
+
+        if not candidates:
+            return DailyPapersResponse(
+                success=True,
+                date_key=date_key,
+                arxiv_latest_total=0,
+                arxiv_selected_total=0,
+                personalized_total=0,
+                arxiv_latest=[],
+                arxiv_selected=[],
+                personalized=[],
+                message="暂无可用论文推荐",
+                memory_keywords_used=mem_kw_list,
+                strategy_explanation="\n".join(
+                    [
+                        "候选不足,未形成当日推荐池",
+                        (f"记忆词约 {mem_kw_n} 个(下列为短词优先)" if mem_kw_n else "记忆词:无"),
+                    ]
+                ),
+                personalized_pick_hints=[],
+                general_pick_hints=[],
+            )
+
+        personalized_final, general_selected = await run_in_threadpool(
+            _select_personalized_and_general,
+            candidates=candidates,
+            external_unique=external_unique,
+            personalized_k=personalized_k,
+            general_k=general_k,
+            skipped_papers=skipped_papers,
+            mem_kw=mem_kw,
+            daily_paper_identity_sig_fn=_identity,
+            diversify=force_refresh,
+        )
+
+        await _record_daily_recommendations(
+            db_path=db_path,
+            date_key=date_key,
+            personalized_final=personalized_final,
+            general_selected=general_selected,
+            log=logger,
+        )
+
+        return await _build_daily_response(
+            date_key=date_key,
+            external_unique=external_unique,
+            general_selected=general_selected,
+            personalized_final=personalized_final,
+            candidates=candidates,
+            personalized_k=personalized_k,
+            mem_kw_n=mem_kw_n,
+            mem_kw_list=mem_kw_list,
+            use_llm_theme_keywords=getattr(body, "use_llm_theme_keywords", True),
+            agent=agent,
+            daily_paper_identity_sig_fn=_identity,
+            papergraph_to_api_fn=papergraph_to_api_fn,
+            db_path=db_path,
+        )
+    except Exception:
+        logger.exception("daily_service.compute_daily_papers_failed")
+        raise HTTPException(status_code=500, detail="daily papers failed")
+
+
+async def record_user_daily_feedback(*, body, db_path) -> Any:
+    import datetime
+    from .daily_recommend_feedback import FeedbackAction
+    from ...models.schemas import DailyRecommendFeedbackResponse
+
+    date_key = datetime.datetime.now().strftime("%Y-%m-%d")
+    identity_key = body.identity_key
+    identity_type = "title_hash"
+    if identity_key.startswith("arxiv:"):
+        identity_type, identity_key = "arxiv", identity_key[6:]
+    elif identity_key.startswith("doi:"):
+        identity_type, identity_key = "doi", identity_key[4:]
+    elif identity_key.startswith("title_hash:"):
+        identity_type, identity_key = "title_hash", identity_key[11:]
+
+    ok = await run_in_threadpool(
+        record_feedback,
+        db_path,
+        date_key=date_key,
+        paper_identity_key=identity_key,
+        identity_type=identity_type,
+        title=body.title,
+        action=FeedbackAction(body.action),
+        source_list=body.source_list,
+        score_at_recommend=body.score_at_recommend,
+        keywords=body.keywords,
+        category=body.category,
+    )
+
+    if str(body.action) == "skip":
+        try:
+            await run_in_threadpool(
+                record_skip_negative_pref,
+                db_path,
+                identity_key=body.identity_key,
+                title=str(body.title or ""),
+                abstract=None,
+                journal=body.journal,
+                source=body.source,
+                keywords=body.keywords,
+                category=body.category,
+                ttl_days=14,
+            )
+            await run_in_threadpool(
+                maybe_promote_longterm_from_recent_skips,
+                db_path,
+                window_days=30,
+                min_count=5,
+                min_confidence=0.6,
+                max_new_rules=2,
+            )
+        except Exception:
+            pass
+
+    return DailyRecommendFeedbackResponse(success=ok, message="反馈已记录" if ok else "记录失败")

+ 514 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/daily_support.py

@@ -0,0 +1,514 @@
+"""每日推荐支撑 —— arXiv RSS 解析、候选论文格式化与 API 适配."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import time
+from collections import Counter
+from typing import Any
+
+from fastapi.concurrency import run_in_threadpool
+
+from ...agents import get_search_agent
+from ...core.search import _arxiv_canonical_from_paper
+from ...settings import get_settings
+from ...utils.common import suppress_exceptions, suppress_exceptions_async
+from .daily_recommend_feedback import get_high_value_keywords_from_feedback, get_skipped_papers
+from .user_behavior_analytics import get_user_interest_profile_for_daily_recommend
+from ..llm.llm_service import coerce_hello_agents_llm_output_to_str
+
+logger = logging.getLogger(__name__)
+
+_DAILY_HTTP_TIMEOUT_SEC = 45
+_DAILY_HTTP_MAX_ATTEMPTS = 3
+_ARXIV_QUERY_NOISE = frozenset({
+    "academicsearch", "tavilysearch", "refinequery", "parseintent", "filterresults",
+    "explainresults", "diversifyresults", "proceedingsitesearch", "finish",
+})
+_OPENALEX_FALLBACK_QUERY = "machine learning neural network transformer deep learning"
+
+_user_profile_cache: tuple[Any, ...] | None = None
+_user_profile_cache_ts: float = 0.0
+_USER_PROFILE_CACHE_TTL = 7200
+
+
+@suppress_exceptions(default_return={"http_timeout_sec": float(_DAILY_HTTP_TIMEOUT_SEC), "http_max_attempts": int(_DAILY_HTTP_MAX_ATTEMPTS)})
+def daily_arxiv_http_kw() -> dict[str, float | int]:
+    s = get_settings()
+    to = float(getattr(s, "papergraph_daily_arxiv_http_timeout_sec", _DAILY_HTTP_TIMEOUT_SEC))
+    at = int(getattr(s, "papergraph_daily_arxiv_http_max_attempts", _DAILY_HTTP_MAX_ATTEMPTS))
+    return {"http_timeout_sec": max(15.0, min(300.0, to)), "http_max_attempts": max(1, min(10, at))}
+
+
+def prepare_memory_keywords(mem_kw: set[str], *, limit: int = 12, short_first: bool = False) -> tuple[list[str], int]:
+    raw_items = {str(x).strip().lower() for x in (mem_kw or set()) if str(x).strip()}
+    ranked = sorted(raw_items, key=lambda s: (len(s), s)) if short_first else sorted(raw_items)
+    out, seen = [], set()
+    for t in ranked:
+        if t and t not in seen and len(t) > 2 and not (t.isdigit() and len(t) <= 4):
+            out.append(t)
+            seen.add(t)
+            if len(out) >= limit:
+                break
+    return out, len(raw_items)
+
+
+def collect_memory_store_texts(
+    store: Any,
+    lib_ids: list[int],
+    *,
+    global_limit: int = 28,
+    snippets_per_paper: int = 5,
+    max_papers: int = 60,
+) -> list[str]:
+    raw_texts: list[str] = []
+    for line in store.list_recent_contents(
+        scope="global", paper_id=None, kinds=["preference", "working", "short"], limit=global_limit
+    ):
+        s = str(line or "").strip()
+        if s:
+            raw_texts.append(s)
+    seen: set[int] = set()
+    n = 0
+    for pid in lib_ids:
+        try:
+            i = int(pid)
+        except Exception:
+            continue
+        if i <= 0 or i in seen:
+            continue
+        seen.add(i)
+        n += 1
+        if n > max_papers:
+            break
+        for line in store.list_recent_contents(
+            scope="paper",
+            paper_id=i,
+            kinds=["short", "working", "paper_summary"],
+            limit=snippets_per_paper,
+        ):
+            s = str(line or "").strip()
+            if s:
+                raw_texts.append(s)
+    return raw_texts
+
+
+def extract_library_characteristics(library_papers: list[Any]) -> tuple[int, set[str]]:
+    """从用户文献库标题/摘要提取高频词,供每日推荐 arXiv 查询拼接。"""
+    blobs: list[str] = []
+    for p in library_papers or []:
+        title = str(getattr(p, "title", "") or "").strip()
+        abstract = str(getattr(p, "abstract", "") or "").strip()
+        if title:
+            blobs.append(title)
+        if abstract:
+            blobs.append(abstract[:800])
+    return len(library_papers or []), memory_keywords_from_texts(blobs, tokens_cap=80)
+
+
+def memory_keywords_from_texts(blobs: list[str], *, tokens_cap: int = 320) -> set[str]:
+    if not blobs:
+        return set()
+
+    def _tok(text: str) -> list[str]:
+        t = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", (text or "").lower())
+        return [x for x in (x.strip() for x in t.split() if x.strip()) if len(x) >= 3][:2000]
+
+    freq: Counter[str] = Counter()
+    for b in blobs:
+        for w in _tok(b):
+            freq[w] += 1
+    return {w for w, _ in freq.most_common(tokens_cap) if 3 <= len(w) <= 28}
+
+
+def build_daily_arxiv_query(mem_kw: set[str], lib_kw: set[str] | list[str] | None, *, log: Any = None) -> str:
+    try:
+        merged = list(mem_kw or set()) + list(lib_kw or [])
+        clean = [
+            str(x).strip().lower()
+            for x in merged
+            if str(x).strip() and len(str(x).strip()) >= 3
+            and str(x).strip().lower() not in _ARXIV_QUERY_NOISE
+            and not str(x).strip().startswith("http")
+        ]
+        keywords = clean[:12]
+        if not keywords:
+            return ""
+
+        if len(keywords) >= 3:
+            llm_query = _llm_build_arxiv_query(keywords, log=log)
+            if llm_query and len(llm_query) >= 10:
+                return llm_query[:200]
+
+        return " OR ".join(f'"{kw}"' for kw in keywords[:4])[:200]
+    except Exception:
+        return ""
+
+
+def _llm_build_arxiv_query(keywords: list[str], *, log: Any = None) -> str:
+    try:
+        from ..llm.llm_service import get_llm, is_llm_configured, coerce_hello_agents_llm_output_to_str
+
+        if not is_llm_configured():
+            return ""
+        kw_str = ", ".join(keywords[:12])
+        prompt = (
+            f"将用户研究关键词转为 arXiv API 搜索查询(ti_abs 模式,AND/OR 组合,不加 site: 或类别前缀)。"
+            f"只输出纯文本查询,不要 JSON 包裹,不要解释。\n"
+            f"关键词:{kw_str}\n"
+            f"查询:"
+        )
+        llm = get_llm()
+        raw = coerce_hello_agents_llm_output_to_str(
+            llm.invoke([{"role": "user", "content": prompt}], temperature=0.1, max_tokens=128)
+        )
+        q = raw.strip().strip('"').strip("'")[:200]
+        return q if len(q) >= 4 else ""
+    except Exception as e:
+        if log:
+            log.debug("LLM arXiv query construction failed: %s", e)
+        return ""
+
+
+def append_unique_by_title(into: list[Any], extra: list[Any]) -> None:
+    seen = {str(getattr(x, "title", "") or "").strip().lower() for x in into}
+    for p in extra:
+        tt = str(getattr(p, "title", "") or "").strip().lower()
+        if tt and tt not in seen:
+            seen.add(tt)
+            into.append(p)
+
+
+async def _safe_load_keywords(coro_or_func, *args, **kwargs) -> set[str]:
+    try:
+        result = await (coro_or_func(*args, **kwargs) if callable(coro_or_func) else coro_or_func)
+        return result if isinstance(result, set) else set()
+    except Exception:
+        return set()
+
+
+async def extract_memory_keywords_via_llm(raw_texts: list[str], log: Any) -> set[str]:
+    if not raw_texts:
+        return set()
+    try:
+        agent = get_search_agent()
+        llm = getattr(agent, "llm", None)
+        if not llm:
+            return set()
+        seen: set[str] = set()
+        deduped: list[str] = []
+        total_chars = 0
+        for t in raw_texts:
+            s = str(t).strip()
+            if not s or s in seen:
+                continue
+            seen.add(s)
+            deduped.append(s)
+            total_chars += len(s)
+            if total_chars > 3000:
+                break
+        memory_block = "\n---\n".join(deduped[:60])
+        prompt = (
+            "Extract research keywords (methods, models, tasks, domain terms) from user memory fragments. "
+            "Output JSON array only, no explanation. Skip stopwords, greetings, dates, URLs.\n\n"
+            f"{memory_block}\n\n"
+            'Format: ["keyword1", ...]'
+        )
+        raw = await run_in_threadpool(
+            llm.invoke,
+            [{"role": "user", "content": prompt}],
+            temperature=0.0,
+            max_tokens=400,
+        )
+        txt = coerce_hello_agents_llm_output_to_str(raw).strip()
+        try:
+            parsed = json.loads(txt)
+        except Exception:
+            m = re.search(r"\[.*?\]", txt, re.DOTALL)
+            if not m:
+                return set()
+            try:
+                parsed = json.loads(m.group())
+            except Exception:
+                return set()
+        if isinstance(parsed, list):
+            return {str(x).strip().lower() for x in parsed if str(x).strip() and len(str(x).strip()) >= 2}
+        return set()
+    except Exception as e:
+        log.warning("LLM 提取记忆关键词失败: %s", e)
+        return set()
+
+
+async def load_memory_keywords(*, db_path: str, lib_ids: list[int], log: Any) -> set[str]:
+    mem_kw: set[str] = set()
+
+    @suppress_exceptions_async(default_return=(None, None))
+    async def _load_store_kw() -> tuple:
+        from ..memory.memory_store import MemoryStore
+
+        store = MemoryStore(str(db_path))
+        raw_texts = collect_memory_store_texts(store, lib_ids)
+        llm_kw = await extract_memory_keywords_via_llm(raw_texts, log)
+        if llm_kw:
+            return (llm_kw, None)
+        return (None, memory_keywords_from_texts(raw_texts))
+
+    llm_kw, store_kw = await _load_store_kw()
+    if llm_kw:
+        mem_kw.update(llm_kw)
+        return mem_kw
+    if store_kw:
+        mem_kw.update(store_kw)
+
+    @suppress_exceptions_async(default_return=None)
+    async def _load_shared_kw() -> set[str] | None:
+        from ..memory.agent_memory import get_agent_memory
+
+        am = get_agent_memory()
+        shared_lines = am.recent(agent_name="shared", memory_types=["working", "episodic"], limit=40, shared=True)
+        if not shared_lines:
+            return None
+        shared_texts = [str(ln).strip() for ln in shared_lines if str(ln).strip()]
+        shared_kw = await extract_memory_keywords_via_llm(shared_texts, log)
+        return shared_kw or am.keywords_from_shared(limit_lines=50, tokens_cap=120)
+
+    shared_kw = await _load_shared_kw()
+    if shared_kw:
+        mem_kw.update(shared_kw)
+    return mem_kw
+
+
+@suppress_exceptions_async(default_return=set())
+async def load_feedback_keywords(*, db_path: str, mem_kw: set[str]) -> set[str]:
+    feedback_keywords = await run_in_threadpool(
+        get_high_value_keywords_from_feedback, db_path, days=21, top_n=15
+    )
+    mem_kw.update(feedback_keywords)
+    return mem_kw
+
+
+async def load_profile_keywords(*, db_path: str, mem_kw: set[str], log: Any) -> set[str]:
+    try:
+        user_profile = await run_in_threadpool(get_user_interest_profile_for_daily_recommend, db_path)
+        mem_kw.update(kw.lower() for kw, weight in user_profile.top_keywords[:25] if weight >= 1.0)
+    except Exception as e:
+        log.debug("数据库行为画像提取失败: %s", e)
+    return mem_kw
+
+
+async def load_user_context(
+    *,
+    db_path: str,
+    lib_ids: list[int],
+    log: Any,
+    include_shown_exclusions: bool = True,
+) -> tuple[set[str], int, list[str], set[str]]:
+    mem_kw = await load_memory_keywords(db_path=db_path, lib_ids=lib_ids, log=log)
+    await load_feedback_keywords(db_path=db_path, mem_kw=mem_kw)
+    await load_profile_keywords(db_path=db_path, mem_kw=mem_kw, log=log)
+    skipped_papers = await _safe_load_keywords(
+        run_in_threadpool(get_skipped_papers, db_path, days=14, include_shown=include_shown_exclusions)
+    )
+    mem_kw_list, mem_kw_n = prepare_memory_keywords(mem_kw)
+    return mem_kw, mem_kw_n, mem_kw_list, skipped_papers
+
+
+def invalidate_user_profile_cache() -> None:
+    global _user_profile_cache, _user_profile_cache_ts
+    _user_profile_cache = None
+    _user_profile_cache_ts = 0.0
+
+
+async def get_or_load_user_context(
+    *,
+    db_path: str,
+    lib_ids: list[int],
+    log: Any,
+    force_reload: bool = False,
+    include_shown_exclusions: bool = True,
+) -> tuple[set[str], int, list[str], set[str]]:
+    global _user_profile_cache, _user_profile_cache_ts
+    now = time.time()
+    if (
+        not force_reload
+        and _user_profile_cache is not None
+        and (now - _user_profile_cache_ts) < _USER_PROFILE_CACHE_TTL
+    ):
+        return _user_profile_cache
+    result = await load_user_context(
+        db_path=db_path,
+        lib_ids=lib_ids,
+        log=log,
+        include_shown_exclusions=include_shown_exclusions,
+    )
+    _user_profile_cache = result
+    _user_profile_cache_ts = now
+    return result
+
+
+def daily_arxiv_category_list(daily_arxiv_cs_categories: list[str] | None) -> list[str]:
+    cats = [str(c).strip() for c in (daily_arxiv_cs_categories or []) if str(c).strip()]
+    return cats if cats else ["cs.CV", "cs.LG", "cs.AI", "cs.CL"]
+
+
+def llm_arxiv_categories(agent: Any, user_keywords: list[str], all_categories: list[str]) -> list[str]:
+    if not user_keywords or len(user_keywords) < 3:
+        return all_categories[:4]
+    kw_str = ", ".join(user_keywords[:10])
+    cats_str = ", ".join(all_categories)
+    prompt = f"用户研究兴趣: {kw_str}\narXiv分类: {cats_str}\n选出最相关的4-6个分类,只返回逗号分隔列表:"
+    try:
+        raw = agent.llm.invoke([{"role": "user", "content": prompt}], temperature=0.0, max_tokens=60)
+        result = coerce_hello_agents_llm_output_to_str(raw).strip()
+        selected = [c.strip() for c in result.split(",") if c.strip() in all_categories]
+        return selected[:6] if selected else all_categories[:4]
+    except Exception:
+        return all_categories[:4]
+
+
+def append_arxiv_batch_filtered(
+    batch: list[Any],
+    *,
+    arxiv_results: list[Any],
+    seen_titles: set[str],
+    exclude_sigs: set[str],
+) -> None:
+    for p in batch:
+        t = str(getattr(p, "title", "") or "").strip().lower()
+        if not t or t in seen_titles:
+            continue
+        pid = _arxiv_canonical_from_paper(p)
+        doi = (getattr(p, "doi", "") or "").strip().lower()
+        if (pid and f"arxiv:{pid}" in exclude_sigs) or (doi and f"doi:{doi}" in exclude_sigs):
+            continue
+        if f"ty:{t}|{getattr(p, 'year', '')}" in exclude_sigs:
+            continue
+        seen_titles.add(t)
+        arxiv_results.append(p)
+
+
+async def fetch_arxiv_candidates(
+    *,
+    searcher: Any,
+    arxiv_query: str,
+    days_back: int,
+    daily_arxiv_cs_categories: list[str],
+    log: Any,
+    exclude_sigs: set[str] | None = None,
+) -> tuple[list[Any], int]:
+    cats = daily_arxiv_category_list(daily_arxiv_cs_categories)
+    exclude_sigs = exclude_sigs or set()
+    q = (arxiv_query or "").strip()
+    http_kw = daily_arxiv_http_kw()
+    arxiv_results: list[Any] = []
+    seen_titles: set[str] = set()
+    n_fail = 0
+    # Widen the date window only when recent arXiv results are too sparse.
+    days_tiers = [1, 3, 7] if days_back <= 7 else [days_back]
+    if days_back > 7:
+        days_tiers = [days_back, 14, 30]
+    else:
+        days_tiers = [d for d in [1, 3, 7] if d >= min(days_back, 7)] or [1, 3, 7]
+    for dbk in days_tiers:
+        if len(arxiv_results) >= 60:
+            break
+        for cat in cats:
+            if len(arxiv_results) >= 60 or n_fail >= 3:
+                break
+            try:
+                batch = await searcher.search_arxiv_async(
+                    q, max_results=30, days_back=dbk, arxiv_categories=[cat],
+                    arxiv_query_style="ti_abs", **http_kw,
+                ) or []
+            except Exception:
+                n_fail += 1
+                log.debug("每日论文:arXiv 请求失败 dbk=%s cat=%s", dbk, cat)
+                continue
+            n_fail = 0
+            append_arxiv_batch_filtered(
+                batch, arxiv_results=arxiv_results, seen_titles=seen_titles, exclude_sigs=exclude_sigs
+            )
+    if not arxiv_results:
+        log.warning("每日论文:arXiv 未拉取到可用论文,将触发 OpenAlex 兜底")
+    return arxiv_results, len(arxiv_results)
+
+
+async def fetch_openalex_daily_fallback(
+    *,
+    searcher: Any,
+    mem_kw: set[str],
+    lib_kw: set[str] | list[str] | None,
+    log: Any,
+    max_results: int = 80,
+) -> list[Any]:
+    import datetime as _dt
+
+    try:
+        q = build_daily_arxiv_query(mem_kw, lib_kw, log=log)
+        if len(q) < 4:
+            bits = [
+                t for t in prepare_memory_keywords(mem_kw, limit=12, short_first=True)[0]
+                if len(t) >= 3 and not t.isdigit()
+            ][:6]
+            q = " ".join(bits).strip()
+        if len(q) < 4:
+            q = _OPENALEX_FALLBACK_QUERY
+        yr = int(_dt.datetime.now(_dt.timezone.utc).year) - 2
+        hits = list(
+            await searcher.search_openalex_async(
+                q[:220],
+                max_results=max(40, min(120, max_results)),
+                year_from=yr,
+            )
+            or []
+        )
+        if hits:
+            log.info("每日论文:OpenAlex 兜底命中 %s 篇", len(hits))
+        return hits
+    except Exception as e:
+        log.warning("每日论文:OpenAlex 兜底失败:%s", e)
+        return []
+
+
+async def fetch_external_candidates(
+    *,
+    searcher: Any,
+    mem_kw: set[str],
+    lib_kw: set[str] | list[str] | None,
+    days_back: int,
+    daily_arxiv_cs_categories: list[str],
+    log: Any,
+    exclude_sigs: set[str] | None = None,
+) -> tuple[list[Any], dict[str, int], str]:
+    arxiv_query = build_daily_arxiv_query(mem_kw, lib_kw, log=log)
+    arxiv_results, arx_n = await fetch_arxiv_candidates(
+        searcher=searcher,
+        arxiv_query=arxiv_query,
+        days_back=days_back,
+        daily_arxiv_cs_categories=daily_arxiv_cs_categories,
+        log=log,
+        exclude_sigs=exclude_sigs,
+    )
+    if len(arxiv_results) < 16 and exclude_sigs:
+        log.info(
+            "每日论文:剔除已展示/跳过后过少(%s),本轮忽略排除集再抓一批以便形成推荐池",
+            len(arxiv_results),
+        )
+        rescue, _ = await fetch_arxiv_candidates(
+            searcher=searcher,
+            arxiv_query="",
+            days_back=max(7, days_back),
+            daily_arxiv_cs_categories=daily_arxiv_cs_categories,
+            log=log,
+            exclude_sigs=set(),
+        )
+        append_unique_by_title(arxiv_results, rescue)
+
+    arxiv_results.sort(
+        key=lambda p: (int(getattr(p, "year", 0) or 0), int(getattr(p, "citations", 0) or 0)),
+        reverse=True,
+    )
+    arxiv_results = arxiv_results[:96]
+    return arxiv_results, {"arxiv": len(arxiv_results)}, arxiv_query

+ 220 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/daily/user_behavior_analytics.py

@@ -0,0 +1,220 @@
+"""用户行为分析 —— 基于浏览/保存/搜索行为更新用户偏好画像."""
+
+from __future__ import annotations
+
+import contextlib
+import re
+import sqlite3
+from collections import Counter
+from dataclasses import dataclass
+
+from ...utils import build_in_clause
+
+@dataclass
+class UserInterestProfile:
+
+    top_keywords: list[tuple[str, float]]
+
+    top_subdomains: list[tuple[str, float]]
+
+    high_interest_paper_ids: list[int]
+
+    recent_active_topics: list[str]
+
+    preferred_years: list[int]
+
+    preferred_sources: list[str]
+
+class UserBehaviorAnalytics:
+
+    def __init__(self, db_path: str) -> None:
+        self.db_path = db_path
+
+    def _get_connection(self) -> sqlite3.Connection:
+        conn = sqlite3.connect(self.db_path)
+        conn.row_factory = sqlite3.Row
+        return conn
+
+    @contextlib.contextmanager
+    def _cursor(self):
+        conn = self._get_connection()
+        try:
+            yield conn.cursor()
+        finally:
+            conn.close()
+
+    def extract_keywords_from_text(self, text: str) -> list[str]:
+
+        if not text:
+            return []
+        t = text.lower()
+        t = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", t)
+        tokens = [x.strip() for x in t.split() if x.strip()]
+        stop = {
+            "the", "a", "an", "and", "or", "of", "to", "in", "for", "with", "on",
+            "we", "our", "is", "are", "be", "via", "from", "this", "that",
+            "using", "use", "based", "towards", "paper", "propose", "method",
+            "learning", "network", "model", "deep", "neural",
+        }
+        return [x for x in tokens if x not in stop and len(x) >= 4][:200]
+
+    def get_papers_by_reading_time(
+        self, *, days: int = 30, min_duration: int = 30, top_n: int = 50,
+    ) -> list[tuple[int, float]]:
+        with self._cursor() as cur:
+            cur.execute(
+                "SELECT paper_id,SUM(duration_sec) AS total_duration FROM paper_reading_sessions WHERE day_key>=date('now',?) AND duration_sec>=? GROUP BY paper_id ORDER BY total_duration DESC LIMIT ?",
+                (f"-{days} days", min_duration, top_n),
+            )
+            return [(int(r["paper_id"]), float(r["total_duration"])) for r in cur.fetchall()]
+
+    def get_papers_by_reading_frequency(
+        self, *, days: int = 30, min_sessions: int = 2, top_n: int = 30,
+    ) -> list[tuple[int, int]]:
+        with self._cursor() as cur:
+            cur.execute(
+                "SELECT paper_id,COUNT(*) AS session_count FROM paper_reading_sessions WHERE day_key>=date('now',?) GROUP BY paper_id HAVING COUNT(*)>=? ORDER BY session_count DESC LIMIT ?",
+                (f"-{days} days", min_sessions, top_n),
+            )
+            return [(int(r["paper_id"]), int(r["session_count"])) for r in cur.fetchall()]
+
+    def get_recently_saved_papers(
+        self, *, days: int = 30, top_n: int = 50,
+    ) -> list[tuple[int, str]]:
+        with self._cursor() as cur:
+            cur.execute(
+                "SELECT id,category FROM papers WHERE created_at>=strftime('%s','now',?) ORDER BY created_at DESC LIMIT ?",
+                (f"-{days} days", top_n),
+            )
+            return [(int(r["id"]), str(r["category"] or "")) for r in cur.fetchall()]
+
+    def extract_keywords_from_high_interest_papers(
+        self, paper_ids: list[int],
+    ) -> Counter[str]:
+        if not paper_ids:
+            return Counter()
+        with self._cursor() as cur:
+            in_clause, params = build_in_clause("id", paper_ids)
+            cur.execute(f"SELECT title,abstract,keywords FROM papers WHERE {in_clause}", params)
+            all_kw: list[str] = []
+            for row in cur.fetchall():
+                all_kw.extend(self.extract_keywords_from_text(str(row["title"] or "")))
+                all_kw.extend(self.extract_keywords_from_text(str(row["abstract"] or "")))
+                for kw in (str(row["keywords"] or "")).split(","):
+                    k = kw.strip().lower()
+                    if k and len(k) >= 3:
+                        all_kw.append(k)
+        return Counter(all_kw)
+
+    def get_interest_subdomains_from_papers(self, paper_ids: list[int]) -> Counter[str]:
+        if not paper_ids:
+            return Counter()
+        with self._cursor() as cur:
+            in_clause, params = build_in_clause("id", paper_ids)
+            cur.execute(f"SELECT title,category,journal FROM papers WHERE {in_clause}", params)
+            subdomains = self._extract_subdomains_from_rows(cur.fetchall())
+        return subdomains
+
+    def _extract_subdomains_from_rows(self, rows) -> Counter[str]:
+        """Count topics from paper metadata. LLM daily pipeline handles semantic classification."""
+        subdomains: Counter[str] = Counter()
+        for row in rows:
+            cat = (row.get("category") or "").strip()
+            if cat:
+                subdomains[cat.lower()] += 1
+        return subdomains
+
+    def get_feedback_enhanced_keywords(
+        self,
+        *,
+        days: int = 21,
+    ) -> Counter[str]:
+        from .daily_recommend_feedback import get_high_value_keywords_from_feedback
+
+        try:
+            keywords_set = get_high_value_keywords_from_feedback(self.db_path, days=days, top_n=30)
+            return Counter({kw: 2.0 for kw in keywords_set})
+        except Exception:
+            return Counter()
+
+    def get_user_interest_profile(
+        self,
+        *,
+        reading_days: int = 30,
+        saved_days: int = 60,
+        feedback_days: int = 21,
+    ) -> UserInterestProfile:
+        high_duration_papers = self.get_papers_by_reading_time(days=reading_days, top_n=50)
+        high_duration_ids = [pid for pid, _ in high_duration_papers]
+
+        freq_papers = self.get_papers_by_reading_frequency(days=reading_days, top_n=30)
+        freq_ids = [pid for pid, _ in freq_papers]
+
+        saved_papers = self.get_recently_saved_papers(days=saved_days, top_n=50)
+        saved_ids = [pid for pid, _ in saved_papers]
+
+        all_interest_ids = list(set(high_duration_ids + freq_ids + saved_ids))
+
+        reading_keywords = self.extract_keywords_from_high_interest_papers(all_interest_ids)
+
+        feedback_keywords = self.get_feedback_enhanced_keywords(days=feedback_days)
+
+        combined_keywords: Counter[str] = Counter()
+        for kw, count in reading_keywords.items():
+            combined_keywords[kw] += count * 1.0
+        for kw, weight in feedback_keywords.items():
+            combined_keywords[kw] += weight
+
+        if saved_ids:
+            saved_paper_ids = [pid for pid, _ in saved_papers]
+            if saved_paper_ids:
+                saved_keywords = self.extract_keywords_from_high_interest_papers(saved_paper_ids)
+                for kw, count in saved_keywords.items():
+
+                    if kw in reading_keywords:
+                        combined_keywords[kw] += count * 0.5
+                    else:
+                        combined_keywords[kw] += count * 1.5
+
+        subdomains = self.get_interest_subdomains_from_papers(all_interest_ids)
+
+        preferred_years = self._extract_preferred_years(all_interest_ids)
+
+        recent_topics = self._extract_recent_active_topics(reading_days=14)
+
+        top_keywords = combined_keywords.most_common(40)
+        top_subdomains = subdomains.most_common(10)
+
+        return UserInterestProfile(
+            top_keywords=top_keywords,
+            top_subdomains=top_subdomains,
+            high_interest_paper_ids=all_interest_ids[:100],
+            recent_active_topics=recent_topics,
+            preferred_years=preferred_years,
+            preferred_sources=[],
+        )
+
+    def _extract_preferred_years(self, paper_ids: list[int]) -> list[int]:
+        if not paper_ids:
+            return []
+        with self._cursor() as cur:
+            in_clause, params = build_in_clause("id", paper_ids)
+            cur.execute(f"SELECT year,COUNT(*) AS cnt FROM papers WHERE {in_clause} AND year IS NOT NULL GROUP BY year ORDER BY cnt DESC LIMIT 5", params)
+            return [int(r["year"]) for r in cur.fetchall() if r["year"]]
+
+    def _extract_recent_active_topics(self, reading_days: int = 14) -> list[str]:
+        with self._cursor() as cur:
+            cur.execute(
+                "SELECT DISTINCT p.title,p.abstract,p.category FROM papers p INNER JOIN paper_reading_sessions prs ON p.id=prs.paper_id WHERE prs.day_key>=date('now',?) LIMIT 30",
+                (f"-{reading_days} days",),
+            )
+            topics = self._extract_subdomains_from_rows(cur.fetchall())
+        return [t for t, _ in topics.most_common(5)]
+
+def get_user_interest_profile_for_daily_recommend(db_path: str) -> UserInterestProfile:
+    analytics = UserBehaviorAnalytics(db_path)
+    return analytics.get_user_interest_profile(
+        reading_days=30,
+        saved_days=60,
+        feedback_days=21,
+    )

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/feedback/__init__.py


+ 182 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/feedback/negative_feedback_memory.py

@@ -0,0 +1,182 @@
+"""负反馈记忆 —— 记录用户不感兴趣的论文/主题,优化后续推荐."""
+
+from __future__ import annotations
+
+import json
+import sqlite3
+import time
+from typing import Any
+
+from ..llm.agent_runtime import run_json_task
+from ..llm.llm_service import get_llm
+from ...utils.common import exec_sql
+
+def ensure_tables(db_path: str) -> None:
+    exec_sql(db_path,
+        """CREATE TABLE IF NOT EXISTS negative_pref_memory (
+          id INTEGER PRIMARY KEY AUTOINCREMENT,
+          created_at INTEGER NOT NULL,
+          expires_at INTEGER NOT NULL,
+          identity_key TEXT,
+          title TEXT,
+          payload_json TEXT NOT NULL
+        )""",
+        "CREATE INDEX IF NOT EXISTS idx_negpref_exp ON negative_pref_memory(expires_at)",
+        """CREATE TABLE IF NOT EXISTS negative_pref_longterm (
+          id INTEGER PRIMARY KEY AUTOINCREMENT,
+          kind TEXT NOT NULL,
+          value TEXT NOT NULL,
+          weight REAL DEFAULT -0.2,
+          created_at INTEGER NOT NULL,
+          last_triggered_at INTEGER NOT NULL,
+          trigger_count INTEGER DEFAULT 0,
+          disabled INTEGER DEFAULT 0,
+          evidence_json TEXT
+        )""",
+        "CREATE UNIQUE INDEX IF NOT EXISTS ux_negpref_longterm_kind_val ON negative_pref_longterm(kind, value)",
+        "CREATE INDEX IF NOT EXISTS idx_negpref_longterm_disabled ON negative_pref_longterm(disabled, last_triggered_at)",
+    )
+
+def _extract_pref_dims(payload: dict[str, Any]) -> dict[str, list[str]]:
+    return {
+        "topic": [str(x).strip().lower() for x in (payload.get("topics_to_downrank") or []) if str(x).strip()],
+        "subdomain": [str(x).strip().lower() for x in (payload.get("subdomains_to_downrank") or []) if str(x).strip()],
+        "style": [str(x).strip().lower() for x in (payload.get("styles_to_downrank") or []) if str(x).strip()],
+        "venue": [str(x).strip().lower() for x in (payload.get("venues_to_downrank") or []) if str(x).strip()],
+        "source": [str(x).strip().lower() for x in (payload.get("sources_to_downrank") or []) if str(x).strip()],
+    }
+
+def maybe_promote_longterm_from_recent_skips(
+    db_path: str,
+    *,
+    window_days: int = 30,
+    min_count: int = 5,
+    min_confidence: float = 0.6,
+    max_new_rules: int = 2,
+) -> list[tuple[str, str]]:
+    ensure_tables(db_path)
+    now = int(time.time())
+    win = max(7, min(90, int(window_days))) * 86400
+    since = now - win
+
+    conn = sqlite3.connect(db_path)
+    try:
+        cur = conn.cursor()
+        cur.execute(
+            """SELECT created_at, title, payload_json
+            FROM negative_pref_memory
+            WHERE created_at >= ? ORDER BY created_at DESC LIMIT 1000""",
+            (since,),
+        )
+        counts: dict[tuple[str, str], int] = {}
+        evidences: dict[tuple[str, str], dict[str, Any]] = {}
+
+        for created_at, title, payload_json in cur.fetchall():
+            try:
+                payload = json.loads(payload_json or "{}")
+            except Exception:
+                continue
+            if not isinstance(payload, dict):
+                continue
+            conf = float(payload.get("confidence") or 0.0)
+            if conf < float(min_confidence):
+                continue
+            dims = _extract_pref_dims(payload)
+            for kind, vals in dims.items():
+                for v in vals[:8]:
+                    vv = (v or "").strip().lower()[:64]
+                    if len(vv) < 2:
+                        continue
+                    key = (kind, vv)
+                    counts[key] = counts.get(key, 0) + 1
+                    if key not in evidences:
+                        evidences[key] = {
+                            "window_days": int(window_days),
+                            "min_confidence": float(min_confidence),
+                            "example_titles": [],
+                            "last_seen_at": int(created_at or 0),
+                        }
+                    if title and len(evidences[key]["example_titles"]) < 3:
+                        evidences[key]["example_titles"].append(str(title)[:160])
+                    evidences[key]["last_seen_at"] = max(int(evidences[key]["last_seen_at"]), int(created_at or 0))
+
+        promoted: list[tuple[str, str]] = []
+        items = sorted(counts.items(), key=lambda x: x[1], reverse=True)
+        for (kind, value), cnt in items:
+            if cnt < int(min_count):
+                break
+            if len(promoted) >= int(max_new_rules):
+                break
+            weight = -0.2
+            ev = evidences.get((kind, value), {})
+            ev["count"] = int(cnt)
+            cur.execute(
+                """INSERT INTO negative_pref_longterm(kind, value, weight, created_at, last_triggered_at, trigger_count, disabled, evidence_json)
+                VALUES (?, ?, ?, ?, ?, ?, 0, ?)
+                ON CONFLICT(kind, value) DO UPDATE SET
+                  last_triggered_at = excluded.last_triggered_at,
+                  trigger_count = COALESCE(negative_pref_longterm.trigger_count, 0) + 1,
+                  evidence_json = excluded.evidence_json""",
+                (kind, value, float(weight), now, int(ev.get("last_seen_at") or now), 1, json.dumps(ev, ensure_ascii=False)),
+            )
+            promoted.append((kind, value))
+        conn.commit()
+        return promoted
+    finally:
+        conn.close()
+
+def record_skip_negative_pref(
+    db_path: str,
+    *,
+    identity_key: str,
+    title: str,
+    abstract: str | None = None,
+    journal: str | None = None,
+    source: str | None = None,
+    keywords: list[str | None] = None,
+    category: str | None = None,
+    ttl_days: int = 14,
+) -> bool:
+    ensure_tables(db_path)
+    ttl = max(1, min(60, int(ttl_days or 14)))
+    now = int(time.time())
+    exp = now + ttl * 86400
+
+    system_prompt = (
+        "你是推荐系统的反馈分析器。用户点了「不感兴趣(skip)」。"
+        "请把这一次 skip 总结成短期负偏好,用于未来 7-14 天轻微降权(不是硬过滤)。"
+        "输出必须是 JSON,字段如下:\n"
+        "- topics_to_downrank: string[](主题关键词,2-8 个)\n"
+        "- subdomains_to_downrank: string[](子领域标签,0-5 个)\n"
+        "- venues_to_downrank: string[](会议/期刊关键词,0-3 个)\n"
+        "- sources_to_downrank: string[](arxiv/openalex/dblp,0-2 个)\n"
+        "- styles_to_downrank: string[](survey/tutorial/benchmark/...,0-3 个)\n"
+        "- confidence: number(0-1)\n"
+        "规则:宁可少写,避免误伤;不要输出解释文字,只输出 JSON。"
+    )
+    prompt = json.dumps({
+        "title": title, "abstract": (abstract or "")[:1200],
+        "journal": journal or "", "source": source or "",
+        "keywords": (keywords or [])[:20], "category": category or "",
+    }, ensure_ascii=False)
+    payload = run_json_task(
+        task_name="negative_pref_summarizer", agent_name="neg_pref_summarizer",
+        llm=get_llm(), system_prompt=system_prompt, user_prompt=prompt,
+        timeout_sec=10.0, retries=1, default={},
+    )
+    payload_json = json.dumps(payload or {}, ensure_ascii=False)
+
+    conn = sqlite3.connect(db_path)
+    try:
+        cur = conn.cursor()
+        cur.execute(
+            """INSERT INTO negative_pref_memory(created_at, expires_at, identity_key, title, payload_json)
+            VALUES (?, ?, ?, ?, ?)""",
+            (now, exp, (identity_key or "")[:160], (title or "")[:400], payload_json),
+        )
+        conn.commit()
+        return True
+    except Exception:
+        return False
+    finally:
+        conn.close()

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/graph/__init__.py


+ 184 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/graph/graph_service.py

@@ -0,0 +1,184 @@
+"""知识图谱构建服务 —— 论文关系抽取、图谱数据管理与查询."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from fastapi import HTTPException
+
+from ...api.repo import RelationRepository
+from ...models.schemas import GraphEdge, GraphNode, LibraryGraphResponse
+from ...services.papers.papers_helpers import graph_author_label, graph_author_node_id
+from .kg_relations import ensure_tables
+
+logger = logging.getLogger(__name__)
+
+def build_library_graph(
+    *,
+    db: Any,
+    limit: int,
+    category: str | None,
+    include_authors: bool,
+    include_keywords: bool,
+    relation_edge_limit: int,
+    focus_paper_id: int | None,
+) -> LibraryGraphResponse:
+    try:
+        ensure_tables(db.db_path)
+        repo = RelationRepository(db.db_path)
+        focus_id = int(focus_paper_id) if focus_paper_id is not None else None
+
+        if focus_id is not None:
+            fp = db.get_paper_by_id(int(focus_id))
+            papers = [fp] if fp else []
+            if not papers:
+                return LibraryGraphResponse(success=True, nodes=[], edges=[])
+            paper_ids_in_view: set[int] = {int(focus_id)}
+        else:
+            papers = db.get_all_papers(limit=int(limit), order_by="created_at DESC")
+            cat = (category or "").strip() or None
+            if cat:
+                papers = [p for p in papers if (getattr(p, "category", None) or "").strip() == cat]
+            paper_ids_in_view = set()
+            for p in papers:
+                pid = int(getattr(p, "id") or 0)
+                if pid > 0:
+                    paper_ids_in_view.add(pid)
+
+        nodes: dict[str, GraphNode] = {}
+        edges: dict[tuple[str, str, str], GraphEdge] = {}
+
+        def up_node(n: GraphNode):
+            if n.id in nodes:
+                nodes[n.id].weight = float(nodes[n.id].weight) + float(n.weight or 1.0)
+                return
+            nodes[n.id] = n
+
+        def up_edge(e: GraphEdge):
+            k = (e.source, e.target, e.type)
+            if k in edges:
+                edges[k].weight = float(edges[k].weight) + float(e.weight or 1.0)
+                return
+            edges[k] = e
+
+        for p in papers:
+            pid = int(getattr(p, "id") or 0)
+            if pid <= 0:
+                continue
+            paper_node_id = f"paper:{pid}"
+            up_node(GraphNode(
+                id=paper_node_id, type="paper",
+                label=str(getattr(p, "title", "") or f"Paper {pid}")[:140],
+                paper_id=pid, year=getattr(p, "year", None),
+                category=getattr(p, "category", None),
+                journal=(getattr(p, "journal", None) or "").strip() or None,
+                venue_type=(getattr(p, "venue_type", None) or "").strip() or None,
+                weight=3.0,
+            ))
+
+            if include_authors:
+                for idx, a in enumerate(getattr(p, "authors", []) or []):
+                    name = (getattr(a, "name", None) or "").strip()
+                    if not name:
+                        continue
+                    aid = graph_author_node_id(pid, idx, a)
+                    alabel = graph_author_label(a, idx, aid)
+                    up_node(GraphNode(id=aid, type="author", label=alabel, weight=1.0))
+                    up_edge(GraphEdge(source=aid, target=paper_node_id, type="authored_by", weight=1.0))
+
+            kws: list[str] = []
+            if include_keywords:
+                for kw in (getattr(p, "keywords", None) or [])[:24]:
+                    k = str(kw or "").strip()
+                    if not k:
+                        continue
+                    kws.append(k)
+                    kid = f"kw:{k.lower()}"
+                    up_node(GraphNode(id=kid, type="keyword", label=k, weight=1.0))
+                    up_edge(GraphEdge(source=kid, target=paper_node_id, type="has_keyword", weight=1.0))
+
+            if include_keywords and len(kws) > 1:
+                base = [f"kw:{k.lower()}" for k in kws[:12]]
+                for i in range(len(base)):
+                    for j in range(i + 1, len(base)):
+                        s, t = base[i], base[j]
+                        if s == t:
+                            continue
+                        if s > t:
+                            s, t = t, s
+                        up_edge(GraphEdge(source=s, target=t, type="co_keyword", weight=0.5))
+
+        try:
+            rel_rows: list[tuple[int, int, str, float, str]] = []
+            if focus_id is not None:
+                rel_rows = repo.fetch_relation_rows(focus_id=int(focus_id), paper_ids=None, limit=int(relation_edge_limit))
+                rel_paper_ids: set[int] = {int(focus_id)}
+                for sid, tid, _, _, _ in rel_rows:
+                    rel_paper_ids.add(int(sid))
+                    rel_paper_ids.add(int(tid))
+                meta = repo.papers_minimal_by_ids(rel_paper_ids)
+                for pid, (title, year, cat) in meta.items():
+                    nid = f"paper:{pid}"
+                    if nid in nodes:
+                        continue
+                    up_node(GraphNode(
+                        id=nid, type="paper",
+                        label=(title or f"Paper {pid}")[:140],
+                        paper_id=int(pid), year=year, category=cat, weight=2.0,
+                    ))
+            else:
+                rel_rows = repo.fetch_relation_rows(
+                    focus_id=None, paper_ids=paper_ids_in_view, limit=int(relation_edge_limit),
+                )
+
+            for sid, tid, rel, score, evidence in rel_rows:
+                s = f"paper:{int(sid)}"
+                t = f"paper:{int(tid)}"
+                if s not in nodes or t not in nodes:
+                    continue
+                up_edge(GraphEdge(
+                    source=s, target=t,
+                    type=f"paper_{str(rel or 'related')}",
+                    weight=float(score or 0.6),
+                    evidence=(str(evidence or "").strip()[:240] or None),
+                ))
+
+                rev = f"rev_{rel}" if rel else "related_to"
+                up_edge(GraphEdge(source=t, target=s, type=f"paper_{rev}", weight=float(score or 0.6) * 0.8))
+        except Exception:
+            logger.warning("graph_service: paper-paper relation fetch failed", exc_info=True)
+
+        if len(papers) > 1:
+            paper_kw: dict[int, set[str]] = {}
+            paper_au: dict[int, set[str]] = {}
+            for p in papers:
+                pid = int(getattr(p, "id") or 0)
+                if pid <= 0:
+                    continue
+                if include_authors:
+                    paper_au[pid] = {graph_author_label(a, i, "") for i, a in enumerate(getattr(p, "authors", []) or []) if (getattr(a, "name", None) or "").strip()}
+                if include_keywords:
+                    paper_kw[pid] = {str(k).strip().lower() for k in (getattr(p, "keywords", None) or [])[:16] if str(k).strip()}
+
+            if include_authors:
+                pids = list(paper_au.keys())
+                for i in range(len(pids)):
+                    for j in range(i + 1, len(pids)):
+                        shared = paper_au[pids[i]] & paper_au[pids[j]]
+                        if shared:
+                            up_edge(GraphEdge(source=f"paper:{pids[i]}", target=f"paper:{pids[j]}",
+                                             type="shared_author", weight=min(2.0, len(shared) * 0.6)))
+            if include_keywords:
+                pids = list(paper_kw.keys())
+                for i in range(len(pids)):
+                    for j in range(i + 1, len(pids)):
+                        shared = paper_kw[pids[i]] & paper_kw[pids[j]]
+                        if shared:
+                            up_edge(GraphEdge(source=f"paper:{pids[i]}", target=f"paper:{pids[j]}",
+                                             type="shared_keyword", weight=min(2.0, len(shared) * 0.35)))
+
+        return LibraryGraphResponse(success=True, nodes=list(nodes.values()), edges=list(edges.values()))
+    except Exception as e:
+        logger.exception("graph_service.build_library_graph_failed")
+        raise HTTPException(status_code=500, detail=str(e))

+ 367 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/graph/kg_relations.py

@@ -0,0 +1,367 @@
+"""知识图谱关系管理 —— 节点/边数据模型、图谱指标统计与查询."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import sqlite3
+import threading
+import time
+from typing import Any
+
+from ...utils.common import suppress_exceptions
+
+from ..reader.paper_reader_context import extract_pdf_text_full_cached, extract_pdf_text_full, _cache_set
+from ...utils import normalize_arxiv_id as _norm_arxiv_id
+
+logger = logging.getLogger(__name__)
+
+_kg_infer_lock = threading.Lock()
+
+_kg_recent_fingerprints: dict[str, float] = {}
+_kg_fingerprints_lock = threading.Lock()
+_KG_DEDUP_WINDOW_SEC = 180.0
+
+_kg_metrics: dict[str, int] = {
+    "build_ok": 0,
+    "build_skip_no_candidates": 0,
+    "build_skip_dedup": 0,
+    "relations_upserted": 0,
+}
+_kg_metrics_lock = threading.Lock()
+
+def get_kg_metrics() -> dict[str, int]:
+    with _kg_metrics_lock:
+        return dict(_kg_metrics)
+
+def _prune_recent_fingerprints(now: float) -> None:
+    cutoff = now - _KG_DEDUP_WINDOW_SEC * 2
+    with _kg_fingerprints_lock:
+        dead = [k for k, t in _kg_recent_fingerprints.items() if t < cutoff]
+        for k in dead:
+            _kg_recent_fingerprints.pop(k, None)
+
+from ...utils.common import exec_sql
+
+def ensure_tables(db_path: str) -> None:
+    exec_sql(db_path,
+        """CREATE TABLE IF NOT EXISTS paper_relations (
+          source_paper_id INTEGER NOT NULL,
+          target_paper_id INTEGER NOT NULL,
+          relation TEXT NOT NULL,
+          score REAL DEFAULT 0.0,
+          evidence TEXT,
+          created_at INTEGER NOT NULL,
+          updated_at INTEGER NOT NULL,
+          PRIMARY KEY (source_paper_id, target_paper_id, relation)
+        )""",
+        "CREATE INDEX IF NOT EXISTS idx_paper_relations_source ON paper_relations(source_paper_id)",
+        "CREATE INDEX IF NOT EXISTS idx_paper_relations_target ON paper_relations(target_paper_id)",
+    )
+
+@suppress_exceptions(default_return=[])
+def _loads_json_list(x: str | None) -> list[Any]:
+    r = json.loads(x or "[]")
+    return r if isinstance(r, list) else []
+
+def _row_to_paper_meta(row: sqlite3.Row) -> dict[str, Any]:
+    keys = row.keys()
+
+    def _col(name: str) -> Any:
+        return row[name] if name in keys else None
+
+    return {
+        "id": row["id"],
+        "title": row["title"],
+        "abstract": row["abstract"],
+        "keywords": _loads_json_list(_col("keywords")),
+        "tags": _loads_json_list(_col("tags")),
+        "journal": row["journal"],
+        "venue_type": row["venue_type"] if "venue_type" in keys else None,
+        "year": row["year"],
+        "category": row["category"],
+        "local_pdf_path": _col("local_pdf_path"),
+        "doi": (_col("doi") or None),
+        "arxiv_id": (_col("arxiv_id") or None),
+        "references": _loads_json_list(_col("references")),
+    }
+
+def _pdf_abspath_from_row(db_path: str, local_pdf_path: str | None) -> str | None:
+    import os
+
+    if not local_pdf_path or not str(local_pdf_path).strip():
+        return None
+    data_root = os.path.dirname(os.path.abspath(db_path))
+    abspath = os.path.normpath(os.path.join(data_root, str(local_pdf_path).strip()))
+    if os.path.isfile(abspath):
+        return abspath
+    return None
+
+def _extract_related_work_excerpt(text: str, max_chars: int = 1600) -> str:
+    t = (text or "").strip()
+    if not t:
+        return ""
+    patterns = [
+        r"(?i)\brelated\s+work\b",
+        r"(?i)\brelated\s+works\b",
+        r"相关工作",
+    ]
+    for pat in patterns:
+        m = re.search(pat, t)
+        if m:
+            return t[m.start() : m.start() + max_chars].strip()
+    return t[: max_chars // 2].strip()
+
+_TOKEN_RE = re.compile(r"[^\w\u4e00-\u9fff]+", re.UNICODE)
+
+_LEX_STOP = frozenset({
+    "the", "a", "an", "and", "or", "of", "to", "in", "for", "with", "on", "by",
+    "we", "our", "is", "are", "be", "this", "that", "from", "as", "at", "it",
+})
+
+def _lexical_token_set(meta: dict[str, Any]) -> set[str]:
+    parts = [
+        str(meta.get("title") or ""),
+        str(meta.get("abstract") or ""),
+        *(str(k) for k in meta.get("keywords") or []),
+        *(str(t) for t in meta.get("tags") or []),
+    ]
+    text = " ".join(parts).lower()
+    text = _TOKEN_RE.sub(" ", text)
+    return {s for s in text.split() if len(s) >= 2 and s not in _LEX_STOP}
+
+def _reference_signatures(refs: list[Any]) -> set[str]:
+    sigs: set[str] = set()
+    for r in refs or []:
+        s = str(r).strip().lower()
+        if not s:
+            continue
+        sigs.add(s)
+        m = re.search(r"(10\.\d{4,9}/[^\s,;\"'<>]+)", s)
+        if m:
+            sigs.add(m.group(1).rstrip(").,]}"))
+        m = re.search(r"arxiv[:/\s]*(\d{4}\.\d{4,5})(?:v\d+)?", s)
+        if m:
+            sigs.add(_norm_arxiv_id(m.group(1)) or m.group(1))
+    return sigs
+
+def _ref_overlap_bonus(new_sigs: set[str], cand: dict[str, Any]) -> float:
+    if not new_sigs:
+        return 0.0
+    doi = (cand.get("doi") or "").strip().lower()
+    if doi and doi in new_sigs:
+        return 1.0
+    ax = _norm_arxiv_id(cand.get("arxiv_id"))
+    if ax and ax in new_sigs:
+        return 1.0
+    if doi:
+        for sig in new_sigs:
+            if len(sig) > 8 and sig in doi:
+                return 0.85
+    return 0.0
+
+def fetch_new_and_candidates(
+    db_path: str,
+    new_paper_id: int,
+    k: int = 32,
+    *,
+    sql_limit: int = 22,
+    lexical_pool: int = 480,
+) -> tuple[dict[str, Any | None], list[dict[str, Any]]]:
+    conn = sqlite3.connect(db_path)
+    conn.row_factory = sqlite3.Row
+    cur = conn.cursor()
+    cur.execute("SELECT * FROM papers WHERE id=?", (int(new_paper_id),))
+    row = cur.fetchone()
+    if not row:
+        conn.close()
+        return None, []
+    new_meta = _row_to_paper_meta(row)
+
+    cat = (new_meta.get("category") or "").strip()
+    year = new_meta.get("year")
+    params: list[Any] = []
+    where = ["id != ?"]
+    params.append(int(new_paper_id))
+
+    if cat:
+        where.append("(category = ? OR category LIKE ?)")
+        params.extend([cat, f"{cat.split('/')[0]}%"])
+
+    if year:
+        try:
+            y = int(year)
+            where.append("(year IS NULL OR year >= ?)")
+            params.append(max(1900, y - 8))
+        except Exception:
+            pass
+
+    wsql = " AND ".join(where)
+    cur.execute(
+        f"SELECT * FROM papers WHERE {wsql} ORDER BY created_at DESC LIMIT ?",
+        (*params, int(sql_limit)),
+    )
+    sql_metas = [_row_to_paper_meta(r) for r in cur.fetchall()]
+
+    cur.execute(
+        """
+        SELECT * FROM papers
+        WHERE id != ?
+        ORDER BY created_at DESC
+        LIMIT ?
+        """,
+        (int(new_paper_id), int(lexical_pool)),
+    )
+    pool_rows = cur.fetchall()
+    conn.close()
+
+    new_lex = _lexical_token_set(new_meta)
+    new_ref_sigs = _reference_signatures(new_meta.get("references") or [])
+
+    scored: list[tuple[float, dict[str, Any]]] = []
+    for r in pool_rows:
+        meta = _row_to_paper_meta(r)
+        cand_tokens = _lexical_token_set(meta)
+        j = len(new_lex & cand_tokens) / max(1, len(new_lex | cand_tokens))
+        ro = _ref_overlap_bonus(new_ref_sigs, meta)
+        comb = j + ro * 0.45
+        scored.append((comb, meta))
+
+    scored.sort(key=lambda x: -x[0])
+
+    seen: set[int] = set()
+    out: list[dict[str, Any]] = []
+
+    for m in sql_metas:
+        pid = int(m["id"])
+        if pid not in seen:
+            seen.add(pid)
+            out.append(m)
+
+    min_lex = 0.055
+    for comb, m in scored:
+        if len(out) >= int(k):
+            break
+        pid = int(m["id"])
+        if pid in seen:
+            continue
+        ro = _ref_overlap_bonus(new_ref_sigs, m)
+        if comb < min_lex and ro <= 0:
+            continue
+        seen.add(pid)
+        out.append(m)
+
+    return new_meta, out[: int(k)]
+
+def upsert_relations(db_path: str, source_paper_id: int, edges: list[dict[str, Any]]) -> int:
+    ensure_tables(db_path)
+    now = int(time.time())
+    conn = sqlite3.connect(db_path)
+    cur = conn.cursor()
+    n = 0
+    for e in edges:
+        try:
+            tid = int(e.get("target_paper_id"))
+        except Exception:
+            continue
+        rel = str(e.get("relation") or "").strip()[:32]
+        if len(rel) < 2 or len(rel) > 32:
+            continue
+        try:
+            score = float(e.get("score") or 0.0)
+        except Exception:
+            score = 0.0
+        ev = str(e.get("evidence") or "").strip()[:240]
+        if tid <= 0 or tid == int(source_paper_id):
+            continue
+        cur.execute(
+            """
+            INSERT INTO paper_relations(source_paper_id, target_paper_id, relation, score, evidence, created_at, updated_at)
+            VALUES(?, ?, ?, ?, ?, ?, ?)
+            ON CONFLICT(source_paper_id, target_paper_id, relation) DO UPDATE SET
+              score=excluded.score,
+              evidence=excluded.evidence,
+              updated_at=excluded.updated_at
+            """,
+            (int(source_paper_id), int(tid), rel, float(score), ev, now, now),
+        )
+        n += 1
+    conn.commit()
+    conn.close()
+    return n
+
+def build_relations_for_new_paper(db_path: str, new_paper_id: int) -> int:
+    from ...agents import get_knowledge_graph_agent
+
+    ensure_tables(db_path)
+    new_meta, cands = fetch_new_and_candidates(db_path, int(new_paper_id), k=32)
+    if not new_meta:
+        return 0
+    if not cands:
+        with _kg_metrics_lock:
+            _kg_metrics["build_skip_no_candidates"] = _kg_metrics.get("build_skip_no_candidates", 0) + 1
+        logger.info(
+            "kg_build_skip_no_candidates",
+            extra={"paper_id": int(new_paper_id)},
+        )
+        return 0
+
+    now = time.time()
+    _prune_recent_fingerprints(now)
+
+    _ax = _norm_arxiv_id(new_meta.get("arxiv_id"))
+    if _ax:
+        fp = f"arxiv:{_ax}"
+    else:
+        _doi = (new_meta.get("doi") or "").strip().lower()
+        if _doi:
+            fp = f"doi:{_doi}"
+        else:
+            _t = (new_meta.get("title") or "").strip().lower()[:160]
+            fp = f"title:{_t}"
+
+    with _kg_fingerprints_lock:
+        last = _kg_recent_fingerprints.get(fp)
+        if last is not None and (now - last) < _KG_DEDUP_WINDOW_SEC:
+            with _kg_metrics_lock:
+                _kg_metrics["build_skip_dedup"] = _kg_metrics.get("build_skip_dedup", 0) + 1
+            logger.info(
+                "kg_build_skip_dedup",
+                extra={"paper_id": int(new_paper_id), "fingerprint": fp},
+            )
+            return 0
+        _kg_recent_fingerprints[fp] = now
+
+    try:
+        pdf_abspath = _pdf_abspath_from_row(db_path, new_meta.get("local_pdf_path"))
+        if pdf_abspath:
+            excerpt, _hit = extract_pdf_text_full_cached(
+                db_path, int(new_paper_id), pdf_abspath, max_chars=9000
+            )
+            if not excerpt.strip():
+                excerpt = extract_pdf_text_full(pdf_abspath, max_chars=9000)
+                if excerpt.strip():
+                    _cache_set(db_path, int(new_paper_id), pdf_abspath, excerpt)
+            if excerpt.strip():
+                new_meta["pdf_excerpt"] = excerpt[:9000]
+                new_meta["related_work_excerpt"] = _extract_related_work_excerpt(excerpt, max_chars=1600)
+    except Exception as exc:
+        logger.warning(
+            "kg_pdf_excerpt_failed",
+            extra={"paper_id": int(new_paper_id)},
+            exc_info=exc,
+        )
+
+    edges: list[dict[str, Any]] = []
+    with _kg_infer_lock:
+        agent = get_knowledge_graph_agent()
+        edges, _ = agent.infer_edges(new_paper=new_meta, candidates=cands)
+
+    n = upsert_relations(db_path, int(new_paper_id), edges)
+    if n:
+        with _kg_metrics_lock:
+            _kg_metrics["relations_upserted"] = _kg_metrics.get("relations_upserted", 0) + n
+    with _kg_metrics_lock:
+        _kg_metrics["build_ok"] = _kg_metrics.get("build_ok", 0) + 1
+    return n

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/__init__.py


+ 23 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/agent_config.py

@@ -0,0 +1,23 @@
+"""HelloAgents 配置适配 —— 生成 PaperGraph 智能体的默认运行配置."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from hello_agents.core.config import Config
+
+from ...settings import get_settings
+
+
+def papergraph_agent_config() -> Config:
+    memory_root = Path(get_settings().data_dir).resolve() / "memory"
+    return Config(
+        debug=bool(get_settings().debug),
+        log_level=str(get_settings().log_level or "INFO"),
+        trace_dir=str(memory_root / "traces"),
+        session_dir=str(memory_root / "sessions"),
+        tool_output_dir=str(memory_root / "tool-output"),
+        skills_dir=str(memory_root / "skills"),
+        todowrite_persistence_dir=str(memory_root / "todos"),
+        devlog_persistence_dir=str(memory_root / "devlogs"),
+    )

+ 134 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/agent_runtime.py

@@ -0,0 +1,134 @@
+"""Agent 运行时管理 —— 超时控制、重试策略与线程池调度."""
+
+from __future__ import annotations
+
+import concurrent.futures
+import logging
+from typing import Any, TypeVar
+from collections.abc import Callable
+
+from hello_agents import SimpleAgent
+
+from ...settings import get_settings
+from .agent_config import papergraph_agent_config
+
+logger = logging.getLogger(__name__)
+
+_T = TypeVar("_T")
+
+def _exception_chain_predicate(exc: BaseException | None, pred) -> bool:
+    seen: set[int] = set()
+    depth = 0
+    cur: BaseException | None = exc
+    while cur is not None and depth < 12:
+        if id(cur) in seen:
+            break
+        seen.add(id(cur))
+        try:
+            if pred(cur):
+                return True
+        except Exception:
+            pass
+        nxt = cur.__cause__
+        if nxt is None:
+            nxt = getattr(cur, "__context__", None)
+        cur = nxt
+        depth += 1
+    return False
+
+def _task_failed_due_to_timeout(exc: BaseException) -> bool:
+    return _exception_chain_predicate(exc, lambda e: (
+        isinstance(e, TimeoutError)
+        or "timeout" in str(e).lower()
+        or "timed out" in str(e).lower()
+    ))
+
+def _run_with_optional_timeout(fn: Callable[[], _T], timeout_sec: float | None) -> _T:
+    if timeout_sec is None or float(timeout_sec) <= 0:
+        return fn()
+    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
+        fut = ex.submit(fn)
+        try:
+            return fut.result(timeout=float(timeout_sec))
+        except concurrent.futures.TimeoutError as exc:
+            fut.cancel()
+            raise TimeoutError(f"agent task timeout after {timeout_sec}s") from exc
+
+def run_agent_task(
+    *,
+    task_name: str,
+    agent_name: str,
+    llm: Any,
+    system_prompt: str,
+    user_prompt: str,
+    timeout_sec: float | None = None,
+    retries: int | None = None,
+    task_logger: logging.Logger | None = None,
+) -> str:
+    log = task_logger or logger
+    s = get_settings()
+    resolved_timeout = float(timeout_sec) if timeout_sec is not None else float(
+        getattr(s, "agent_runtime_default_timeout_sec", 20.0)
+    )
+    resolved_retries = int(retries) if retries is not None else int(
+        getattr(s, "agent_runtime_default_retries", 1)
+    )
+    attempts = max(1, resolved_retries + 1)
+    last_error: Exception | None = None
+
+    for i in range(attempts):
+        try:
+            agent = SimpleAgent(
+                name=agent_name,
+                llm=llm,
+                system_prompt=system_prompt,
+                config=papergraph_agent_config(),
+            )
+            raw = _run_with_optional_timeout(lambda: agent.run(user_prompt), resolved_timeout)
+            return (raw or "").strip()
+        except Exception as exc:
+            last_error = exc
+            if i + 1 < attempts:
+                log.warning("[%s] attempt %d/%d failed: %s", task_name, i + 1, attempts, exc)
+            else:
+                if _task_failed_due_to_timeout(last_error):
+                    log.warning("[%s] failed after %d attempt(s): %s", task_name, attempts, last_error)
+                else:
+                    log.exception("[%s] failed after %d attempt(s)", task_name, attempts)
+    raise RuntimeError(f"{task_name}_failed") from last_error
+
+def run_json_task(
+    *,
+    task_name: str,
+    agent_name: str,
+    llm: Any,
+    system_prompt: str,
+    user_prompt: str,
+    timeout_sec: float | None = None,
+    retries: int | None = None,
+    default: dict[str, Any | None] = None,
+    parse_fn: Callable[[str | None, dict[str, Any | None]]] = None,
+    task_logger: logging.Logger | None = None,
+) -> dict[str, Any]:
+    log = task_logger or logger
+    if parse_fn is None:
+        from ..search_intent import extract_json_object
+
+        parser = extract_json_object
+    else:
+        parser = parse_fn
+    raw = run_agent_task(
+        task_name=task_name,
+        agent_name=agent_name,
+        llm=llm,
+        system_prompt=system_prompt,
+        user_prompt=user_prompt,
+        timeout_sec=timeout_sec,
+        retries=retries,
+        task_logger=log,
+    )
+    data = parser(raw)
+    if isinstance(data, dict):
+        return data
+    log.warning("[%s] JSON parse failed, fallback to default", task_name)
+    return dict(default or {})

+ 246 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/llm/llm_service.py

@@ -0,0 +1,246 @@
+"""LLM 服务层 —— OpenAI 兼容接口封装与 HelloAgents LLM 适配."""
+
+import logging
+import os
+from functools import wraps
+from typing import Any
+from collections.abc import Callable
+
+from hello_agents import HelloAgentsLLM
+from ...settings import get_settings
+
+logger = logging.getLogger(__name__)
+
+def normalize_openai_compatible_chat_messages(messages: Any) -> Any:
+    if not isinstance(messages, list):
+        return messages
+    out: list[Any] = []
+    for m in messages:
+        if not isinstance(m, dict):
+            out.append(m)
+            continue
+        role = str(m.get("role") or "").strip().lower()
+        content = m.get("content")
+        if role == "summary":
+            text = content if isinstance(content, str) else ("" if content is None else str(content))
+            out.append({"role": "user", "content": ("[前文摘要]\n" + text).strip()})
+            continue
+        if role == "developer":
+            text = content if isinstance(content, str) else ("" if content is None else str(content))
+            nm = dict(m)
+            nm["role"] = "system"
+            nm["content"] = text
+            out.append(nm)
+            continue
+        out.append(m)
+    return out
+
+def _patch_hello_agents_llm_openai_chat_roles() -> None:
+    marker = "_papergraph_openai_role_normalize_applied"
+    if getattr(HelloAgentsLLM, marker, False):
+        return
+
+    def _wrap(orig: Callable[..., Any]) -> Callable[..., Any]:
+        @wraps(orig)
+        def inner(self: Any, *args: Any, **kwargs: Any) -> Any:
+            if args and isinstance(args[0], list):
+                args = (normalize_openai_compatible_chat_messages(args[0]),) + tuple(args[1:])
+            elif isinstance(kwargs.get("messages"), list):
+                kwargs = dict(kwargs)
+                kwargs["messages"] = normalize_openai_compatible_chat_messages(kwargs["messages"])
+            return orig(self, *args, **kwargs)
+
+        return inner
+
+    HelloAgentsLLM.invoke = _wrap(HelloAgentsLLM.invoke)
+    if hasattr(HelloAgentsLLM, "invoke_with_tools"):
+        HelloAgentsLLM.invoke_with_tools = _wrap(HelloAgentsLLM.invoke_with_tools)
+    for _async_name in ("ainvoke", "async_invoke"):
+        if hasattr(HelloAgentsLLM, _async_name):
+            setattr(HelloAgentsLLM, _async_name, _wrap(getattr(HelloAgentsLLM, _async_name)))
+    setattr(HelloAgentsLLM, marker, True)
+    logger.debug("HelloAgentsLLM: patched invoke* for OpenAI-compatible message roles (summary→user)")
+
+_patch_hello_agents_llm_openai_chat_roles()
+
+def _patch_deepseek_disable_thinking() -> None:
+    marker = "_papergraph_deepseek_thinking_disabled"
+    if getattr(HelloAgentsLLM, marker, False):
+        return
+
+    import re as _re
+
+    def _is_deepseek(llm_self: Any) -> bool:
+        base = str(getattr(getattr(llm_self, "_adapter", None), "base_url", "") or "")
+        return bool(_re.search(r"deepseek", base, _re.I))
+
+    def _wrap(orig: Callable[..., Any]) -> Callable[..., Any]:
+        @wraps(orig)
+        def inner(self: Any, *args: Any, **kwargs: Any) -> Any:
+            if _is_deepseek(self):
+                kwargs = dict(kwargs)
+                extra = dict(kwargs.get("extra_body") or {})
+                if "thinking" not in extra:
+                    extra["thinking"] = {"type": "disabled"}
+                kwargs["extra_body"] = extra
+            return orig(self, *args, **kwargs)
+
+        return inner
+
+    HelloAgentsLLM.invoke = _wrap(HelloAgentsLLM.invoke)
+    if hasattr(HelloAgentsLLM, "invoke_with_tools"):
+        HelloAgentsLLM.invoke_with_tools = _wrap(HelloAgentsLLM.invoke_with_tools)
+    for _async_name in ("ainvoke", "async_invoke"):
+        if hasattr(HelloAgentsLLM, _async_name):
+            setattr(HelloAgentsLLM, _async_name, _wrap(getattr(HelloAgentsLLM, _async_name)))
+    setattr(HelloAgentsLLM, marker, True)
+    logger.debug("HelloAgentsLLM: patched invoke* to disable thinking mode for DeepSeek")
+
+_patch_deepseek_disable_thinking()
+
+_llm_instance: HelloAgentsLLM | None = None
+
+def coerce_hello_agents_llm_output_to_str(out: Any) -> str:
+    if out is None:
+        return ""
+    if isinstance(out, str):
+        return out
+    for attr in ("content", "text"):
+        v = getattr(out, attr, None)
+        if isinstance(v, str):
+            return v
+    msg = getattr(out, "message", None)
+    if msg is not None:
+        c = getattr(msg, "content", None)
+        if isinstance(c, str):
+            return c
+    choices = getattr(out, "choices", None)
+    if isinstance(choices, list) and choices:
+        m = getattr(choices[0], "message", None)
+        if m is not None:
+            c = getattr(m, "content", None)
+            if isinstance(c, str):
+                return c
+    return str(out)
+
+def _maybe_disable_proxy_for_llm(base_url: str) -> None:
+    url = (base_url or "").strip()
+    if not url:
+        return
+
+    disable = get_settings().llm_disable_proxy
+    proxy_vars = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
+    has_proxy = any(str(os.getenv(k) or "").strip() for k in proxy_vars)
+    if not has_proxy:
+        return
+
+    from urllib.parse import urlparse
+    host = ""
+    try:
+        host = (urlparse(url).hostname or "").strip()
+    except Exception:
+        host = ""
+    if not host:
+        return
+
+    def _append_no_proxy(*extra_hosts: str) -> None:
+        to_add = [h for h in (host, *extra_hosts) if h and str(h).strip()]
+
+        if "deepseek.com" in host:
+            for h in ("deepseek.com", "*.deepseek.com"):
+                if h not in to_add:
+                    to_add.append(h)
+
+        if "aihubmix.com" in host:
+            for h in ("aihubmix.com", "*.aihubmix.com"):
+                if h not in to_add:
+                    to_add.append(h)
+        for env_key in ("NO_PROXY", "no_proxy"):
+            cur = str(os.getenv(env_key) or "").strip()
+            parts = [p.strip() for p in cur.split(",") if p.strip()]
+            seen = {p.lower() for p in parts}
+            for h in to_add:
+                hl = h.lower()
+                if hl not in seen:
+                    parts.append(h)
+                    seen.add(hl)
+            os.environ[env_key] = ",".join(parts)
+
+    if disable:
+        for k in proxy_vars:
+            os.environ.pop(k, None)
+        _append_no_proxy()
+        return
+
+    _append_no_proxy()
+
+def is_llm_configured() -> bool:
+    s = get_settings()
+    key = (os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") or s.openai_api_key or "").strip()
+    if not key:
+
+        from dotenv import load_dotenv
+        env_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')
+        if os.path.exists(env_path):
+            load_dotenv(env_path, override=True)
+            key = (os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") or "").strip()
+    return bool(key)
+
+def _sync_env_from_settings() -> None:
+    s = get_settings()
+
+    if not os.getenv("LLM_API_KEY") and not os.getenv("OPENAI_API_KEY") and os.getenv("AIHUBMIX_API_KEY"):
+        os.environ["LLM_API_KEY"] = str(os.getenv("AIHUBMIX_API_KEY") or "").strip()
+    if not os.getenv("LLM_BASE_URL") and not os.getenv("OPENAI_BASE_URL") and os.getenv("AIHUBMIX_BASE_URL"):
+        os.environ["LLM_BASE_URL"] = str(os.getenv("AIHUBMIX_BASE_URL") or "").strip()
+    if not os.getenv("LLM_MODEL_ID") and not os.getenv("OPENAI_MODEL") and os.getenv("AIHUBMIX_MODEL_ID"):
+        os.environ["LLM_MODEL_ID"] = str(os.getenv("AIHUBMIX_MODEL_ID") or "").strip()
+
+    if not os.getenv("LLM_API_KEY") and not os.getenv("OPENAI_API_KEY") and s.openai_api_key:
+        os.environ["LLM_API_KEY"] = s.openai_api_key
+    if not os.getenv("LLM_BASE_URL") and not os.getenv("OPENAI_BASE_URL") and s.openai_base_url:
+        os.environ["LLM_BASE_URL"] = s.openai_base_url
+    if not os.getenv("LLM_MODEL_ID") and not os.getenv("OPENAI_MODEL") and s.openai_model:
+        os.environ["LLM_MODEL_ID"] = s.openai_model
+
+def get_llm() -> HelloAgentsLLM:
+    global _llm_instance
+    if _llm_instance is None:
+
+        _sync_env_from_settings()
+
+        api_key = (os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") or "")
+        base_url = (os.getenv("LLM_BASE_URL") or os.getenv("OPENAI_BASE_URL") or "")
+        model = (os.getenv("LLM_MODEL_ID") or os.getenv("OPENAI_MODEL") or "")
+
+        if not api_key:
+            s = get_settings()
+            api_key = s.openai_api_key
+            if not base_url:
+                base_url = s.openai_base_url
+            if not model:
+                model = s.openai_model
+
+        if not api_key:
+            raise RuntimeError("LLM 未配置:请设置 LLM_API_KEY(或在 backend/.env 中配置)")
+
+        _maybe_disable_proxy_for_llm(base_url)
+
+        kw = {}
+        if model:
+            kw["model"] = model
+        if api_key:
+            kw["api_key"] = api_key
+        if base_url:
+            kw["base_url"] = base_url
+
+        logger.info("🔧 正在初始化 LLM...")
+        logger.info("   Model: %s", model or "default")
+        logger.info("   Base URL: %s", base_url or "default")
+
+        _llm_instance = HelloAgentsLLM(**kw)
+        logger.info("✅ LLM 已初始化")
+        logger.info("   实际模型: %s", getattr(_llm_instance, "model", "") or "unknown")
+
+        logger.info("   Provider: %s", getattr(_llm_instance, "provider", None) or "unknown")
+    return _llm_instance

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/__init__.py


+ 134 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/papers_converters.py

@@ -0,0 +1,134 @@
+"""论文数据转换器 —— LiteraturePaper ↔ API Paper 格式互转."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Iterable
+
+from pydantic import TypeAdapter
+
+from app.core.paper import Paper as LitPaper
+from app.core.search.paper_searcher import abbreviate_journal as _abbrev
+
+from ...models.schemas import Author, Paper, PaperSource, ReadStatus
+
+logger = logging.getLogger(__name__)
+_paper_list_adapter = TypeAdapter(list[Paper])
+
+
+def _coerce_paper_source(val: Any) -> PaperSource:
+    if isinstance(val, PaperSource):
+        return val
+    try:
+        return PaperSource(str(val or "unknown").lower().strip())
+    except ValueError:
+        return PaperSource.UNKNOWN
+
+
+def _normalize_author_entries(authors_in: list[Any]) -> list[dict[str, Any]]:
+    norm: list[dict[str, Any]] = []
+    for a in authors_in:
+        if isinstance(a, str):
+            norm.append({"name": a})
+        elif isinstance(a, dict):
+            norm.append(a)
+        else:
+            norm.append({"name": getattr(a, "name", "") or ""})
+    return norm
+
+
+def litpaper_to_api_paper(p: LitPaper) -> Paper:
+    d = p.to_dict()
+    d["journal"] = _abbrev(d.get("journal"))
+    try:
+        return Paper.model_validate(d)
+    except Exception:
+        d = p.to_dict()
+        src = d.get("source") or "unknown"
+        try:
+            ps = PaperSource(src)
+        except ValueError:
+            ps = PaperSource.UNKNOWN
+        rs = d.get("read_status") or "unread"
+        try:
+            rse = ReadStatus(rs)
+        except ValueError:
+            rse = ReadStatus.UNREAD
+        return Paper(
+            id=d.get("id"),
+            title=d.get("title", ""),
+            authors=[
+                Author(**a) if isinstance(a, dict) else Author(name=str(a)) for a in d.get("authors", [])
+            ],
+            abstract=d.get("abstract"),
+            doi=d.get("doi"),
+            pmid=d.get("pmid"),
+            arxiv_id=d.get("arxiv_id"),
+            pmc_id=d.get("pmc_id"),
+            journal=_abbrev(d.get("journal")),
+            year=d.get("year"),
+            volume=d.get("volume"),
+            issue=d.get("issue"),
+            pages=d.get("pages"),
+            publisher=d.get("publisher"),
+            pdf_url=d.get("pdf_url"),
+            source_url=d.get("source_url"),
+            local_pdf_path=d.get("local_pdf_path"),
+            keywords=d.get("keywords") or [],
+            mesh_terms=d.get("mesh_terms") or [],
+            references=d.get("references") or [],
+            citations=d.get("citations") or 0,
+            source=ps,
+            relevance_score=d.get("relevance_score") or 0,
+            notes=d.get("notes"),
+            tags=d.get("tags") or [],
+            category=d.get("category"),
+            rating=d.get("rating"),
+            read_status=rse,
+            importance=d.get("importance") or "normal",
+        )
+
+
+def api_paper_to_litpaper(p: Paper) -> LitPaper:
+    d = p.model_dump(mode="json", exclude_none=False, exclude_unset=False)
+    d.pop("id", None)
+    d.pop("local_pdf_path", None)
+    d.pop("category", None)
+    d.pop("created_at", None)
+    d.pop("updated_at", None)
+    d["source"] = p.source.value
+    d["read_status"] = p.read_status.value
+    return LitPaper.from_dict(d)
+
+
+def normalize_papers_for_api(papers: Iterable[Any] | None) -> list[Paper]:
+    """统一 API 层 Paper 列表:接受 Paper / LitPaper / dict,返回校验后的 list[Paper]。"""
+    if not papers:
+        return []
+    blobs: list[Any] = []
+    for raw in papers:
+        if isinstance(raw, Paper):
+            blobs.append(raw)
+            continue
+        if isinstance(raw, LitPaper):
+            blobs.append(litpaper_to_api_paper(raw))
+            continue
+        d = raw.model_dump() if hasattr(raw, "model_dump") else (dict(raw) if isinstance(raw, dict) else None)
+        if not d or not str(d.get("title") or "").strip():
+            continue
+        d["authors"] = _normalize_author_entries(d.get("authors") or [])
+        d["source"] = _coerce_paper_source(d.get("source"))
+        if "journal" not in d and d.get("venue") is not None:
+            d["journal"] = d.get("venue")
+        if "source_url" not in d and d.get("url") is not None:
+            d["source_url"] = d.get("url")
+        blobs.append(d)
+    try:
+        return _paper_list_adapter.validate_python(blobs, strict=False)
+    except Exception as ex:
+        logger.exception("paper normalization failed")
+        raise ValueError("paper_normalization_failed") from ex
+
+
+def litpapers_to_api_papers(papers: Iterable[LitPaper]) -> list[Paper]:
+    return normalize_papers_for_api([litpaper_to_api_paper(p) for p in papers])

+ 47 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/papers_helpers.py

@@ -0,0 +1,47 @@
+"""论文辅助函数 —— arXiv ID 标准化与论文元数据提取."""
+
+from typing import Any
+
+from ...utils import normalize_arxiv_id
+
+def daily_paper_identity_sig(p: Any) -> str:
+    ax = normalize_arxiv_id(getattr(p, "arxiv_id", None))
+    if ax:
+        return f"arxiv:{ax}"
+    doi = (getattr(p, "doi", None) or "").strip().lower()
+    if doi:
+        return f"doi:{doi}"
+    t = (getattr(p, "title", None) or "").strip().lower()
+    y = int(getattr(p, "year", 0) or 0)
+    return f"ty:{t}|{y}"
+
+def graph_author_node_id(paper_id: int, author_index: int, author: Any) -> str:
+    raw_orcid = getattr(author, "orcid", None)
+    orc = None
+    if raw_orcid:
+        s = str(raw_orcid).strip().lower()
+        for prefix in ("https://orcid.org/", "http://orcid.org/"):
+            if s.startswith(prefix):
+                s = s[len(prefix):]
+        s = s.strip().rstrip("/")
+        if len(s) >= 10:
+            orc = s
+    if orc:
+        return f"author:o:{orc}"
+    try:
+        aid = getattr(author, "db_id", None)
+        if aid is not None and int(aid) > 0:
+            return f"author:db:{int(aid)}"
+    except Exception:
+        pass
+    return f"author:p:{int(paper_id)}:{int(author_index)}"
+
+def graph_author_label(author: Any, author_index: int, node_id: str) -> str:
+    name = (getattr(author, "name", None) or "").strip() or "Unknown"
+    if not str(node_id).startswith("author:p:"):
+        return name[:120]
+    aff = (getattr(author, "affiliation", None) or "").strip()
+    if aff:
+        short = aff[:26] + ("…" if len(aff) > 26 else "")
+        return f"{name[:80]} ({short})"
+    return f"{name[:80]} (#{author_index + 1})"

+ 361 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/papers/papers_library_service.py

@@ -0,0 +1,361 @@
+"""文献库管理服务 —— 论文存储、分类、标签、阅读记录与 PDF 下载管理."""
+
+import logging
+import os
+import time
+
+from fastapi import BackgroundTasks, HTTPException, Request
+
+from ...settings import get_settings
+from ...models.schemas import (
+    DeletePaperResponse,
+    LibraryCategoriesResponse,
+    LibraryCategoryFolder,
+    Paper,
+    PapersResponse,
+    SavePapersRequest,
+    SavePapersResponse,
+    UpdatePaperRequest,
+    UpdatePaperResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+def _merge_tag_lists(base: list[str], extra: list[str], max_n: int = 24) -> list[str]:
+    out: list[str] = []
+    seen: set[str] = set()
+    for t in (base or []) + (extra or []):
+        k = (t or "").strip()
+        if not k:
+            continue
+        low = k.lower()
+        if low in seen:
+            continue
+        seen.add(low)
+        out.append(k)
+        if len(out) >= max_n:
+            break
+    return out
+
+def list_library_categories(*, db) -> LibraryCategoriesResponse:
+    try:
+        from app.core.paper_paths import LIBRARY_PDF_ROOT_DIR
+
+        items = db.list_library_category_folders()
+        return LibraryCategoriesResponse(
+            success=True,
+            store_root=LIBRARY_PDF_ROOT_DIR,
+            folders=[LibraryCategoryFolder(**x) for x in items],
+        )
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+def get_library(
+    *,
+    db,
+    litpaper_to_api_paper_fn,
+    limit: int = 50,
+    offset: int = 0,
+    q: str | None = None,
+    year_from: int | None = None,
+    year_to: int | None = None,
+    read_status=None,
+    tags: str | None = None,
+    category: str | None = None,
+) -> PapersResponse:
+    try:
+
+        tag_list = [t.strip() for t in tags.split(",")] if tags else None
+        cat = (category or "").strip() or None
+        if q or year_from or year_to or read_status or tag_list or cat:
+            papers_data = db.search_library(
+                query=q,
+                tags=tag_list,
+                year_from=year_from,
+                year_to=year_to,
+                read_status=read_status.value if read_status else None,
+                category=cat,
+                limit=limit,
+                offset=offset,
+            )
+        else:
+            papers_data = db.get_all_papers(limit=limit, offset=offset, order_by="created_at DESC")
+
+        ids_missing_pdf = [
+            int(p.id)
+            for p in papers_data
+            if p.id is not None and not (getattr(p, "local_pdf_path", None) or "").strip()
+        ]
+        if ids_missing_pdf:
+            repaired = db.repair_library_local_pdf_paths_batch(ids_missing_pdf)
+            for p in papers_data:
+                if p.id is not None and int(p.id) in repaired:
+                    p.local_pdf_path = repaired[int(p.id)]
+
+        total = db.count_papers() if not (q or year_from or year_to or read_status or tag_list or cat) else len(papers_data) + (1 if len(papers_data) >= limit else 0)
+        papers = [litpaper_to_api_paper_fn(p) for p in papers_data]
+        return PapersResponse(success=True, total=total or len(papers), papers=papers)
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+async def save_papers(
+    *,
+    db,
+    request: SavePapersRequest,
+    background_tasks: BackgroundTasks,
+    api_to_lit_fn,
+    litpaper_to_api_paper_fn,
+) -> SavePapersResponse:
+    try:
+        t0 = time.perf_counter()
+        from ..graph.kg_relations import build_relations_for_new_paper
+        from app.core.paper_paths import (
+            LIBRARY_PDF_ROOT_DIR,
+            library_pdf_relative_path,
+            normalize_library_category_display,
+        )
+        from app.core.pdf_download import download_paper_pdf_to_path, resolve_paper_pdf_url
+
+        lit_list = [api_to_lit_fn(p) for p in request.papers]
+        for api_p, lit_p in zip(request.papers, lit_list):
+            if (api_p.source_url or "").strip():
+                lit_p.source_url = (api_p.source_url or "").strip()
+            if (api_p.pdf_url or "").strip():
+                lit_p.pdf_url = (api_p.pdf_url or "").strip()
+            if (api_p.doi or "").strip():
+                lit_p.doi = (api_p.doi or "").strip()
+            if (api_p.arxiv_id or "").strip():
+                lit_p.arxiv_id = (api_p.arxiv_id or "").strip()
+            # Fill missing arXiv ID from DOI/source URL.
+            if not (getattr(lit_p, "arxiv_id", None) or "").strip():
+                import re
+                for field_val in ((api_p.doi or "").strip(), (api_p.source_url or "").strip()):
+                    m = re.search(r"arxiv/([\d.]+)", field_val, re.I)
+                    if m:
+                        lit_p.arxiv_id = m.group(1)
+                        break
+            # Infer venue type from journal name when absent.
+            if not (getattr(lit_p, "venue_type", None) or "").strip():
+                j = (getattr(lit_p, "journal", None) or "").strip()
+                if j:
+                    import re
+                    if re.search(r"(?i)\b(journal|transactions|letters|magazine|review|annals|acta|bulletin)\b", j):
+                        lit_p.venue_type = "journal"
+                    else:
+                        lit_p.venue_type = "conference"
+
+            # Fill missing DOI from source URL.
+            if not (getattr(lit_p, "doi", None) or "").strip():
+                su = (api_p.source_url or "").strip()
+                import re
+                m = re.search(r"doi\.org/(10\.\S+)", su, re.I)
+                if m:
+                    lit_p.doi = m.group(1)
+
+            if (api_p.category or "").strip():
+                lit_p.category = normalize_library_category_display(api_p.category)
+
+        llm_classified = 0
+
+        if lit_list and getattr(request, "llm_classify", True):
+            try:
+                existing_categories = []
+                try:
+                    existing_categories = db.list_library_categories_by_count(limit=80)
+                except Exception:
+                    existing_categories = []
+
+                from ...agents import get_paper_analysis_agent
+
+                agent = get_paper_analysis_agent()
+                for lit_p in lit_list:
+                    cat, extra = agent.classify_for_library(
+                        lit_p.title,
+                        lit_p.abstract,
+                        lit_p.journal,
+                        getattr(lit_p, "keywords", None) or [],
+                        existing_categories=existing_categories,
+                    )
+                    lit_p.category = cat
+                    lit_p.tags = _merge_tag_lists(lit_p.tags or [], extra)
+                    if not getattr(lit_p, "venue_type", None):
+                        lit_p.venue_type = agent.classify_venue_type(lit_p.journal)
+                    llm_classified += 1
+            except Exception as e:
+                logger.warning("大模型归类未执行:%s", e)
+                for lit_p in lit_list:
+                    lit_p.category = normalize_library_category_display(getattr(lit_p, "category", None))
+        elif lit_list:
+
+            for lit_p in lit_list:
+                lit_p.category = normalize_library_category_display(getattr(lit_p, "category", None))
+
+        for lit_p in lit_list:
+            lit_p.category = normalize_library_category_display(getattr(lit_p, "category", None))
+
+        t_after_classify = time.perf_counter()
+
+        # Backfill missing abstracts via Tavily.
+        for lit_p in lit_list:
+            if not (lit_p.abstract or "").strip() and lit_p.title:
+                try:
+                    from ...settings import get_settings as _gs
+                    _ak = getattr(_gs(), "tavily_api_key", "").strip()
+                    if _ak:
+                        import httpx
+                        _resp = httpx.post("https://api.tavily.com/search", json={
+                            "api_key": _ak, "query": f"{lit_p.title} paper abstract", "max_results": 3}, timeout=15.0)
+                        for _it in (_resp.json().get("results") or []):
+                            if len(_it.get("content","")) > 100:
+                                lit_p.abstract = _it["content"][:2000]
+                                break
+                except Exception: pass
+
+        ids, added_new, updated_existing = db.add_papers(lit_list)
+        t_after_db = time.perf_counter()
+
+        t_after_memory = time.perf_counter()
+
+        for pid in ids or []:
+            try:
+                if pid is None or int(pid) <= 0:
+                    continue
+                build_relations_for_new_paper(db.db_path, int(pid))
+            except Exception:
+                continue
+
+        pdf_downloaded = 0
+        if request.download_pdfs and lit_list and ids:
+            s = get_settings()
+            mail = (s.ncbi_email or "").strip()
+            data_root = os.path.dirname(os.path.abspath(db.db_path))
+            os.makedirs(os.path.join(data_root, LIBRARY_PDF_ROOT_DIR), exist_ok=True)
+            for lit_p, pid in zip(lit_list, ids):
+                if pid is None or pid < 0:
+                    continue
+                relpath = library_pdf_relative_path(
+                    getattr(lit_p, "category", None), int(pid), getattr(lit_p, "title", None)
+                )
+                dest = os.path.join(data_root, relpath)
+                os.makedirs(os.path.dirname(dest), exist_ok=True)
+                try:
+                    resolved = resolve_paper_pdf_url(lit_p, email=mail)
+                except Exception as ex:
+                    logger.warning("解析 PDF 链接异常(已跳过该条 PDF): %s", ex, exc_info=True)
+                    resolved = None
+                if not resolved:
+                    logger.warning(
+                        "保存跳过 PDF:无可用链接 title=%r doi=%r",
+                        lit_p.title,
+                        lit_p.doi,
+                    )
+                if os.path.isfile(dest) and os.path.getsize(dest) >= 256:
+                    db.set_local_pdf_path(int(pid), relpath)
+                    pdf_downloaded += 1
+                    continue
+                if resolved and download_paper_pdf_to_path(lit_p, dest, email=mail):
+                    db.set_local_pdf_path(int(pid), relpath)
+                    pdf_downloaded += 1
+                elif resolved:
+                    logger.warning("保存 PDF 下载失败 title=%r", lit_p.title)
+        t_after_pdf = time.perf_counter()
+
+        ids_ok = [int(x) for x in ids if x is not None and int(x) >= 0]
+        if ids_ok:
+            need_repair = []
+            for pid in ids_ok:
+                row = db.get_paper_by_id(pid)
+                if row and not (getattr(row, "local_pdf_path", None) or "").strip():
+                    need_repair.append(pid)
+            if need_repair:
+                db.repair_library_local_pdf_paths_batch(need_repair)
+
+        msg = None
+        if (
+            request.download_pdfs
+            and lit_list
+            and pdf_downloaded == 0
+            and any(pid is not None and pid >= 0 for pid in ids)
+        ):
+            msg = "未能写入本地 PDF:请确认含 arXiv / pdf_url 等可下载链接。"
+
+        logger.info(
+            "POST /api/papers/save timing total=%.3fs classify=%.3fs db=%.3fs memory=%.3fs pdf=%.3fs"
+            " papers=%d llm_classify=%s download_pdfs=%s",
+            (t_after_pdf - t0),
+            (t_after_classify - t0),
+            (t_after_db - t_after_classify),
+            (t_after_memory - t_after_db),
+            (t_after_pdf - t_after_memory),
+            len(lit_list),
+            getattr(request, "llm_classify", True),
+            getattr(request, "download_pdfs", False),
+        )
+
+        return SavePapersResponse(
+            success=True,
+            added=int(added_new),
+            updated=int(updated_existing),
+            ids=ids,
+            pdf_downloaded=pdf_downloaded,
+            llm_classified=llm_classified,
+            message=msg,
+        )
+    except Exception as e:
+        logger.exception("POST /api/papers/save 失败")
+        raise HTTPException(status_code=500, detail=str(e))
+
+def get_paper_by_id(*, db, paper_id: int, litpaper_to_api_paper_fn) -> Paper:
+    p = db.get_paper_by_id(paper_id)
+    if not p:
+        raise HTTPException(status_code=404, detail="文献不存在")
+
+    if p.id is not None and not (getattr(p, "local_pdf_path", None) or "").strip():
+        repaired = db.repair_library_local_pdf_paths_batch([int(p.id)])
+        if repaired:
+            p2 = db.get_paper_by_id(paper_id)
+            if p2:
+                p = p2
+    return litpaper_to_api_paper_fn(p)
+
+def update_paper_by_id(*, db, paper_id: int, body: UpdatePaperRequest) -> UpdatePaperResponse:
+    try:
+        from app.core.paper_paths import normalize_library_category_display
+
+        fields = {}
+        if body.notes is not None:
+            fields["notes"] = body.notes
+        if body.tags is not None:
+            fields["tags"] = body.tags
+        if body.category is not None:
+            fields["category"] = normalize_library_category_display(body.category)
+        if body.rating is not None:
+            fields["rating"] = body.rating
+        if body.read_status is not None:
+            fields["read_status"] = body.read_status.value
+        if body.importance is not None:
+            fields["importance"] = body.importance
+        ok = db.update_paper(paper_id, **fields)
+        if not ok:
+            raise HTTPException(status_code=404, detail="未更新或文献不存在")
+        return UpdatePaperResponse(success=True, updated_fields=list(fields.keys()))
+    except HTTPException:
+        raise
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+def delete_paper_by_id(*, db, paper_id: int) -> DeletePaperResponse:
+    ok = db.delete_paper(paper_id)
+    if not ok:
+        raise HTTPException(status_code=404, detail="文献不存在")
+    return DeletePaperResponse(success=True, message="已删除")
+
+def build_library_pdf_response_service(*, paper_id: int, request: Request, db_path: str, logger_obj):
+    from ..pdf.pdf_service import build_library_pdf_response
+
+    return build_library_pdf_response(
+        paper_id=int(paper_id),
+        request=request,
+        db_path=db_path,
+        logger=logger_obj,
+    )

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/pdf/__init__.py


+ 124 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/pdf/pdf_service.py

@@ -0,0 +1,124 @@
+"""PDF 处理服务 —— MuPDF 文本提取、元数据解析与文件管理."""
+
+from __future__ import annotations
+
+import email.utils
+import hashlib
+import os
+import re
+from typing import Any
+
+from fastapi import HTTPException
+from starlette.requests import Request
+from starlette.responses import Response, StreamingResponse
+
+from ...settings import get_settings
+
+def _iter_file(path: str):
+    with open(path, "rb") as f:
+        while True:
+            chunk = f.read(1024 * 256)
+            if not chunk:
+                break
+            yield chunk
+
+def build_library_pdf_response(*, paper_id: int, request: Request, db_path: str, logger: Any) -> Response:
+    from ...core.storage import PaperDatabase
+    path = PaperDatabase(db_path).get_library_pdf_abspath(paper_id)
+    if not path or not os.path.isfile(path):
+        raise HTTPException(status_code=404, detail="本地 PDF 不存在")
+
+    data_root = os.path.realpath(os.path.abspath(get_settings().data_dir))
+    real_path = os.path.realpath(os.path.abspath(path))
+    if real_path != data_root and not real_path.startswith(data_root + os.sep):
+        logger.warning(
+            "PDF 路径安全检查失败: paper_id=%d, path=%s, data_root=%s",
+            int(paper_id),
+            path,
+            data_root,
+        )
+        raise HTTPException(status_code=403, detail="非法文件路径")
+
+    st = os.stat(path)
+    file_size = int(st.st_size)
+    mtime = int(st.st_mtime)
+    range_header = request.headers.get("range") or request.headers.get("Range")
+    if_none_match = (request.headers.get("if-none-match") or request.headers.get("If-None-Match") or "").strip()
+    if_modified_since = (
+        request.headers.get("if-modified-since") or request.headers.get("If-Modified-Since") or ""
+    ).strip()
+    if_range = (request.headers.get("if-range") or request.headers.get("If-Range") or "").strip()
+
+    etag_raw = f"{path}|{mtime}|{file_size}".encode("utf-8", "ignore")
+    etag = 'W/"' + hashlib.sha1(etag_raw).hexdigest() + '"'
+    last_modified = email.utils.formatdate(mtime, usegmt=True)
+
+    common_headers: dict[str, str] = {
+        "Content-Disposition": f"inline; filename=paper-{int(paper_id)}.pdf",
+        "Accept-Ranges": "bytes",
+        "Access-Control-Allow-Origin": "*",
+        "Access-Control-Allow-Methods": "GET, OPTIONS",
+        "Access-Control-Allow-Headers": "*",
+        "Access-Control-Expose-Headers": "Accept-Ranges, Content-Range, Content-Length, ETag, Last-Modified",
+        "Cache-Control": "public, max-age=3600",
+        "ETag": etag,
+        "Last-Modified": last_modified,
+    }
+
+    if not range_header:
+        try:
+            if if_none_match and if_none_match == etag:
+                return Response(status_code=304, headers=common_headers)
+            if if_modified_since:
+                ims_ts = email.utils.parsedate_to_datetime(if_modified_since).timestamp()
+                if int(ims_ts) >= mtime:
+                    return Response(status_code=304, headers=common_headers)
+        except Exception:
+            pass
+
+    if not range_header:
+        return StreamingResponse(
+            _iter_file(path),
+            media_type="application/pdf",
+            headers={**common_headers, "Content-Length": str(file_size)},
+        )
+
+    if if_range:
+        ok = if_range in (etag, last_modified)
+        if not ok:
+            return StreamingResponse(
+                _iter_file(path),
+                media_type="application/pdf",
+                headers={**common_headers, "Content-Length": str(file_size)},
+            )
+
+    m = re.match(r"bytes=(\d+)-(\d*)", range_header.strip())
+    if not m:
+        return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
+    start = int(m.group(1))
+    end = int(m.group(2)) if m.group(2) else file_size - 1
+    if start >= file_size:
+        return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
+    end = min(end, file_size - 1)
+    if end < start:
+        return Response(status_code=416, headers={**common_headers, "Content-Range": f"bytes */{file_size}"})
+
+    length = end - start + 1
+
+    def iter_range():
+        with open(path, "rb") as f:
+            f.seek(start)
+            remaining = length
+            while remaining > 0:
+                chunk = f.read(min(1024 * 256, remaining))
+                if not chunk:
+                    break
+                remaining -= len(chunk)
+                yield chunk
+
+    headers = {
+        **common_headers,
+        "Content-Range": f"bytes {start}-{end}/{file_size}",
+        "Content-Length": str(length),
+    }
+    return StreamingResponse(iter_range(), status_code=206, media_type="application/pdf", headers=headers)

+ 1 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/__init__.py

@@ -0,0 +1 @@
+

+ 136 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_artifact.py

@@ -0,0 +1,136 @@
+"""阅读产物管理 —— 导读摘要、术语表等 AI 生成内容的持久化."""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+from typing import Any
+
+from .paper_reader_structure import parse_pdf_merged_text_to_json
+
+ARTIFACT_VERSION = 1
+ARTIFACT_DIR = "reader_artifacts"
+
+
+def reader_artifact_path(db_path: str, paper_id: int) -> str:
+    data_root = os.path.dirname(os.path.abspath(db_path or "."))
+    return os.path.join(data_root, ARTIFACT_DIR, f"paper_{int(paper_id)}.json")
+
+
+def _pdf_stat(pdf_abspath: str | None) -> dict[str, int]:
+    if not pdf_abspath or not os.path.isfile(pdf_abspath):
+        return {}
+    try:
+        st = os.stat(pdf_abspath)
+        return {"mtime": int(st.st_mtime), "size": int(st.st_size)}
+    except Exception:
+        return {}
+
+
+def _paper_meta(paper: Any) -> dict[str, Any]:
+    authors = [
+        (getattr(a, "name", None) or "").strip()
+        for a in (getattr(paper, "authors", None) or [])
+        if (getattr(a, "name", None) or "").strip()
+    ]
+    return {
+        "id": getattr(paper, "id", None),
+        "title": (getattr(paper, "title", None) or "").strip(),
+        "authors": authors,
+        "year": getattr(paper, "year", None),
+        "venue": (getattr(paper, "journal", None) or "").strip(),
+        "doi": (getattr(paper, "doi", None) or "").strip(),
+        "arxiv_id": (getattr(paper, "arxiv_id", None) or "").strip(),
+        "abstract": (getattr(paper, "abstract", None) or "").strip(),
+        "keywords": [str(x) for x in (getattr(paper, "keywords", None) or [])[:32] if str(x).strip()],
+    }
+
+
+def load_reader_artifact(db_path: str, paper_id: int, pdf_abspath: str | None = None) -> dict[str, Any] | None:
+    path = reader_artifact_path(db_path, paper_id)
+    if not os.path.isfile(path):
+        return None
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            obj = json.load(f)
+    except Exception:
+        return None
+    if int(obj.get("version") or 0) != ARTIFACT_VERSION:
+        return None
+    current = _pdf_stat(pdf_abspath)
+    saved = obj.get("pdf") or {}
+    if current and (int(saved.get("mtime") or 0) != current["mtime"] or int(saved.get("size") or 0) != current["size"]):
+        return None
+    return obj
+
+
+def build_reader_artifact(
+    db_path: str,
+    paper_id: int,
+    paper: Any,
+    pdf_text: str,
+    pdf_abspath: str | None = None,
+) -> dict[str, Any] | None:
+    text = (pdf_text or "").strip()
+    if len(text) < 200:
+        return None
+
+    parsed = parse_pdf_merged_text_to_json(text, max_chapter_chars=9000, max_chapters=40, max_ref_entries=100)
+    artifact = {
+        "version": ARTIFACT_VERSION,
+        "generated_at": int(time.time()),
+        "paper": _paper_meta(paper),
+        "pdf": _pdf_stat(pdf_abspath),
+        "structure": parsed,
+    }
+    path = reader_artifact_path(db_path, paper_id)
+    try:
+        os.makedirs(os.path.dirname(path), exist_ok=True)
+        tmp = path + ".tmp"
+        with open(tmp, "w", encoding="utf-8") as f:
+            json.dump(artifact, f, ensure_ascii=False, indent=2)
+        os.replace(tmp, path)
+    except Exception:
+        return None
+    return artifact
+
+
+def ensure_reader_artifact(
+    db_path: str,
+    paper_id: int,
+    paper: Any,
+    pdf_text: str,
+    pdf_abspath: str | None = None,
+) -> dict[str, Any] | None:
+    cached = load_reader_artifact(db_path, paper_id, pdf_abspath)
+    if cached:
+        return cached
+    return build_reader_artifact(db_path, paper_id, paper, pdf_text, pdf_abspath)
+
+
+def format_reader_artifact_block(artifact: dict[str, Any] | None, *, max_chars: int = 9000) -> str:
+    if not artifact:
+        return ""
+    paper = artifact.get("paper") or {}
+    structure = artifact.get("structure") or {}
+    chapters = structure.get("chapters") or []
+    refs = (structure.get("references") or {}).get("entries") or []
+    lines = [
+        "【结构化阅读档案(由 PDF 自动解析生成;阅读助手优先依据此档案回答)】",
+        f"标题:{paper.get('title') or '—'}",
+        f"摘要:{paper.get('abstract') or '(无摘要)'}",
+        "章节:",
+    ]
+    for i, ch in enumerate(chapters[:14], start=1):
+        heading = (ch.get("heading") or f"Section {i}").strip()
+        text = " ".join(str(ch.get("text") or "").split())
+        snippet = text[:900]
+        tail = "..." if len(text) > 900 else ""
+        lines.append(f"{i}. {heading}\n{snippet}{tail}")
+    if refs:
+        lines.append("参考文献条目:")
+        for i, ref in enumerate(refs[:30], start=1):
+            lines.append(f"[{i}] {str(ref)[:420]}")
+    block = "\n".join(lines).strip()
+    return block[:max_chars]

+ 532 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_context.py

@@ -0,0 +1,532 @@
+"""阅读上下文管理 —— 论文正文、对话历史与工具调用结果的上下文组装."""
+
+from __future__ import annotations
+
+import os
+import re
+import sqlite3
+import time
+from typing import Any
+
+def extract_pdf_text_full(abspath: str | None) -> str:
+    if not abspath or not os.path.isfile(abspath):
+        return ""
+    best = ""
+
+    # Priority 1: pymupdf4llm Markdown — preserves tables, headings, structure
+    try:
+        import pymupdf4llm
+        best = (pymupdf4llm.to_markdown(abspath) or "").strip()
+    except Exception:
+        pass
+
+    # Priority 2: fitz plain text — only as fallback if Markdown is too short
+    try:
+        import fitz
+        doc = fitz.open(abspath)
+        pages: list[str] = []
+        for page in doc:
+            t = page.get_text("text")
+            if t:
+                pages.append(t.strip())
+        doc.close()
+        fitz_text = "\n\n".join(pages).strip()
+
+        # Prefer Markdown even if shorter (preserves tables), but fall back if
+        # Markdown is clearly broken (< 30% of fitz length and < 500 chars)
+        if not best or (len(best) < 500 and len(fitz_text) > len(best) * 3):
+            best = fitz_text
+    except Exception:
+        pass
+
+    if len(best) < 200:
+        try:
+            import fitz
+            doc = fitz.open(abspath)
+            blocks: list[str] = []
+            for page in doc:
+                for block in page.get_text("blocks") or []:
+                    if len(block) >= 5 and block[4].strip():
+                        blocks.append(str(block[4]).strip())
+            doc.close()
+            block_text = "\n".join(blocks).strip()
+            if len(block_text) > len(best):
+                best = block_text
+        except Exception:
+            pass
+    return best
+
+
+def extract_pdf_tables_markdown(abspath: str | None) -> str:
+    """Extract tables from PDF as Markdown. Uses fitz's built-in table detection first,
+    then falls back to pymupdf4llm."""
+    if not abspath or not os.path.isfile(abspath):
+        return ""
+    tables: list[str] = []
+
+    # Method 1: fitz page.find_tables() — best for structured tables
+    try:
+        import fitz
+        doc = fitz.open(abspath)
+        for page in doc:
+            try:
+                tabs = page.find_tables()
+            except Exception:
+                tabs = None
+            if tabs:
+                for tab in tabs:
+                    try:
+                        md = tab.to_markdown()
+                        if md and "|" in str(md) and len(str(md)) > 20:
+                            tables.append(str(md).strip())
+                    except Exception:
+                        pass
+        doc.close()
+    except Exception:
+        pass
+
+    if not tables:
+        # Method 2: pymupdf4llm Markdown — parses full doc
+        try:
+            import pymupdf4llm
+            md = (pymupdf4llm.to_markdown(abspath) or "").strip()
+            for m in re.finditer(r"(\|[^\n]+\|\n\|[-:| ]+\|\n(?:\|[^\n]+\|\n?)+)", md):
+                tables.append(m.group(1).strip())
+        except Exception:
+            pass
+
+    if not tables:
+        # Method 3: fitz text blocks — last resort
+        try:
+            import fitz
+            doc = fitz.open(abspath)
+            for page in doc:
+                blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
+                for block in blocks:
+                    if block.get("type") != 0:
+                        continue
+                    lines = block.get("lines", [])
+                    if len(lines) < 2:
+                        continue
+                    spans_by_line = [[s["text"] for s in ln["spans"]] for ln in lines]
+                    # Tabular data typically has 3+ aligned columns
+                    ncols = min(len(spans) for spans in spans_by_line)
+                    if ncols < 3:
+                        continue
+                    # Build markdown table by columns
+                    cols = [[] for _ in range(ncols)]
+                    for spans in spans_by_line:
+                        for j in range(ncols):
+                            cols[j].append(spans[j].strip())
+                    md_rows = [" | ".join(cols[i]) for i in range(ncols)]
+                    # Reformat: each original row → one markdown row
+                    nrows = len(cols[0])
+                    result = [" | ".join(str(cols[c][r]) for c in range(ncols)) for r in range(nrows)]
+                    result.insert(1, " | ".join("---" for _ in range(ncols)))
+                    tables.append("\n".join(result))
+            doc.close()
+        except Exception:
+            pass
+
+    return "\n\n".join(tables) if tables else ""
+
+def preprocess_pdf_text_for_reference_blob(blob: str) -> str:
+    try:
+        import ftfy
+        return ftfy.fix_text(blob or "")
+    except ImportError:
+        return (blob or "").replace("\r\n", "\n").replace("\r", "\n").replace("\f", "\n")
+
+def soft_unwrap_reference_section_newlines(blob: str) -> str:
+    s = preprocess_pdf_text_for_reference_blob(blob or "")
+
+    s = re.sub(r",\s*\n(?!\n)", ", ", s)
+
+    s = re.sub(r"(?<=[,.])\s*\n(?!\s*\n)\s*(?=[A-Za-z0-9(\u4e00-\u9fff])", " ", s)
+    s = re.sub(r"\n{4,}", "\n\n\n", s)
+    s = re.sub(r"[ \t]{2,}", " ", s)
+    return s.strip()
+
+def normalize_saved_reference_entry(text: str) -> str:
+    s = (text or "").strip()
+    if not s:
+        return ""
+    s = re.sub(r"([A-Za-z]{2,})-\s*\r?\n\s*([A-Za-z]{2,})", r"\1\2", s)
+    s = re.sub(r"([A-Za-z]{2,})-\s{1,3}([A-Za-z]{2,})", r"\1\2", s)
+    s = re.sub(r"[\s\u00a0\u2000-\u200b\u202f\u2060\ufeff]+", " ", s).strip()
+    return s
+
+_REF_SECTION = re.compile(
+    r"(?:^|\n)\s*(?:"
+    r"References|REFERENCES|Bibliography|BIBLIOGRAPHY|"
+    r"参考文献|引用文献|參考文獻"
+    r")\s*\n",
+    re.MULTILINE,
+)
+
+def extract_references_section_raw_from_pdf_text(pdf_text: str) -> str:
+    t = (pdf_text or "").strip()
+    if len(t) < 120:
+        return ""
+    m = _REF_SECTION.search(t)
+    body = t[m.end():].strip() if m else t[-min(len(t), 48000):]
+    body = soft_unwrap_reference_section_newlines(body)
+    return (body or "").strip()
+
+_REF_FALLBACK_ENTRY_HEAD = re.compile(
+    r"^(?:\[\d{1,3}\]\s*)?(?:[A-Z][a-zA-Z'\u2019\-]{1,42},\s+[A-Z.\-]|\d{1,3}\.\s+[A-Za-z0-9])"
+)
+_REF_FALLBACK_DOI_LINE = re.compile(r"^doi:\s*10\.\d", re.I)
+
+def reference_strings_for_resolve_fallback(section_raw: str, *, max_strings: int = 80) -> list[str]:
+    if not (section_raw or "").strip():
+        return []
+    text = soft_unwrap_reference_section_newlines(section_raw)
+    lines = [ln.strip() for ln in text.split("\n") if (ln or "").strip()]
+    out: list[str] = []
+    buf = ""
+    for ln in lines:
+        if re.match(r"^(figure|fig\.|table|tab\.|appendix|section)\b", ln, re.I):
+            if buf:
+                s = re.sub(r"\s+", " ", buf.strip())
+                if len(s) >= 28:
+                    out.append(normalize_saved_reference_entry(s)[:520])
+                buf = ""
+            continue
+        starts = bool(_REF_FALLBACK_ENTRY_HEAD.match(ln) or _REF_FALLBACK_DOI_LINE.match(ln))
+        if starts and buf:
+            s = re.sub(r"\s+", " ", buf.strip())
+            if len(s) >= 28:
+                out.append(normalize_saved_reference_entry(s)[:520])
+                if len(out) >= max_strings:
+                    break
+            buf = ln
+        elif starts and not buf:
+            buf = ln
+        else:
+            buf = (buf + " " + ln).strip() if buf else ln
+    if buf and len(out) < max_strings:
+        s = re.sub(r"\s+", " ", buf.strip())
+        if len(s) >= 28:
+            out.append(normalize_saved_reference_entry(s)[:520])
+    return out[:max_strings]
+
+def _ensure_cache_table(conn: sqlite3.Connection) -> None:
+    cur = conn.cursor()
+    cur.execute(
+        """
+        CREATE TABLE IF NOT EXISTS paper_pdf_excerpt_cache (
+          paper_id INTEGER PRIMARY KEY,
+          pdf_abspath TEXT,
+          pdf_mtime INTEGER,
+          pdf_size INTEGER,
+          excerpt TEXT,
+          updated_at INTEGER
+        )
+        """
+    )
+
+    cur.execute("PRAGMA table_info(paper_pdf_excerpt_cache)")
+    cols = [r[1] for r in cur.fetchall()]
+    if "hit_count" not in cols:
+        cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN hit_count INTEGER DEFAULT 0")
+    if "miss_count" not in cols:
+        cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN miss_count INTEGER DEFAULT 0")
+    if "last_hit_at" not in cols:
+        cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN last_hit_at INTEGER")
+    if "last_miss_at" not in cols:
+        cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN last_miss_at INTEGER")
+    conn.commit()
+
+def _pdf_stat(abspath: str) -> tuple[int, int | None]:
+    try:
+        st = os.stat(abspath)
+        return int(st.st_mtime), int(st.st_size)
+    except Exception:
+        return None
+
+def _cache_get(db_path: str, paper_id: int, pdf_abspath: str, max_age_days: int = 30) -> str | None:
+    if not db_path or not pdf_abspath:
+        return None
+    st = _pdf_stat(pdf_abspath)
+    if not st:
+        return None
+    mtime, size = st
+    now = int(time.time())
+    conn = None
+    try:
+        conn = sqlite3.connect(db_path)
+        cur = conn.cursor()
+        _ensure_cache_table(conn)
+        cur.execute(
+            "SELECT pdf_mtime,pdf_size,excerpt,updated_at FROM paper_pdf_excerpt_cache WHERE paper_id=?",
+            (int(paper_id),),
+        )
+        row = cur.fetchone()
+        if not row:
+            cur.execute(
+                "INSERT OR IGNORE INTO paper_pdf_excerpt_cache(paper_id,pdf_abspath,pdf_mtime,pdf_size,excerpt,updated_at,miss_count,last_miss_at) VALUES(?,?,?,?,?,?,?,?)",
+                (int(paper_id), pdf_abspath, mtime, size, "", 0, 1, now),
+            )
+            conn.commit()
+            return None
+        ok = int(row[0] or 0) == mtime and int(row[1] or 0) == size
+        ex = (row[2] or "").strip()
+        updated_at = int(row[3] or 0)
+        expired = bool(updated_at and max_age_days > 0 and (now - updated_at) > max_age_days * 86400)
+        if ok and ex and (not expired):
+            cur.execute(
+                "UPDATE paper_pdf_excerpt_cache SET hit_count=hit_count+1,last_hit_at=? WHERE paper_id=?",
+                (now, int(paper_id)),
+            )
+            conn.commit()
+            return ex
+
+        cur.execute(
+            "UPDATE paper_pdf_excerpt_cache SET miss_count=miss_count+1,last_miss_at=? WHERE paper_id=?",
+            (now, int(paper_id)),
+        )
+        conn.commit()
+        return None
+    except Exception:
+        return None
+    finally:
+        if conn:
+            conn.close()
+
+def _cache_set(db_path: str, paper_id: int, pdf_abspath: str, excerpt: str) -> None:
+    if not db_path or not pdf_abspath:
+        return
+    st = _pdf_stat(pdf_abspath)
+    if not st:
+        return
+    mtime, size = st
+    now = int(time.time())
+    conn = None
+    try:
+        conn = sqlite3.connect(db_path)
+        cur = conn.cursor()
+        _ensure_cache_table(conn)
+        cur.execute(
+            """
+            INSERT INTO paper_pdf_excerpt_cache(paper_id,pdf_abspath,pdf_mtime,pdf_size,excerpt,updated_at)
+            VALUES(?,?,?,?,?,?)
+            ON CONFLICT(paper_id) DO UPDATE SET
+              pdf_abspath=excluded.pdf_abspath,
+              pdf_mtime=excluded.pdf_mtime,
+              pdf_size=excluded.pdf_size,
+              excerpt=excluded.excerpt,
+              updated_at=excluded.updated_at
+            """,
+            (int(paper_id), pdf_abspath, mtime, size, excerpt or "", now),
+        )
+        conn.commit()
+    except Exception:
+        return
+    finally:
+        if conn:
+            conn.close()
+
+def extract_pdf_text_full_cached(db_path: str, paper_id: int, abspath: str | None, ) -> tuple[str, bool]:
+    if not abspath or not os.path.isfile(abspath):
+        return "", False
+    ex = _cache_get(db_path, int(paper_id), abspath, max_age_days=45)
+    if ex is not None:
+        return ex, True
+    return "", False
+
+def _cache_delete(db_path: str, paper_id: int) -> None:
+    if not db_path:
+        return
+    try:
+        conn = sqlite3.connect(db_path)
+        conn.execute("DELETE FROM paper_pdf_excerpt_cache WHERE paper_id=?", (int(paper_id),))
+        conn.commit()
+        conn.close()
+    except Exception:
+        pass
+
+def compute_and_cache_excerpt(db_path: str, paper_id: int, pdf_abspath: str) -> None:
+    ex = extract_pdf_text_full(pdf_abspath)
+    if ex.strip():
+        _cache_set(db_path, int(paper_id), pdf_abspath, ex)
+    else:
+
+        _cache_delete(db_path, int(paper_id))
+
+def _ensure_reader_pdf_available(db: Any, paper: Any) -> str | None:
+    """阅读页兜底:库内无 PDF 但有 arXiv/pdf_url 时,现取现存一份供上下文解析。"""
+    pid = getattr(paper, "id", None)
+    if pid is None:
+        return None
+    try:
+        existing = db.get_library_pdf_abspath(int(pid))
+        if existing:
+            return existing
+    except Exception:
+        return None
+
+    if not any((getattr(paper, "arxiv_id", None), getattr(paper, "pdf_url", None), getattr(paper, "source_url", None))):
+        return None
+
+    try:
+        from ...core.paper_paths import LIBRARY_PDF_ROOT_DIR, library_pdf_relative_path
+        from ...core.pdf_download import download_paper_pdf_to_path, resolve_paper_pdf_url
+        from ...settings import get_settings
+
+        relpath = library_pdf_relative_path(getattr(paper, "category", None), int(pid), getattr(paper, "title", None))
+        data_root = os.path.dirname(os.path.abspath(getattr(db, "db_path", "")))
+        dest = os.path.join(data_root, relpath)
+        os.makedirs(os.path.join(data_root, LIBRARY_PDF_ROOT_DIR), exist_ok=True)
+        os.makedirs(os.path.dirname(dest), exist_ok=True)
+
+        mail = (getattr(get_settings(), "ncbi_email", "") or "").strip()
+        if os.path.isfile(dest) and os.path.getsize(dest) >= 256:
+            db.set_local_pdf_path(int(pid), relpath)
+            return dest
+        if resolve_paper_pdf_url(paper, email=mail) and download_paper_pdf_to_path(paper, dest, email=mail):
+            db.set_local_pdf_path(int(pid), relpath)
+            return dest
+    except Exception:
+        return None
+    return None
+
+def build_reader_snap(paper: Any, *, pdf_text_for_references: str = "") -> dict[str, Any]:
+    refs_raw = getattr(paper, "references", None) or []
+    refs: list[str] = []
+    for r in refs_raw[:220]:
+        s = normalize_saved_reference_entry(str(r))
+        if len(s) >= 6:
+            refs.append(s)
+    refs_source = "db"
+    references_section_raw = ""
+    if not refs and (pdf_text_for_references or "").strip():
+        references_section_raw = extract_references_section_raw_from_pdf_text(pdf_text_for_references)
+        refs_source = "pdf_section" if references_section_raw else "none"
+    elif not refs:
+        refs_source = "none"
+    pid = getattr(paper, "id", None)
+    out: dict[str, Any] = {
+        "paper_id": int(pid) if pid is not None and int(pid) > 0 else None,
+        "title": (getattr(paper, "title", None) or "").strip(),
+        "doi": (getattr(paper, "doi", None) or "").strip(),
+        "arxiv_id": (getattr(paper, "arxiv_id", None) or "").strip(),
+        "abstract": (getattr(paper, "abstract", None) or "").strip(),
+        "keywords": [str(x) for x in (getattr(paper, "keywords", None) or [])[:32] if str(x).strip()],
+        "references": refs,
+        "references_source": refs_source,
+        "references_section_raw": references_section_raw,
+    }
+    ptf = (pdf_text_for_references or "").strip()
+    if len(ptf) >= 200:
+        out["_pdf_merged_for_structure"] = ptf
+    return out
+
+def format_paper_reader_block(
+    paper: Any,
+    pdf_excerpt: str,
+    *,
+    references_section_raw: str = "",
+    reader_artifact_block: str = "",
+) -> str:
+    authors = ", ".join((a.name or "").strip() for a in (paper.authors or []) if (a.name or "").strip())
+    lines = [
+        f"标题:{paper.title}",
+        f"作者:{authors or '—'}",
+        f"年份:{paper.year if paper.year is not None else '—'}",
+        f"来源/期刊:{(paper.journal or '').strip() or '—'}",
+        f"DOI:{(paper.doi or '').strip() or '—'}",
+        f"领域分类:{getattr(paper, 'category', None) or '—'}",
+        f"摘要:\n{(paper.abstract or '').strip() or '(无摘要)'}",
+    ]
+    kw = getattr(paper, "keywords", None) or []
+    if kw:
+        lines.append(f"关键词:{', '.join(str(x) for x in kw[:32])}")
+    refs = getattr(paper, "references", None) or []
+    if refs:
+        lines.append("【参考文献条目(库内保存的 references 列表;阅读助手仅从下列字符串解析并检索,不自由主题泛搜)】")
+        for i, r in enumerate(refs[:120], start=1):
+            s = normalize_saved_reference_entry(str(r))
+            if not s:
+                continue
+            lines.append(f"  [{i}] {s[:420]}")
+    elif (references_section_raw or "").strip():
+        raw = (references_section_raw or "").strip()
+        lines.append(
+            "【参考文献区 PDF 原文摘录(未程序切条;可先调 reader_pdf_structure 得 JSON 与 entries,"
+            "再对用户相关请求用 reader_paper_lookup 且 from_pdf_references_section=true)】"
+        )
+        lines.append(raw)
+    else:
+        lines.append(
+            "【参考文献】库表未保存结构化 references,且当前未能从 PDF 摘录中定位到参考文献标题后的文本。"
+            "可换带参考文献的数据源重新保存,或由用户粘贴英文题名 / DOI。"
+        )
+    if (reader_artifact_block or "").strip():
+        lines.append((reader_artifact_block or "").strip())
+    ex = (pdf_excerpt or "").strip()
+    if ex:
+        lines.append(
+            "【PDF 正文(结构化 Markdown;## 标记为自动识别的章节标题;精确内容以 PDF 视图为准)】\n"
+            + ex
+        )
+    return "\n".join(lines)
+
+def build_reader_context_for_paper(db: Any, paper_id: int) -> tuple[Any | None, str, str]:
+    p = db.get_paper_by_id(int(paper_id))
+    if not p:
+        return None, "", ""
+    pdf_path = _ensure_reader_pdf_available(db, p)
+    excerpt, is_cached = extract_pdf_text_full_cached(getattr(db, "db_path", ""), int(paper_id), pdf_path)
+    if pdf_path and not excerpt:
+        excerpt = extract_pdf_text_full(pdf_path)
+        if excerpt.strip():
+            _cache_set(getattr(db, "db_path", ""), int(paper_id), pdf_path, excerpt)
+    merged_for_refs = excerpt.strip()
+    refs_raw = ""
+    if not (getattr(p, "references", None) or []):
+        refs_raw = extract_references_section_raw_from_pdf_text(merged_for_refs) if merged_for_refs else ""
+    # DBLP 论文无摘要→Tavily 搜摘要+正文摘录(作为 PDF 替代)
+    if not (p.abstract or "").strip() and not excerpt.strip() and p.title:
+        try:
+            from ...settings import get_settings as _gs
+            _ak = getattr(_gs(), "tavily_api_key", "").strip()
+            if _ak:
+                import httpx
+                _resp = httpx.post("https://api.tavily.com/search", json={
+                    "api_key": _ak, "query": f"{p.title} paper",
+                    "max_results": 5, "include_answer": True, "search_depth": "advanced"}, timeout=20.0)
+                _resp.raise_for_status()
+                _parts = []
+                for _it in (_resp.json().get("results") or []):
+                    _c = _it.get("content", "")
+                    if _c and len(_c) > 80: _parts.append(_c)
+                _full = "\n\n".join(_parts)[:4000]
+                if _full:
+                    p.abstract = _full[:2000]
+                    excerpt = _full  # 替代 PDF 正文
+                    db.update_paper(p.id, abstract=p.abstract)
+        except Exception: pass
+    reader_artifact_block = ""
+    if merged_for_refs:
+        try:
+            from .paper_reader_artifact import ensure_reader_artifact, format_reader_artifact_block
+
+            artifact = ensure_reader_artifact(
+                getattr(db, "db_path", ""),
+                int(paper_id),
+                p,
+                merged_for_refs,
+                pdf_path,
+            )
+            reader_artifact_block = format_reader_artifact_block(artifact)
+        except Exception:
+            reader_artifact_block = ""
+    block = format_paper_reader_block(
+        p,
+        excerpt,
+        references_section_raw=refs_raw,
+        reader_artifact_block=reader_artifact_block,
+    )
+    pdf_parsing = False  # 后台异步解析,不阻塞用户
+    return p, block, merged_for_refs, pdf_parsing

+ 107 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_history.py

@@ -0,0 +1,107 @@
+"""阅读历史记录 —— 对话会话持久化、恢复与上下文延续."""
+
+from __future__ import annotations
+
+import sqlite3
+import time
+from contextlib import contextmanager
+
+from ...utils.common import exec_sql
+
+def ensure_tables(db_path: str) -> None:
+    exec_sql(db_path,
+        "CREATE TABLE IF NOT EXISTS paper_reader_turns(id INTEGER PRIMARY KEY AUTOINCREMENT,paper_id INTEGER NOT NULL,role TEXT NOT NULL,content TEXT NOT NULL,created_at INTEGER NOT NULL)",
+        "CREATE INDEX IF NOT EXISTS idx_paper_reader_turns_paper ON paper_reader_turns(paper_id,created_at)",
+    )
+
+@contextmanager
+def _conn(db_path: str, *, row_factory=None):
+    conn = sqlite3.connect(db_path)
+    if row_factory:
+        conn.row_factory = row_factory
+    try:
+        yield conn
+        conn.commit()
+    finally:
+        conn.close()
+
+def append_turn(db_path: str, *, paper_id: int, role: str, content: str) -> None:
+    ensure_tables(db_path)
+    role2 = (role or "").strip().lower()
+    if role2 not in ("user", "assistant"):
+        role2 = "user"
+    text = (content or "").strip()
+    if not text:
+        return
+    now = int(time.time())
+    with _conn(db_path) as conn:
+        conn.execute(
+            "INSERT INTO paper_reader_turns(paper_id,role,content,created_at) VALUES(?,?,?,?)",
+            (int(paper_id), role2, text, now),
+        )
+
+def prepend_turn(
+    db_path: str,
+    *,
+    paper_id: int,
+    role: str,
+    content: str,
+    before_created_at: int,
+) -> None:
+    ensure_tables(db_path)
+    role2 = (role or "").strip().lower()
+    if role2 not in ("user", "assistant"):
+        role2 = "user"
+    text = (content or "").strip()
+    if not text:
+        return
+    ts = int(before_created_at) - 1
+    if ts < 0:
+        ts = 0
+    now = int(time.time())
+    if ts >= now:
+        ts = now - 1
+    with _conn(db_path) as conn:
+        conn.execute(
+            "INSERT INTO paper_reader_turns(paper_id,role,content,created_at) VALUES(?,?,?,?)",
+            (int(paper_id), role2, text, ts),
+        )
+
+def ensure_opening_turn(db_path: str, *, paper_id: int, opening_text: str) -> None:
+    op = (opening_text or "").strip()
+    if not op:
+        return
+    turns = list_turns(db_path, paper_id=int(paper_id), limit=5)
+    if not turns:
+        append_turn(db_path, paper_id=int(paper_id), role="assistant", content=op)
+        return
+    first = turns[0]
+    r0 = (first.get("role") or "").strip().lower()
+    c0 = (first.get("content") or "").strip()
+    if r0 == "assistant" and c0 == op:
+        return
+    if r0 == "assistant":
+        return
+    if r0 == "user":
+        try:
+            ts0 = int(first.get("created_at") or 0)
+        except Exception:
+            ts0 = int(time.time())
+        prepend_turn(db_path, paper_id=int(paper_id), role="assistant", content=op, before_created_at=ts0)
+        return
+
+def list_turns(db_path: str, *, paper_id: int, limit: int = 200) -> list[dict[str, str | None]]:
+    ensure_tables(db_path)
+    with _conn(db_path, row_factory=sqlite3.Row) as conn:
+        rows = conn.execute(
+            "SELECT role,content,created_at FROM paper_reader_turns WHERE paper_id=? ORDER BY created_at ASC,id ASC LIMIT ?",
+            (int(paper_id), int(limit)),
+        ).fetchall()
+        out: list[dict[str, str | None]] = []
+        for r in rows:
+            out.append({
+                "role": (r["role"] or "").strip(),
+                "content": (r["content"] or "").strip(),
+                "created_at": int(r["created_at"] or 0),
+            })
+        return out

+ 243 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_service.py

@@ -0,0 +1,243 @@
+"""论文阅读服务 —— PDF 正文抽取、AI 导读生成、上下文对话与参考文献辅助."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+from collections.abc import Iterable
+
+from fastapi import BackgroundTasks, HTTPException
+from starlette.concurrency import run_in_threadpool
+
+from ...agents import get_paper_analysis_agent
+from ...agents.support.reader_reference_lookup_tool import READER_RELATED_FROM_BIBLIOGRAPHY, READER_RELATED_FROM_PRE_SEARCH
+from ...utils.common import suppress_exceptions_async
+
+logger = logging.getLogger(__name__)
+
+_OPENING_PROMPT = (
+    "请用中文写一段不超过 380 字的导读:研究问题、核心方法、实验与结论的阅读要点。"
+    "仅依据当前提供的摘要与摘录组织表述;勿单列「不确定处」「局限」或待查清单(用户追问时再说明材料范围即可)。"
+)
+_NO_HISTORY_PLACEHOLDER = "(尚无对话历史)"
+
+def _update_memory_from_turn(*, store: Any, paper_id: int, user_message: str, assistant_reply: str) -> None:
+    um = (user_message or "").strip()
+    if not um:
+        return
+    store.add(scope="paper", paper_id=paper_id, kind="working", content=f"用户问:{um[:220]}", importance=0.5)
+    try:
+        store.extract_memory_via_llm(paper_id, um, assistant_reply)
+    except Exception:
+        logger.debug("extract_memory_via_llm failed for paper %s", paper_id, exc_info=True)
+
+class PaperReaderService:
+    def __init__(self, db: Any, agent: Any | None = None) -> None:
+        self._db = db
+        self._agent = agent or get_paper_analysis_agent()
+
+    @property
+    def db(self) -> Any:
+        return self._db
+
+    @staticmethod
+    def _format_reader_history(turns: Iterable[Any]) -> str:
+        lines: list[str] = []
+        tail = list(turns or [])[-24:]
+        for t in tail:
+            role = (getattr(t, "role", None) or "").strip().lower()
+            content = (getattr(t, "content", None) or "").strip()
+            if not content:
+                continue
+            if role not in ("user", "assistant"):
+                role = "user"
+            label = "用户" if role == "user" else "助手"
+            lines.append(f"{label}:{content}")
+        return "\n\n".join(lines)
+
+    async def _build_reader_context(self, paper_id: int, user_message: str = "") -> tuple[Any, str, str]:
+        from ..memory.memory_store import MemoryStore
+        from .paper_reader_context import build_reader_context_for_paper
+
+        paper, base_ctx, pdf_ref_text, pdf_parsing = await run_in_threadpool(build_reader_context_for_paper, self._db, paper_id)
+        if not paper:
+            raise HTTPException(status_code=404, detail="文献不存在")
+
+        mem = await run_in_threadpool(
+            MemoryStore(self._db.db_path).build_context_block,
+            paper_id=paper_id,
+        )
+        title_hint = str(getattr(paper, "title", None) or "")
+        ctx = (base_ctx + ("\n\n" + mem if mem else "")).strip()
+        return paper, ctx, title_hint, pdf_ref_text, pdf_parsing
+
+    def _schedule_pdf_excerpt(self, paper_id: int, ctx: str, background_tasks: BackgroundTasks) -> None:
+        from .paper_reader_context import compute_and_cache_excerpt
+
+        try:
+            pdf_path = self._db.get_library_pdf_abspath(paper_id)
+            if pdf_path and "【PDF 正文摘录" not in ctx:
+                background_tasks.add_task(compute_and_cache_excerpt, self._db.db_path, paper_id, pdf_path)
+        except Exception as exc:
+            logger.debug("paper_reader.schedule_pdf_excerpt_failed", extra={"paper_id": paper_id}, exc_info=exc)
+
+    @suppress_exceptions_async(default_return=None, log_level="warning", log_message="paper_reader.ensure_opening_turn_failed")
+    async def _ensure_opening_turn_safe(self, *, paper_id: int, opening_text: str) -> None:
+        from .paper_reader_history import ensure_opening_turn
+
+        await run_in_threadpool(
+            ensure_opening_turn,
+            self._db.db_path,
+            paper_id=int(paper_id),
+            opening_text=opening_text,
+        )
+
+    @suppress_exceptions_async(default_return=None, log_level="warning", log_message="paper_reader.append_history_failed")
+    async def _append_history(self, *, paper_id: int, user_message: str, reply: str) -> None:
+        from .paper_reader_history import append_turn
+
+        await run_in_threadpool(
+            append_turn,
+            self._db.db_path,
+            paper_id=int(paper_id),
+            role="user",
+            content=user_message,
+        )
+        await run_in_threadpool(
+            append_turn,
+            self._db.db_path,
+            paper_id=int(paper_id),
+            role="assistant",
+            content=reply,
+        )
+
+    @suppress_exceptions_async(default_return=None, log_level="warning", log_message="paper_reader.memory_update_failed")
+    async def _update_memory(self, *, store: Any, paper_id: int, user_message: str, reply: str) -> None:
+        await run_in_threadpool(
+            _update_memory_from_turn,
+            store=store, paper_id=paper_id,
+            user_message=user_message, assistant_reply=reply,
+        )
+
+    async def get_opening(self, *, paper_id: int, background_tasks: BackgroundTasks) -> dict:
+        from .reader_opening_cache import get_cached_opening, set_cached_opening
+
+        from .paper_reader_context import build_reader_snap
+
+        paper, ctx, title_hint, pdf_ref_text, pdf_parsing = await self._build_reader_context(paper_id)
+
+        reader_snap = build_reader_snap(paper, pdf_text_for_references=pdf_ref_text)
+        try:
+            pdf_path = self._db.get_library_pdf_abspath(paper_id)
+            if pdf_path:
+                reader_snap["_pdf_abspath"] = pdf_path
+        except Exception:
+            pass
+        if pdf_parsing:
+            self._schedule_pdf_excerpt(paper_id, ctx, background_tasks)
+
+        cached, fresh = await run_in_threadpool(get_cached_opening, self._db.db_path, paper_id, 72)
+        if cached and fresh:
+            op = cached.strip()
+            await self._ensure_opening_turn_safe(paper_id=paper_id, opening_text=op)
+            return {"opening": op, "pdf_parsing": pdf_parsing}
+
+        if cached and not fresh:
+            def _refresh() -> None:
+                try:
+                    opening2, _, _ = self._agent.paper_reader_reply(
+                        ctx, _NO_HISTORY_PLACEHOLDER, _OPENING_PROMPT, reader_snap
+                    )
+                    set_cached_opening(self._db.db_path, paper_id, opening2.strip())
+                except Exception as exc:
+                    logger.warning("paper_reader.opening_refresh_failed", extra={"paper_id": paper_id}, exc_info=exc)
+
+            background_tasks.add_task(_refresh)
+            op = cached.strip()
+            await self._ensure_opening_turn_safe(paper_id=paper_id, opening_text=op)
+            return {"opening": op, "pdf_parsing": pdf_parsing}
+
+        opening, _, _ = await run_in_threadpool(
+            lambda: self._agent.paper_reader_reply(ctx, _NO_HISTORY_PLACEHOLDER, _OPENING_PROMPT, reader_snap)
+        )
+        op = opening.strip()
+        await run_in_threadpool(set_cached_opening, self._db.db_path, paper_id, op)
+        await self._ensure_opening_turn_safe(paper_id=paper_id, opening_text=op)
+        return {"opening": op, "pdf_parsing": pdf_parsing}
+
+    async def process_chat(
+        self,
+        *,
+        paper_id: int,
+        messages: list[Any],
+        user_message: str,
+        background_tasks: BackgroundTasks,
+    ) -> dict[str, Any]:
+        from ..memory.memory_store import MemoryStore
+
+        paper, ctx, title_hint, pdf_ref_text, pdf_parsing = await self._build_reader_context(paper_id, user_message)
+        from .paper_reader_context import build_reader_snap
+
+        reader_snap = build_reader_snap(paper, pdf_text_for_references=pdf_ref_text)
+        try:
+            pdf_path = self._db.get_library_pdf_abspath(paper_id)
+            if pdf_path:
+                reader_snap["_pdf_abspath"] = pdf_path
+        except Exception:
+            pass
+        self._schedule_pdf_excerpt(paper_id, ctx, background_tasks)
+
+        store = MemoryStore(self._db.db_path)
+        rel_mem = await run_in_threadpool(
+            store.get_context_for_query,
+            paper_id=int(paper_id),
+            query=user_message,
+            limit=6,
+        )
+        if rel_mem:
+            ctx += "\n\n" + rel_mem
+        hist = self._format_reader_history(messages)
+
+        reply, related_papers, related_sources = await run_in_threadpool(
+            lambda: self._agent.paper_reader_reply(ctx, hist, user_message, reader_snap)
+        )
+        rs = list(related_sources or [])
+        related_hints: list[dict[str, Any]] = [
+            {
+                "ref_idx": i,
+                "title": getattr(p, "title", None),
+                "reason": (
+                    "来自当前文献参考文献题录(OpenAlex 解析)"
+                    if i - 1 < len(rs) and rs[i - 1] == READER_RELATED_FROM_BIBLIOGRAPHY
+                    else "基于论文主题相似度匹配"
+                    if i - 1 < len(rs) and rs[i - 1] == READER_RELATED_FROM_PRE_SEARCH
+                    else "来自用户给定英文短语或外部题名检索(OpenAlex)"
+                ),
+            }
+            for i, p in enumerate(related_papers or [], start=1)
+        ]
+
+        await self._append_history(paper_id=paper_id, user_message=user_message, reply=reply)
+        await self._update_memory(store=store, paper_id=paper_id, user_message=user_message, reply=reply)
+        background_tasks.add_task(store.compress_working, scope="paper", paper_id=int(paper_id), min_entries=6)
+
+        return {
+            "reply": reply.strip(),
+            "pdf_parsing": pdf_parsing,
+            "related_papers": related_papers,
+            "related_hints": related_hints,
+            "kg_edges": [],
+        }
+
+    async def get_history(self, *, paper_id: int, limit: int) -> list[dict[str, Any]]:
+        from .paper_reader_history import list_turns
+
+        paper = await run_in_threadpool(self._db.get_paper_by_id, int(paper_id))
+        if not paper:
+            raise HTTPException(status_code=404, detail="文献不存在")
+        return await run_in_threadpool(
+            list_turns,
+            self._db.db_path,
+            paper_id=int(paper_id),
+            limit=int(limit),
+        )

+ 127 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/paper_reader_structure.py

@@ -0,0 +1,127 @@
+"""论文结构化解析 —— 章节分段、图表位置检测与结构树构建."""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from .paper_reader_context import (
+    extract_references_section_raw_from_pdf_text,
+    reference_strings_for_resolve_fallback,
+)
+
+_REF_HEADER = re.compile(
+    r"(?is)(?:^|\n)[\s#]*(?:references|reference\s+list|bibliography|cited\s+references|引用文献|参考文献)\s*[::]?\s*(?:\n+|$)",
+)
+
+_CHAPTER_LINE = re.compile(
+    r"(?m)^(?:\s|#)*(?:(?P<num>\d+(?:\.\d+){0,2})\.?\s+)?(?P<h>"
+    r"Abstract|ABSTRACT|Introduction|INTRODUCTION|Related\s+Work|RELATED\s+WORK|"
+    r"Background|BACKGROUND|Preliminar(?:y|ies)|Problem\s+Formulation|"
+    r"Methodology|Method|Methods|Model|Models|Approach|Architecture|Framework|"
+    r"Experiment(?:s)?|EXPERIMENTS|Implementation|Evaluation|Results?|RESULTS|Analysis|ANALYSIS|"
+    r"Discussion|DISCUSSION|Ablation|Ablations|Comparison|Comparisons|"
+    r"Conclusion|CONCLUSIONS?|Limitations?|LIMITATIONS|Future\s+Work|Broader\s+Impact|"
+    r"Appendix|APPENDIX|Supplementary|Acknowledg(?:e)?ments?|ACKNOWLEDG|"
+    r"摘要|引言|简介|预备|问题表述|相关工作|背景|方法|模型|架构|框架|"
+    r"实验|实现|评估|结果|分析|讨论|消融|对比|结论|局限|未来工作|附录|补充|致谢"
+    r")(?:\s*[.::#])?\s*$",
+    re.I,
+)
+
+def _slug_heading(h: str) -> str:
+    s = re.sub(r"[^\w\u4e00-\u9fff]+", "_", (h or "").strip().lower())
+    s = re.sub(r"_+", "_", s).strip("_")
+    return (s[:48] or "sec")
+
+def _split_chapters(
+    text: str,
+    *,
+    max_chapter_chars: int,
+    max_chapters: int,
+) -> list[dict[str, Any]]:
+    t = (text or "").strip()
+    if not t:
+        return []
+    matches = list(_CHAPTER_LINE.finditer(t))
+    if not matches:
+        return [
+            {
+                "id": "document",
+                "heading": "(未识别到标准章节标题)",
+                "text": t[:max_chapter_chars],
+                "truncated": len(t) > max_chapter_chars,
+            }
+        ]
+
+    out: list[dict[str, Any]] = []
+    p0 = matches[0].start()
+    if p0 > 40:
+        pre = t[:p0].strip()
+        if len(pre) >= 24:
+            out.append(
+                {
+                    "id": "preamble",
+                    "heading": "(文首)",
+                    "text": pre[:max_chapter_chars],
+                    "truncated": len(pre) > max_chapter_chars,
+                }
+            )
+
+    for i, m in enumerate(matches):
+        if len(out) >= max_chapters:
+            break
+        start = m.end()
+        end = matches[i + 1].start() if i + 1 < len(matches) else len(t)
+        heading = (m.group("h") or "section").strip()
+        body = t[start:end].strip()
+        if not body:
+            continue
+        slug = _slug_heading(heading)
+        out.append(
+            {
+                "id": f"{slug}_{i}",
+                "heading": heading,
+                "text": body[:max_chapter_chars],
+                "truncated": len(body) > max_chapter_chars,
+            }
+        )
+    return out
+
+def parse_pdf_merged_text_to_json(
+    merged_text: str,
+    *,
+    max_chapter_chars: int = 12000,
+    max_chapters: int = 24,
+    max_ref_entries: int = 80,
+) -> dict[str, Any]:
+    t = (merged_text or "").strip()
+    if not t:
+        return {
+            "version": 1,
+            "chapters": [],
+            "references": {"raw": "", "entries": [], "entry_count": 0},
+        }
+
+    m = _REF_HEADER.search(t)
+    head_for_chapters = t[: m.start()].strip() if m else t
+
+    ref_raw = extract_references_section_raw_from_pdf_text(t)
+    entries = reference_strings_for_resolve_fallback(ref_raw, max_strings=max_ref_entries) if ref_raw else []
+
+    chapters = _split_chapters(
+        head_for_chapters,
+        max_chapter_chars=max_chapter_chars,
+        max_chapters=max_chapters,
+    )
+
+    return {
+        "version": 1,
+        "chapters": chapters,
+        "references": {
+            "raw": (ref_raw or "")[:28000],
+            "raw_truncated": len(ref_raw or "") > 28000,
+            "entry_count": len(entries),
+            "entries": entries,
+        },
+    }

+ 66 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/reader_opening_cache.py

@@ -0,0 +1,66 @@
+"""论文打开缓存 —— 首次打开论文时的预处理结果缓存."""
+
+from __future__ import annotations
+
+import sqlite3
+import time
+from contextlib import contextmanager
+
+@contextmanager
+def _conn(db_path: str):
+    conn = sqlite3.connect(db_path)
+    try:
+        yield conn
+        conn.commit()
+    finally:
+        conn.close()
+
+def _ensure_table(conn: sqlite3.Connection) -> None:
+    conn.execute(
+        "CREATE TABLE IF NOT EXISTS paper_opening_cache(paper_id INTEGER PRIMARY KEY,opening TEXT,updated_at INTEGER,hit_count INTEGER DEFAULT 0,miss_count INTEGER DEFAULT 0,last_hit_at INTEGER,last_miss_at INTEGER)"
+    )
+
+def get_cached_opening(db_path: str, paper_id: int, max_age_hours: int = 72) -> tuple[str | None, bool]:
+    if not db_path:
+        return None, False
+    now = int(time.time())
+    try:
+        with _conn(db_path) as conn:
+            _ensure_table(conn)
+            row = conn.execute("SELECT opening,updated_at FROM paper_opening_cache WHERE paper_id=?", (int(paper_id),)).fetchone()
+            if not row:
+                conn.execute(
+                    "INSERT OR IGNORE INTO paper_opening_cache(paper_id,opening,updated_at,miss_count,last_miss_at) VALUES(?,?,?,?,?)",
+                    (int(paper_id), "", 0, 1, now),
+                )
+                return None, False
+            opening = (row[0] or "").strip()
+            updated_at = int(row[1] or 0)
+            fresh = bool(opening and updated_at and (now - updated_at) <= int(max_age_hours) * 3600)
+            if opening:
+                conn.execute(
+                    "UPDATE paper_opening_cache SET hit_count=hit_count+1,last_hit_at=? WHERE paper_id=?",
+                    (now, int(paper_id)),
+                )
+            else:
+                conn.execute(
+                    "UPDATE paper_opening_cache SET miss_count=miss_count+1,last_miss_at=? WHERE paper_id=?",
+                    (now, int(paper_id)),
+                )
+            return (opening or None), fresh
+    except Exception:
+        return None, False
+
+def set_cached_opening(db_path: str, paper_id: int, opening: str) -> None:
+    if not db_path:
+        return
+    now = int(time.time())
+    try:
+        with _conn(db_path) as conn:
+            _ensure_table(conn)
+            conn.execute(
+                "INSERT INTO paper_opening_cache(paper_id,opening,updated_at) VALUES(?,?,?) ON CONFLICT(paper_id) DO UPDATE SET opening=excluded.opening,updated_at=excluded.updated_at",
+                (int(paper_id), str(opening or "").strip(), now),
+            )
+    except Exception:
+        return

+ 238 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reader/reader_recommend_llm.py

@@ -0,0 +1,238 @@
+"""阅读推荐 —— 基于当前论文内容推荐相关文献."""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any
+
+from ...agents.support.reader_reference_lookup_tool import (
+    READER_RECOMMEND_MAX_RESULTS,
+    prioritize_reader_related_pairs_refs_first,
+)
+from ...utils import parse_llm_json, truncate_text
+from ..llm.llm_service import coerce_hello_agents_llm_output_to_str, get_llm, is_llm_configured
+from .paper_reader_context import preprocess_pdf_text_for_reference_blob
+
+logger = logging.getLogger(__name__)
+
+READER_BIB_SOURCES = frozenset({"bibliography", "ref_block"})
+
+def extract_title_queries_from_ref_blob_llm(
+    section_raw: str,
+    snap: dict[str, Any],
+    *,
+    max_queries: int = 12,
+) -> list[str]:
+    if not is_llm_configured() or not (section_raw or "").strip():
+        return []
+    blob = truncate_text((section_raw or "").strip(), 11000, suffix="...")
+    title = str(snap.get("title") or "").strip()
+    ab = truncate_text(str(snap.get("abstract") or "").strip(), 1200, suffix="...")
+    kw = snap.get("keywords") or []
+    kw_s = ", ".join(str(x) for x in kw[:20] if str(x).strip()) if isinstance(kw, (list, tuple)) else ""
+
+    system = (
+        "Extract English paper titles/phrases from the reference blob below for OpenAlex search. "
+        "Output JSON: {\"queries\":[...]}, max "
+        f"{max_queries} items, each 16-160 chars. "
+        "Each must be a contiguous substring of the reference blob (join lines with spaces). "
+        "Prefer long titles (>=22 chars); arXiv/DOI are OK as single items. "
+        "Skip journal names, venue-only lines, vol/pages, generic topics."
+    )
+    user = (
+        f"[Title] {title}\n[Abstract snippet] {ab}\n[Keywords] {kw_s}\n\n"
+        f"[Reference blob]\n{blob}\n"
+    )
+    try:
+        llm = get_llm()
+        raw = llm.invoke(
+            [
+                {"role": "system", "content": system},
+                {"role": "user", "content": user},
+            ]
+        )
+        text = coerce_hello_agents_llm_output_to_str(raw).strip()
+    except Exception as exc:
+        logger.debug("extract_title_queries_llm_invoke_failed", exc_info=exc)
+        return []
+
+    data = parse_llm_json(text)
+    if not isinstance(data, dict):
+        return []
+    arr = data.get("queries") or data.get("title_queries") or []
+    if not isinstance(arr, list):
+        return []
+    out: list[str] = []
+    seen: set[str] = set()
+    for x in arr:
+        q = re.sub(r"\s+", " ", str(x).strip())[:200]
+        if len(q) < 16:
+            continue
+
+        _nq = re.sub(r"\s+", " ", preprocess_pdf_text_for_reference_blob(q or "").lower()).strip()
+        _nr = re.sub(r"\s+", " ", preprocess_pdf_text_for_reference_blob(section_raw or "").lower()).strip()
+        if len(_nq) < 12 or len(_nr) < 40:
+            continue
+        if not (_nq in _nr or (len(_nq[:48]) >= 14 and _nq[:48] in _nr)):
+            _words = [w for w in re.findall(r"[a-z]{5,}", _nq) if len(w) >= 5][:8]
+            if not _words or sum(1 for w in _words if w in _nr) < max(2, int(len(_words) * 0.5)):
+                continue
+        k = q.lower()[:240]
+        if k in seen:
+            continue
+        seen.add(k)
+        out.append(q[:520])
+        if len(out) >= max_queries:
+            break
+    return out
+
+def merge_ref_lines_with_llm_queries(
+    section_raw: str,
+    snap: dict[str, Any],
+    base_lines: list[str],
+    *,
+    max_queries: int = 12,
+) -> list[str]:
+    llm_q = extract_title_queries_from_ref_blob_llm(section_raw, snap, max_queries=max_queries)
+    merged: list[str] = []
+    seen: set[str] = set()
+    for src in (base_lines or []) + llm_q:
+        t = re.sub(r"\s+", " ", str(src).strip())
+        if len(t) < 22:
+            continue
+        k = t.lower()[:260]
+        if k in seen:
+            continue
+        seen.add(k)
+        merged.append(t[:520])
+        if len(merged) >= 72:
+            break
+    return merged
+
+def rerank_reader_recommend_pairs_by_llm(
+    snap: dict[str, Any],
+    pairs: list[tuple[Any, str]],
+    *,
+    user_message: str,
+    history_lines: str = "",
+    reco_max_hint: int = 2,
+) -> list[tuple[Any, str]]:
+    if not pairs:
+        return pairs
+    if not is_llm_configured():
+        return prioritize_reader_related_pairs_refs_first(pairs)
+    hint = max(1, min(int(reco_max_hint or 2), READER_RECOMMEND_MAX_RESULTS))
+
+    head: list[tuple[Any, str]] = []
+    bib: list[tuple[Any, str]] = []
+    for p, s in pairs:
+        if s in READER_BIB_SOURCES:
+            bib.append((p, s))
+        else:
+            head.append((p, s))
+
+    if len(bib) <= 1:
+        return bib + head
+
+    n = len(bib)
+    title = str(snap.get("title") or "").strip()
+    ab = truncate_text(str(snap.get("abstract") or "").strip(), 2000, suffix="...")
+    kw = snap.get("keywords") or []
+    kw_s = ", ".join(str(x) for x in kw[:24] if str(x).strip()) if isinstance(kw, (list, tuple)) else ""
+    um = truncate_text((user_message or "").strip(), 600, suffix="...")
+    hist = truncate_text((history_lines or "").strip(), 1400, suffix="...")
+
+    lines: list[str] = []
+    for i, (ap, _) in enumerate(bib):
+        t = str(getattr(ap, "title", "") or "").strip() or "(no title)"
+        y = getattr(ap, "year", None) or "-"
+        j = str(getattr(ap, "journal", None) or getattr(ap, "venue", None) or "").strip() or "-"
+        ax = str(getattr(ap, "arxiv_id", None) or "").strip() or "-"
+        doi = str(getattr(ap, "doi", None) or "").strip() or "-"
+        lines.append(f"{i}. {t} | year={y} | venue={j[:80]} | arxiv={ax} | doi={doi}")
+
+    system = (
+        "You are a relevance judge. Given the main paper, chat context, and user question, "
+        "rank candidate papers (from its reference parsing) by relevance to the paper's method, task, data. "
+        "Decide how many to keep (keep_n) -- don't pad to match the user's hint, "
+        f"max = min(candidate_count, {READER_RECOMMEND_MAX_RESULTS}). "
+        "Exclude unrelated domains, generic-topic surveys, shared buzzwords. "
+        "Non-reference items are secondary. "
+        "Output JSON:\n"
+        "{\"keep_n\":int,\"order\":[int,...],"
+        "\"items\":[{\"i\":0,\"score\":0.82,\"relation\":\"...\",\"why\":\"<=40 chars\"}]}\n"
+        f"keep_n in 1..min(count,{READER_RECOMMEND_MAX_RESULTS}), matching conversation intent. "
+        "order: full permutation of indices (0-based) by descending relevance, no dupes. "
+        f"User hint (~{hint}) is non-binding -- explain in items[].why if different."
+    )
+    user = (
+        f"[Title] {title}\n[Abstract] {ab}\n[Keywords] {kw_s}\n\n"
+        f"[Chat context]\n{hist or '(none)'}\n\n"
+        f"[User question] {um}\n\n"
+        f"[Candidates] ({n} total)\n" + "\n".join(lines) + "\n"
+    )
+    order: list[int | None] = None
+    keep_n: int | None = None
+    try:
+        llm = get_llm()
+        raw = llm.invoke(
+            [
+                {"role": "system", "content": system},
+                {"role": "user", "content": user},
+            ]
+        )
+        text = coerce_hello_agents_llm_output_to_str(raw).strip()
+        data = parse_llm_json(text)
+        if isinstance(data, dict):
+            if isinstance(data.get("order"), list):
+                parsed: list[int] = []
+                for x in data["order"]:
+                    try:
+                        parsed.append(int(x))
+                    except (TypeError, ValueError):
+                        continue
+                order = parsed
+            for key in ("keep_n", "keep", "n_keep", "num_keep"):
+                v = data.get(key)
+                if v is None:
+                    continue
+                try:
+                    keep_n = int(v)
+                    break
+                except (TypeError, ValueError):
+                    continue
+    except Exception as exc:
+        logger.debug("rerank_reader_recommend_llm_invoke_failed", exc_info=exc)
+
+    if not order or len(order) < max(2, (n + 1) // 2):
+        try:
+            from ...agents.support.reader_reference_lookup_tool import rerank_reader_pairs_by_anchor
+
+            kn = max(1, min(hint, n, READER_RECOMMEND_MAX_RESULTS))
+            return rerank_reader_pairs_by_anchor(snap, bib, k=kn) + head
+        except Exception:
+            return bib[: max(1, min(hint, n, READER_RECOMMEND_MAX_RESULTS))] + head
+
+    seen_i: set[int] = set()
+    reordered: list[tuple[Any, str]] = []
+    for i in order:
+        try:
+            ii = int(i)
+        except (TypeError, ValueError):
+            continue
+        if 0 <= ii < n and ii not in seen_i:
+            reordered.append(bib[ii])
+            seen_i.add(ii)
+    for i in range(n):
+        if i not in seen_i:
+            reordered.append(bib[i])
+
+    kn = hint
+    if keep_n is not None:
+        try:
+            kn = int(keep_n)
+        except (TypeError, ValueError):
+            kn = hint
+    kn = max(1, min(kn, n, READER_RECOMMEND_MAX_RESULTS))
+    return reordered[:kn] + head

+ 1 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reading_log/__init__.py

@@ -0,0 +1 @@
+"""Reading session persistence; import from ``reading_log.log`` directly."""

+ 45 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/reading_log/log.py

@@ -0,0 +1,45 @@
+"""阅读日志服务 —— 论文阅读时间记录、阅读日历数据生成与统计."""
+
+from __future__ import annotations
+import datetime as _dt, sqlite3, time
+from ...utils.common import exec_sql
+
+def ensure_tables(db_path: str) -> None:
+    exec_sql(db_path,
+        """CREATE TABLE IF NOT EXISTS paper_reading_sessions (
+          id INTEGER PRIMARY KEY AUTOINCREMENT,
+          paper_id INTEGER NOT NULL, duration_sec INTEGER NOT NULL,
+          day_key TEXT NOT NULL, created_at INTEGER NOT NULL)""",
+        "CREATE INDEX IF NOT EXISTS idx_prs_day ON paper_reading_sessions(day_key, created_at)",
+        "CREATE INDEX IF NOT EXISTS idx_prs_paper ON paper_reading_sessions(paper_id, created_at)")
+
+def append_session(db_path: str, *, paper_id: int, duration_sec: int, client_ts: int | None = None) -> None:
+    if not db_path or int(duration_sec or 0) <= 0:
+        return
+    ensure_tables(db_path)
+    dur = min(int(duration_sec), 86400)
+    ts = int(client_ts) if client_ts else int(time.time())
+    day = _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
+    conn = sqlite3.connect(db_path)
+    try:
+        conn.execute("INSERT INTO paper_reading_sessions(paper_id,duration_sec,day_key,created_at) VALUES(?,?,?,?)",
+                     (int(paper_id), dur, day, int(time.time())))
+        conn.commit()
+    finally:
+        conn.close()
+
+def list_daily_aggregate(db_path: str, *, days: int = 180) -> list[dict[str, int | str]]:
+    if not db_path:
+        return []
+    ensure_tables(db_path)
+    d = max(7, min(int(days or 180), 366))
+    start = _dt.datetime.fromtimestamp(int(time.time()) - (d - 1) * 86400).strftime("%Y-%m-%d")
+    conn = sqlite3.connect(db_path)
+    conn.row_factory = sqlite3.Row
+    try:
+        rows = conn.execute(
+            "SELECT day_key, SUM(duration_sec) AS seconds, COUNT(*) AS sessions FROM paper_reading_sessions WHERE day_key>=? GROUP BY day_key ORDER BY day_key",
+            (start,)).fetchall()
+        return [{"date": r["day_key"], "seconds": int(r["seconds"] or 0), "sessions": int(r["sessions"] or 0)} for r in rows]
+    finally:
+        conn.close()

+ 0 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/__init__.py


+ 99 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/method_acronym.py

@@ -0,0 +1,99 @@
+"""Short method names / acronyms (DiAD, LoRA, Mamba) — avoid broad keyword expansion."""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+_METHOD_ACRONYM_RE = re.compile(r"^[A-Za-z][A-Za-z0-9\-]{1,15}$")
+
+
+def is_method_acronym_token(text: str) -> bool:
+    """如 DiAD、LoRA、Mamba(无空格、偏短、含大小写或全大写)。"""
+    t = (text or "").strip()
+    if not t or " " in t:
+        return False
+    if not _METHOD_ACRONYM_RE.match(t):
+        return False
+    if t.isupper() and len(t) >= 2:
+        return True
+    if re.search(r"[A-Z]", t) and re.search(r"[a-z]", t):
+        return True
+    if len(t) <= 8 and re.search(r"[A-Z]{2,}", t):
+        return True
+    return len(t) <= 6 and t[0].isupper()
+
+
+def title_matches_method_acronym(title: str, acronym: str) -> bool:
+    if not acronym:
+        return False
+    ac = acronym.strip()
+    flags = 0 if (re.search(r"[a-z]", ac) and re.search(r"[A-Z]", ac)) else re.I
+    return bool(re.search(rf"\b{re.escape(ac)}\b", title or "", flags))
+
+
+def derive_full_title_from_named_method(paper: Any, acronym: str) -> str | None:
+    """从「DiAD: A Diffusion-based ...」提取正式标题用于会场版检索。"""
+    title = str(getattr(paper, "title", None) or "").strip()
+    if not title or not acronym:
+        return None
+    m = re.match(rf"^{re.escape(acronym.strip())}\s*[:\\-]\s*(.+)$", title, re.I)
+    if not m:
+        return None
+    full = m.group(1).strip()
+    return full if len(full) >= 12 else None
+
+
+def resolve_method_acronym(query: str, keywords: list[str] | None) -> str | None:
+    q = (query or "").strip()
+    if is_method_acronym_token(q):
+        return q
+    kws = [str(k).strip() for k in (keywords or []) if str(k).strip()]
+    if len(kws) == 1 and is_method_acronym_token(kws[0]):
+        return kws[0]
+    return None
+
+
+def paper_matches_method_query(
+    paper: Any,
+    acronym: str,
+    *,
+    canonical_titles: list[str] | None = None,
+    pinned_arxiv_ids: list[str] | None = None,
+    venue: str | None = None,
+) -> bool:
+    """方法缩写查询:标题含缩写、锚定标题模糊匹配、或 pinned arXiv。"""
+    from ...core.search.paper_searcher import PaperSearcher
+
+    title = str(getattr(paper, "title", None) or "")
+    blob = f"{title} {getattr(paper, 'abstract', None) or ''}"
+    acronym_hit = title_matches_method_acronym(blob, acronym)
+    venue_hit = bool(
+        venue and PaperSearcher._paper_matches_venue_proceedings(paper, venue)
+    )
+    title_l = title.lower()
+    canonical_hit = False
+    for ct in canonical_titles or []:
+        ctl = (ct or "").strip().lower()
+        if len(ctl) >= 12 and (ctl in title_l or title_l in ctl):
+            canonical_hit = True
+            break
+    arxiv_id = str(getattr(paper, "arxiv_id", None) or getattr(paper, "arxivId", None) or "")
+    url = str(getattr(paper, "url", None) or getattr(paper, "source_url", None) or "")
+    hay = f"{arxiv_id} {url}".lower()
+    pinned_hit = any(
+        (aid or "").strip().lower() in hay for aid in (pinned_arxiv_ids or []) if (aid or "").strip()
+    )
+    named_method = bool(
+        re.match(rf"^{re.escape(acronym.strip())}\s*[:\\-]", title.strip(), re.I)
+    )
+
+    if pinned_hit or canonical_hit:
+        return True
+    if venue:
+        if venue_hit and acronym_hit:
+            return True
+        if named_method and acronym_hit:
+            return True
+        return False
+    return acronym_hit

+ 136 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/paper_filters.py

@@ -0,0 +1,136 @@
+"""Fast filters for non-main-track papers."""
+
+from __future__ import annotations
+
+import re
+
+from app.core.paper import Paper as LitPaper
+
+_JOURNAL_LIKE_VENUE_RE = re.compile(
+    r"(?i)\b(?:trans\.|transactions|journal|magazine|letters|pattern anal)\b"
+)
+
+_VENUE_ALIASES: dict[str, tuple[str, ...]] = {
+    "neurips": (
+        "neurips", "nips",
+        "neural information processing systems",
+        "advances in neural information processing systems",
+    ),
+    "nips": (
+        "neurips", "nips",
+        "neural information processing systems",
+        "advances in neural information processing systems",
+    ),
+    "cvpr": ("cvpr", "computer vision and pattern recognition"),
+    "iccv": ("iccv", "international conference on computer vision"),
+    "eccv": ("eccv", "european conference on computer vision"),
+    "iclr": ("iclr", "international conference on learning representations"),
+    "icml": ("icml", "international conference on machine learning"),
+    "acl": ("acl", "annual meeting of the association for computational linguistics"),
+    "emnlp": ("emnlp", "empirical methods in natural language processing"),
+    "aaai": ("aaai", "association for the advancement of artificial intelligence"),
+    "ijcai": ("ijcai", "international joint conference on artificial intelligence"),
+    "kdd": ("kdd", "knowledge discovery and data mining"),
+    "sigir": ("sigir", "information retrieval"),
+}
+
+
+def _venue_aliases(venue: str | None) -> tuple[str, ...]:
+    raw = (venue or "").strip().lower()
+    if not raw:
+        return ()
+    key = re.sub(r"[^a-z0-9]", "", raw)
+    aliases = _VENUE_ALIASES.get(key) or _VENUE_ALIASES.get(raw)
+    return aliases or (raw,)
+
+
+def _contains_venue_alias(text: str, venue: str | None) -> bool:
+    low = (text or "").lower()
+    if not low:
+        return False
+    compact = re.sub(r"[^a-z0-9]", "", low)
+    for alias in _venue_aliases(venue):
+        a = alias.lower()
+        if len(a) < 2:
+            continue
+        if a in low:
+            return True
+        ac = re.sub(r"[^a-z0-9]", "", a)
+        if ac and ac in compact:
+            return True
+    return False
+
+
+def has_strong_main_conference_venue_signal(paper: LitPaper, target_venue: str | None) -> bool:
+    """Check whether source or venue proves target-conference membership."""
+    if not (target_venue or "").strip():
+        return True
+    source = (getattr(paper, "source", None) or "").strip().lower()
+    if source in {"tavily_proceedings", "official_discovered", "tavily", "proceedings"}:
+        return True
+    journal = (getattr(paper, "journal", None) or getattr(paper, "venue", None) or "").strip()
+    return _contains_venue_alias(journal, target_venue)
+
+
+def is_journal_not_target_proceedings(paper: LitPaper, target_venue: str | None) -> bool:
+    venue = (target_venue or "").strip()
+    if not venue:
+        return False
+    journal = (getattr(paper, "journal", None) or getattr(paper, "venue", None) or "").strip()
+    if not journal or not _JOURNAL_LIKE_VENUE_RE.search(journal):
+        return False
+    vkey = re.sub(r"[^a-z0-9]", "", venue.lower())
+    jkey = re.sub(r"[^a-z0-9]", "", journal.lower())
+    return bool(vkey) and vkey not in jkey
+
+
+def is_stale_best_of_special(title: str, pinned_year: int | None) -> bool:
+    if pinned_year is None or not re.search(r"(?i)\b(best of|special section)\b", title or ""):
+        return False
+    for m in re.finditer(r"(?:19|20)\d{2}", title or ""):
+        try:
+            y = int(m.group())
+        except ValueError:
+            continue
+        if y != int(pinned_year):
+            return True
+    return False
+
+
+def is_obvious_workshop_track(paper: LitPaper, target_venue: str | None = None) -> bool:
+    """Fast pre-filter for obvious workshop/satellite papers."""
+    title = (getattr(paper, "title", None) or "").strip()
+    journal = (getattr(paper, "journal", None) or getattr(paper, "venue", None) or "").strip()
+    blob = f"{title} {journal}".lower()
+    if not blob.strip():
+        return False
+    if re.search(
+        r"(?i)\b(workshops?|symposium|symposia|tutorial|"
+        r"demo\s+track|satellite)\b",
+        blob,
+    ):
+        return True
+    if target_venue:
+        can_t = re.escape((target_venue or "").strip())
+        if re.search(rf"(?i)\b{can_t}\s+20\d{{2}}\s+workshop\b", title):
+            return True
+    return False
+
+
+def should_exclude_main_conference_paper(
+    paper: LitPaper,
+    target_venue: str | None = None,
+    *,
+    pinned_year: int | None = None,
+    require_venue_signal: bool = False,
+) -> bool:
+    title = (getattr(paper, "title", None) or "").strip()
+    if is_obvious_workshop_track(paper, target_venue):
+        return True
+    if is_journal_not_target_proceedings(paper, target_venue):
+        return True
+    if is_stale_best_of_special(title, pinned_year):
+        return True
+    if require_venue_signal and not has_strong_main_conference_venue_signal(paper, target_venue):
+        return True
+    return False

+ 441 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/paper_ranker.py

@@ -0,0 +1,441 @@
+"""论文精排模块 —— LLM 驱动的候选论文排序与理由生成."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+from app.core.paper import Paper as LitPaper
+
+from ..llm.agent_runtime import _exception_chain_predicate, run_agent_task
+from ..llm.llm_service import get_llm
+from ..search_intent import extract_json_object
+from ...settings import get_settings
+from .paper_filters import should_exclude_main_conference_paper
+from .ranking_prompt import (
+    RANKER_SYSTEM_PROMPT,
+    RANKER_SYSTEM_PROMPT_RETRY,
+    build_ranking_prompt,
+)
+
+logger = logging.getLogger(__name__)
+
+__all__ = [
+    "LlmPaperRanker",
+    "RankedPaper",
+    "_papers_to_ranked_pool",
+]
+
+
+def _recall_max_candidates() -> int:
+    try:
+        return max(8, min(60, int(get_settings().papergraph_recall_max_candidates)))
+    except Exception:
+        return 24
+
+
+def _pool_fallback_sort_key(rp: RankedPaper) -> tuple:
+    return (
+        float(getattr(rp, "fine_score", 0) or 0),
+        int(getattr(rp.paper, "year", 0) or 0),
+        int(getattr(rp.paper, "citations", 0) or 0),
+    )
+
+
+def _papers_to_ranked_pool(
+    papers: list[LitPaper],
+    *,
+    cap: int,
+    prefer_recency: bool,
+) -> list[RankedPaper]:
+    pool = [RankedPaper(paper=p) for p in papers[: max(1, cap)]]
+    if prefer_recency:
+        pool.sort(key=lambda x: int(getattr(x.paper, "year", 0) or 0), reverse=True)
+    return pool
+
+
+def _looks_like_llm_timeout(exc: BaseException) -> bool:
+    def pred(x: BaseException) -> bool:
+        if isinstance(x, TimeoutError):
+            return True
+        s = str(x).lower()
+        return any(
+            k in s
+            for k in ("timeout", "timed out", "readtimeout", "apitimeout", "agent task timeout")
+        )
+
+    return _exception_chain_predicate(exc, pred)
+
+
+def _is_connectionish_error(exc: BaseException) -> bool:
+    etxt = str(exc).lower()
+    return any(k in etxt for k in ("connection", "remoteprotocolerror", "server disconnected", "eof"))
+
+
+@dataclass
+class RankedPaper:
+    paper: LitPaper
+    fine_score: float = 0.0
+    final_score: float = 0.0
+    ranking_reason: str = ""
+    metadata: dict[str, Any] = field(default_factory=dict)
+
+
+def _is_venue_match(paper: LitPaper, venue: str) -> bool:
+    """Check if paper's journal/source matches the target venue."""
+    from .paper_filters import has_strong_main_conference_venue_signal
+    return has_strong_main_conference_venue_signal(paper, venue)
+
+
+class LlmPaperRanker:
+    """召回去重后由 LLM 直接排序。"""
+
+    def __init__(self, recall_max: int = 24, fine_top_k: int = 10, llm=None):
+        self.recall_max = max(8, int(recall_max or 24))
+        self.fine_top_k = fine_top_k
+        self._llm = llm or get_llm()
+
+    @staticmethod
+    def _ranked_paper_dedupe_key(rp: RankedPaper) -> str:
+        p = rp.paper
+        aid = str(getattr(p, "arxiv_id", "") or "").strip()
+        if aid:
+            return f"arxiv:{aid}"
+        doi = str(getattr(p, "doi", "") or "").strip().lower()
+        if doi:
+            return f"doi:{doi}"
+        t = str(getattr(p, "title", "") or "").strip().lower()[:240]
+        return f"t:{t}" if t else f"id:{id(p)}"
+
+    def _parse_ranking_result(self, result: str, papers: list[RankedPaper]) -> list[RankedPaper]:
+        if not papers:
+            return []
+        raw = (result or "").strip()
+        if not raw:
+            return []
+
+        data: dict[str, Any | None] = None
+        try:
+            data = extract_json_object(raw)
+            if data is None:
+                m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", raw, re.IGNORECASE | re.DOTALL)
+                if m:
+                    data = extract_json_object(m.group(1))
+            if data is None:
+                start, end = raw.find("{"), raw.rfind("}")
+                if start >= 0 and end > start:
+                    chunk = re.sub(r",\s*([\]}])", r"\1", raw[start : end + 1])
+                    data = json.loads(chunk)
+                    if not isinstance(data, dict):
+                        data = None
+        except Exception as e:
+            logger.warning("[LlmPaperRanker] 精排 JSON 解析失败: %s", e)
+            return []
+
+        if not isinstance(data, dict):
+            return []
+        rankings = data.get("rankings")
+        if not isinstance(rankings, list):
+            return []
+
+        result_list: list[RankedPaper] = []
+        seen_idx: set[int] = set()
+        for r in rankings:
+            if not isinstance(r, dict):
+                continue
+            try:
+                idx = int(r.get("paper_index", r.get("index", 0))) - 1
+            except (TypeError, ValueError):
+                continue
+            if idx in seen_idx or not (0 <= idx < len(papers)):
+                continue
+            seen_idx.add(idx)
+            rp = papers[idx]
+            try:
+                rp.fine_score = float(r.get("fine_score", 0))
+            except (TypeError, ValueError):
+                rp.fine_score = 0.0
+            rp.ranking_reason = str(r.get("reason", "") or "").strip()
+            result_list.append(rp)
+        return result_list
+
+    def _supplement_ranked(
+        self,
+        ranked: list[RankedPaper],
+        pool: list[RankedPaper],
+        top_k: int,
+        *,
+        allow_supplement: bool = True,
+    ) -> list[RankedPaper]:
+        cap = min(int(top_k or 10), len(pool))
+        if not allow_supplement or len(ranked) >= cap:
+            return ranked[:cap]
+
+        keys = {self._ranked_paper_dedupe_key(rp) for rp in ranked}
+        out = list(ranked)
+        for rp in sorted(pool, key=_pool_fallback_sort_key, reverse=True):
+            if len(out) >= cap:
+                break
+            k = self._ranked_paper_dedupe_key(rp)
+            if k in keys:
+                continue
+            keys.add(k)
+            rp.fine_score = float(getattr(rp, "fine_score", 0.0) or 0.0)
+            if not getattr(rp, "ranking_reason", ""):
+                rp.ranking_reason = "(精排序列未覆盖该项,按召回顺序递补)"
+            out.append(rp)
+        return out[:cap]
+
+    def _finalize_scores(self, ranked: list[RankedPaper]) -> None:
+        for rp in ranked:
+            rp.final_score = round(float(rp.fine_score or 0), 2)
+
+    def _invoke_llm_rank(
+        self,
+        candidates: list[RankedPaper],
+        prompt: str,
+        *,
+        task_name: str,
+        agent_name: str,
+        system_prompt: str,
+        timeout_sec: float,
+    ) -> str:
+        return run_agent_task(
+            task_name=task_name,
+            agent_name=agent_name,
+            llm=self._llm,
+            system_prompt=system_prompt,
+            user_prompt=prompt,
+            timeout_sec=timeout_sec,
+            retries=0,
+            task_logger=logger,
+        )
+
+    def _rank_from_llm_output(
+        self,
+        result_text: str,
+        candidates: list[RankedPaper],
+        top_k: int,
+    ) -> tuple[list[RankedPaper], str]:
+        ranked = self._parse_ranking_result(result_text, candidates)
+        if not ranked:
+            ranked = sorted(candidates, key=_pool_fallback_sort_key, reverse=True)[:top_k]
+            for rp in ranked:
+                rp.fine_score = 0.0
+                rp.ranking_reason = rp.ranking_reason or "(精排未返回有效条目,按召回顺序保留)"
+            return self._supplement_ranked(ranked, candidates, top_k), "recall_fallback"
+        return self._supplement_ranked(ranked, candidates, top_k), "llm_rank"
+
+    def _fine_rank(
+        self,
+        papers: list[RankedPaper],
+        query: str,
+        top_k: int = 10,
+        *,
+        ranking_profile: str = "accuracy",
+        target_venue: str | None = None,
+        main_conference_proceedings_only: bool = False,
+        intent_source_message: str | None = None,
+        target_titles: list[str] | None = None,
+        authors: list[str] | None = None,
+        venues: list[str] | None = None,
+        year_from: int | None = None,
+        year_to: int | None = None,
+        method_acronym: str | None = None,
+    ) -> tuple[list[RankedPaper], str]:
+        if not papers:
+            return [], "llm_rank"
+
+        profile = str(ranking_profile or "accuracy").strip().lower()
+        if profile not in ("accuracy", "novelty", "classic"):
+            profile = "accuracy"
+
+        try:
+            cand_limit = int(str(get_settings().papergraph_fine_rank_candidates))
+        except Exception:
+            cand_limit = 12
+        cand_limit = max(int(top_k or 10), min(40, max(10, cand_limit)))
+
+        try:
+            fine_timeout_sec = float(os.getenv("PAPERGRAPH_FINE_RANK_TIMEOUT_SEC", "30").strip() or 30)
+        except Exception:
+            fine_timeout_sec = 30.0
+        fine_timeout_sec = max(10.0, min(120.0, fine_timeout_sec))
+
+        candidates = list(papers or [])[:cand_limit]
+        try:
+            abs_max = int(os.getenv("PAPERGRAPH_FINE_RANK_ABSTRACT_CHARS", "").strip() or 200)
+        except Exception:
+            abs_max = 200
+
+        rank_kwargs = dict(
+            ranking_profile=profile,
+            abstract_max_chars=abs_max,
+            target_venue=target_venue,
+            main_conference_proceedings_only=main_conference_proceedings_only,
+            intent_source_message=intent_source_message,
+            target_titles=target_titles,
+            authors=authors,
+            venues=venues,
+            year_from=year_from,
+            year_to=year_to,
+            method_acronym=method_acronym,
+        )
+
+        try:
+            prompt = build_ranking_prompt(candidates, query, top_k, **rank_kwargs)
+            result_text = self._invoke_llm_rank(
+                candidates,
+                prompt,
+                task_name="paper_ranker_fine_rank",
+                agent_name="paper_ranker",
+                system_prompt=RANKER_SYSTEM_PROMPT,
+                timeout_sec=fine_timeout_sec,
+            )
+            ranked, fine_method = self._rank_from_llm_output(result_text, candidates, top_k)
+            self._finalize_scores(ranked)
+            return ranked[:top_k], fine_method
+
+        except Exception as e:
+            if _looks_like_llm_timeout(e) or _is_connectionish_error(e):
+                try:
+                    retry_limit = min(max(int(top_k or 10) * 2, 12), max(12, cand_limit))
+                    retry_candidates = list(papers or [])[:retry_limit]
+                    prompt2 = build_ranking_prompt(
+                        retry_candidates,
+                        query,
+                        top_k,
+                        ranking_profile=profile,
+                        abstract_max_chars=min(280, max(160, abs_max // 2)),
+                        target_venue=target_venue,
+                        main_conference_proceedings_only=main_conference_proceedings_only,
+                        intent_source_message=intent_source_message,
+                    )
+                    ranked2, method2 = self._rank_from_llm_output(
+                        self._invoke_llm_rank(
+                            retry_candidates,
+                            prompt2,
+                            task_name="paper_ranker_fine_rank_retry",
+                            agent_name="paper_ranker_retry",
+                            system_prompt=RANKER_SYSTEM_PROMPT_RETRY,
+                            timeout_sec=min(120.0, fine_timeout_sec + 25.0),
+                        ),
+                        retry_candidates,
+                        top_k,
+                    )
+                    if method2 == "llm_rank":
+                        self._finalize_scores(ranked2)
+                        return ranked2[:top_k], method2
+                except Exception:
+                    pass
+
+            if _looks_like_llm_timeout(e):
+                logger.warning(
+                    "[LlmPaperRanker] 精排 LLM 超时(当前上限 %.0fs),已按召回顺序降级;可提高 "
+                    "PAPERGRAPH_FINE_RANK_TIMEOUT_SEC,或减小 PAPERGRAPH_FINE_RANK_CANDIDATES / "
+                    "PAPERGRAPH_FINE_RANK_ABSTRACT_CHARS。详情: %s",
+                    fine_timeout_sec,
+                    str(e)[:200],
+                )
+            else:
+                logger.exception(
+                    "[LlmPaperRanker] 精排失败: %s (llm_set=%s, LLM_API_KEY=%s, LLM_BASE_URL=%s, LLM_MODEL_ID=%s)",
+                    e,
+                    bool(self._llm),
+                    ("已配置" if os.getenv("LLM_API_KEY") else "未配置"),
+                    os.getenv("LLM_BASE_URL", "未配置"),
+                    os.getenv("LLM_MODEL_ID", "未配置"),
+                )
+            etxt = str(e).lower()
+            if any(k in etxt for k in ("proxy", "ssl", "eof", "connection")):
+                logger.warning(
+                    "[LlmPaperRanker] 提示:若为代理/SSL 握手失败,可在 backend/.env 设置 LLM_DISABLE_PROXY=1 后重启;"
+                    "或临时取消 HTTPS_PROXY/ALL_PROXY;或确认 NO_PROXY 包含 LLM 域名(见 llm_service._maybe_disable_proxy_for_llm)。"
+                )
+
+            fallback = sorted(list(papers or []), key=_pool_fallback_sort_key, reverse=True)
+            if main_conference_proceedings_only and target_venue:
+                yf, yt = kwargs.get("year_from"), kwargs.get("year_to")
+                pin_y = int(yf) if yf is not None and yf == yt else None
+                fallback = [
+                    rp
+                    for rp in fallback
+                    if not should_exclude_main_conference_paper(
+                        rp.paper, target_venue, pinned_year=pin_y
+                    )
+                ]
+            self._finalize_scores(fallback)
+            return fallback[:top_k], "recall_fallback"
+
+    def rank(
+        self,
+        papers: list[LitPaper],
+        query: str,
+        top_k: int | None = None,
+        **kwargs: Any,
+    ) -> tuple[list[RankedPaper], dict[str, Any]]:
+        final_k = top_k or self.fine_top_k
+        profile = str(kwargs.get("ranking_profile") or "accuracy").strip().lower()
+        if profile not in ("accuracy", "novelty", "classic"):
+            profile = "accuracy"
+        target_venue = (kwargs.get("target_venue") or "").strip() or None
+        main_conf = bool(kwargs.get("main_conference_proceedings_only"))
+        if main_conf and target_venue:
+            yf, yt = kwargs.get("year_from"), kwargs.get("year_to")
+            pin_y = int(yf) if yf is not None and yf == yt else None
+            papers = [
+                p
+                for p in papers
+                if not should_exclude_main_conference_paper(p, target_venue, pinned_year=pin_y)
+            ]
+        sort_mode = str(kwargs.get("sort") or "").strip().lower()
+        prefer_recency = bool(kwargs.get("prefer_recency") or sort_mode == "date" or target_venue)
+        recall_cap = min(_recall_max_candidates(), max(self.recall_max, final_k + 4))
+
+        # Pre-rank: when venue is specified, boost venue-matched papers ahead of others
+        if target_venue:
+            papers = sorted(
+                papers,
+                key=lambda p: (
+                    0 if _is_venue_match(p, target_venue) else 1,
+                    -(int(getattr(p, "year", 0) or 0)),
+                    -(int(getattr(p, "citations", 0) or 0)),
+                ),
+            )
+
+        candidate_pool = _papers_to_ranked_pool(papers, cap=recall_cap, prefer_recency=prefer_recency)
+        if not candidate_pool:
+            return [], {"error": "无候选论文"}
+
+        try:
+            fine_result, fine_method = self._fine_rank(
+                papers=candidate_pool,
+                query=query,
+                top_k=final_k,
+                ranking_profile=profile,
+                target_venue=target_venue,
+                main_conference_proceedings_only=main_conf,
+                intent_source_message=kwargs.get("intent_source_message"),
+                target_titles=list(kwargs.get("target_titles") or []),
+                authors=list(kwargs.get("authors") or []),
+                venues=list(kwargs.get("venues") or []),
+                year_from=kwargs.get("year_from"),
+                year_to=kwargs.get("year_to"),
+                method_acronym=(kwargs.get("method_acronym") or "").strip() or None,
+            )
+            method = fine_method
+        except Exception:
+            fine_result = sorted(candidate_pool, key=_pool_fallback_sort_key, reverse=True)[:final_k]
+            method = "recall_fallback"
+
+        return fine_result, {
+            "ranking_method": method,
+            "ranking_profile": profile,
+            "total_candidates": len(papers),
+            "recall_pool": len(candidate_pool),
+            "fine_output": len(fine_result),
+        }

+ 104 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/pipeline_runtime.py

@@ -0,0 +1,104 @@
+"""Pipeline 运行时配置 — 集中读取 settings,减少 pipeline 噪音。"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from .plan_helpers import effective_max_results, is_pinned_single_year
+from .search_plan import ResolvedSearchPlan
+
+
+@dataclass(frozen=True)
+class SearchRuntimeConfig:
+    max_results: int
+    recall_max: int
+    recall_cap: int
+    recall_wall: float
+    rank_wall: float
+    arxiv_fallback_wall: float
+    proc_min: int
+    proc_enabled: bool
+    http_timeout_sec: float
+    http_max_attempts: int
+    openalex_timeout_sec: float
+    dblp_timeout_sec: float | None = None
+
+    @classmethod
+    def from_settings(
+        cls,
+        settings: Any,
+        plan: ResolvedSearchPlan,
+        max_results: int | None = None,
+    ) -> "SearchRuntimeConfig":
+        mr = max(int(max_results or getattr(plan, "max_results", None) or 10), 5)
+        mr = effective_max_results(plan, mr)
+
+        recall_max = int(plan.recall_max_candidates or 24)
+        try:
+            recall_cap_setting = int(settings.papergraph_recall_max_candidates)
+        except (TypeError, ValueError):
+            recall_cap_setting = 24
+        recall_cap = max(mr + 4, min(60, recall_cap_setting, recall_max))
+
+        recall_wall = max(
+            10.0, min(180.0, float(getattr(settings, "papergraph_search_recall_wall_sec", 42.0)))
+        )
+        venue = (plan.venues[0] if plan.venues else None) or None
+        if is_pinned_single_year(plan) and venue:
+            recall_wall = max(recall_wall, 75.0)
+
+        rank_wall = max(
+            10.0, min(120.0, float(getattr(settings, "papergraph_fine_rank_pipeline_wall_sec", 25.0)))
+        )
+        arxiv_fb_wall = max(
+            3.0,
+            min(90.0, float(getattr(settings, "papergraph_search_arxiv_fallback_wall_sec", 15.0))),
+        )
+
+        try:
+            proc_min = int(getattr(settings, "papergraph_proceedings_supplement_min_candidates", 8) or 8)
+        except (TypeError, ValueError):
+            proc_min = 8
+        proc_enabled = bool(getattr(settings, "papergraph_proceedings_supplement_enabled", True))
+
+        http_timeout = max(
+            2.0, min(60.0, float(getattr(settings, "papergraph_search_recall_http_timeout_sec", 12.0)))
+        )
+        try:
+            http_max_attempts = int(settings.papergraph_search_http_max_attempts)
+        except (TypeError, ValueError):
+            http_max_attempts = 2
+        http_max_attempts = max(1, min(3, http_max_attempts))
+
+        openalex_timeout = 18.0
+        dblp_timeout: float | None = None
+        if is_pinned_single_year(plan) and venue:
+            dblp_timeout = 55.0
+            openalex_timeout = 45.0
+
+        return cls(
+            max_results=mr,
+            recall_max=recall_max,
+            recall_cap=recall_cap,
+            recall_wall=recall_wall,
+            rank_wall=rank_wall,
+            arxiv_fallback_wall=arxiv_fb_wall,
+            proc_min=proc_min,
+            proc_enabled=proc_enabled,
+            http_timeout_sec=http_timeout,
+            http_max_attempts=http_max_attempts,
+            openalex_timeout_sec=openalex_timeout,
+            dblp_timeout_sec=dblp_timeout,
+        )
+
+    def execution_kwargs(self) -> dict[str, Any]:
+        """HTTP/超时等执行参数,不混入用户约束。"""
+        out: dict[str, Any] = {
+            "http_timeout_sec": self.http_timeout_sec,
+            "http_max_attempts": self.http_max_attempts,
+            "openalex_timeout_sec": self.openalex_timeout_sec,
+        }
+        if self.dblp_timeout_sec is not None:
+            out["dblp_timeout_sec"] = self.dblp_timeout_sec
+        return out

+ 108 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/plan_helpers.py

@@ -0,0 +1,108 @@
+"""Derived helpers for ResolvedSearchPlan."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from .method_acronym import resolve_method_acronym
+
+
+def _venues(plan: "ResolvedSearchPlan") -> list[str]:
+    return [str(v).strip() for v in (plan.venues or []) if str(v).strip()]
+
+
+def _keywords(plan: "ResolvedSearchPlan") -> list[str]:
+    return [str(k).strip() for k in (plan.keywords or []) if str(k).strip()]
+
+if TYPE_CHECKING:
+    from .recall_context import RecallContext
+    from .search_plan import ResolvedSearchPlan
+
+
+def primary_venue(plan: "ResolvedSearchPlan") -> str | None:
+    v = _venues(plan)
+    return v[0] if v else None
+
+
+def method_acronym_for(plan: "ResolvedSearchPlan", ctx: "RecallContext | None" = None) -> str:
+    if (plan.method_acronym or "").strip():
+        return str(plan.method_acronym).strip()
+    if ctx is not None:
+        return str(ctx.search_kwargs.get("method_acronym") or "").strip()
+    return ""
+
+
+def is_pinned_single_year(plan: "ResolvedSearchPlan") -> bool:
+    yf, yt = plan.year_from, plan.year_to
+    return bool(_venues(plan) and isinstance(yf, int) and isinstance(yt, int) and yf == yt)
+
+
+def is_strict_venue_match(plan: "ResolvedSearchPlan") -> bool:
+    return is_pinned_single_year(plan) and bool(plan.main_conference_proceedings_only)
+
+
+def use_venue_proceedings_journal(plan: "ResolvedSearchPlan") -> bool:
+    return bool(_venues(plan))
+
+
+def pinned_research_topic(plan: "ResolvedSearchPlan") -> str:
+    """Search topic after removing pinned venue/year terms."""
+    from ...core.search.normalize import extract_pinned_topic_terms
+
+    venue = primary_venue(plan) or ""
+    year = plan.year_from if isinstance(plan.year_from, int) else None
+    return extract_pinned_topic_terms(
+        query=plan.query or "",
+        merged_kw=list(plan.keywords or []),
+        venue=venue,
+        year=year,
+    ).strip()
+
+
+def is_venue_browse_plan(plan: "ResolvedSearchPlan") -> bool:
+    """True for pure venue+year browsing."""
+    if not is_pinned_single_year(plan) or not plan.main_conference_proceedings_only or not _venues(plan):
+        return False
+    if method_acronym_for(plan) or resolve_method_acronym(plan.query or "", _keywords(plan)):
+        return False
+    if plan.target_titles or plan.authors:
+        return False
+    return not bool(pinned_research_topic(plan))
+
+
+def effective_max_results(plan: "ResolvedSearchPlan", requested: int) -> int:
+    return max(int(requested), 30) if is_venue_browse_plan(plan) else int(requested)
+
+
+def effective_recall_max_candidates(plan: "ResolvedSearchPlan", current: int) -> int:
+    cur = int(current or 24)
+    return max(cur, 24) if is_venue_browse_plan(plan) else cur
+
+
+def tavily_configured() -> bool:
+    try:
+        from ...settings import get_settings
+
+        return bool(str(getattr(get_settings(), "tavily_api_key", "") or "").strip())
+    except Exception:
+        return False
+
+
+def should_supplement_from_proceedings_site(plan: "ResolvedSearchPlan") -> bool:
+    """Venue searches can use proceedings supplement when Tavily is configured."""
+    return (
+        bool(_venues(plan))
+        and tavily_configured()
+    )
+
+
+def should_supplement_from_intent_dict(intent: dict[str, Any]) -> bool:
+    venues = [str(v).strip() for v in (intent.get("venues") or []) if str(v).strip()]
+    if not venues:
+        return False
+    yf, yt = intent.get("year_from"), intent.get("year_to")
+    try:
+        pinned = yf is not None and yt is not None and int(yf) == int(yt)
+    except (TypeError, ValueError):
+        pinned = False
+    return pinned and bool(intent.get("main_conference_proceedings_only")) and tavily_configured()

+ 216 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/proceedings_discovery.py

@@ -0,0 +1,216 @@
+"""Tavily 自动发现会议官网 proceedings 域名(无需事先写入 JSON 映射)。"""
+
+from __future__ import annotations
+
+import logging
+import re
+from functools import lru_cache
+from typing import Any
+from urllib.parse import urlparse
+
+from .tavily_venue_config import get_official_proceedings_hosts, tavily_include_domains_for_venue
+
+logger = logging.getLogger(__name__)
+
+_PROCEEDINGS_PATH_HINTS = (
+    "proceedings",
+    "openaccess",
+    "/papers/",
+    "papers.nips",
+    "thecvf.com",
+    "mlr.press",
+    "aclanthology",
+    "openreview.net",
+    "program",
+    "main_conference",
+    "main-conference",
+)
+_BAD_DISCOVERY_HOSTS = (
+    "dblp.org",
+    "arxiv.org",
+    "openalex.org",
+    "semanticscholar.org",
+    "google.",
+    "youtube.",
+    "twitter.",
+    "x.com",
+    "facebook.",
+    "wikipedia.org",
+    "paperswithcode.com",
+    "github.com",
+    "reddit.com",
+    "medium.com",
+    "linkedin.com",
+    "scholar.google",
+)
+
+
+def _normalize_host(url: str) -> str:
+    try:
+        host = (urlparse(url).netloc or "").lower().removeprefix("www.")
+    except ValueError:
+        return ""
+    return host
+
+
+def _score_proceedings_url(url: str, *, venue: str, year: int | None) -> float:
+    if not url:
+        return 0.0
+    low = url.lower()
+    host = _normalize_host(url)
+    if not host:
+        return 0.0
+    if any(b in host or b in low for b in _BAD_DISCOVERY_HOSTS):
+        return 0.0
+
+    score = 0.0
+    official = get_official_proceedings_hosts()
+    if any(h in host for h in official):
+        score += 50.0
+    if any(h in low for h in _PROCEEDINGS_PATH_HINTS):
+        score += 25.0
+    if year is not None and str(year) in low:
+        score += 20.0
+    vl = (venue or "").strip().lower()
+    if vl and vl in low:
+        score += 15.0
+    if re.search(r"/(paper|publication|content|html)/", low):
+        score += 8.0
+    if "workshop" in low or "challenge" in low or "ntire" in low:
+        score -= 30.0
+    return score
+
+
+@lru_cache(maxsize=128)
+def _discovery_queries(venue: str, year: int | None) -> tuple[str, ...]:
+    v = (venue or "").strip()
+    y = f" {year}" if year is not None else ""
+    return (
+        f"{v}{y} official proceedings open access papers site",
+        f"{v}{y} conference accepted papers list main conference",
+        f"{v}{y} openaccess proceedings {v} papers",
+        f"{v}{y} main conference track accepted papers",
+        f"site:papers.nips.cc {v}{y} accepted papers main conference",
+        f"site:openreview.net {v}{y} accepted papers",
+    )
+
+
+async def discover_proceedings_domains(
+    *,
+    api_key: str,
+    venue: str,
+    year: int | None = None,
+    httpx_client: Any = None,
+    max_domains: int = 3,
+) -> list[str]:
+    """用 Tavily 开放搜索会议名+年份,从结果 URL 推断官方 proceedings 站点域名。"""
+    venue = (venue or "").strip()
+    if not venue or not (api_key or "").strip():
+        return []
+
+    static = tavily_include_domains_for_venue(venue)
+    if static:
+        return list(static)[:max_domains]
+
+    from .web_presearch import tavily_search_async
+
+    host_scores: dict[str, float] = {}
+    for q in _discovery_queries(venue, year):
+        try:
+            items = await tavily_search_async(
+                api_key=api_key,
+                query=q,
+                max_results=8,
+                include_domains=None,
+                httpx_client=httpx_client,
+            )
+        except Exception as e:
+            logger.debug("[proceedings_discovery] query failed %r: %s", q[:60], e)
+            continue
+
+        for it in items or []:
+            link = str(it.get("link") or it.get("url") or "").strip()
+            if not link:
+                continue
+            host = _normalize_host(link)
+            if not host or "." not in host:
+                continue
+            sc = _score_proceedings_url(link, venue=venue, year=year)
+            if sc <= 0:
+                continue
+            host_scores[host] = max(host_scores.get(host, 0.0), sc)
+
+    ranked = sorted(host_scores.items(), key=lambda x: x[1], reverse=True)
+    domains = [h for h, sc in ranked if sc >= 20.0][:max_domains]
+    if domains:
+        logger.info(
+            "[proceedings_discovery] venue=%s year=%s → domains %s (scores=%s)",
+            venue,
+            year,
+            domains,
+            [round(host_scores[d], 1) for d in domains],
+        )
+    return domains
+
+
+async def discover_proceedings_links(
+    *,
+    api_key: str,
+    venue: str,
+    year: int | None = None,
+    httpx_client: Any = None,
+    max_links: int = 5,
+) -> list[dict[str, Any]]:
+    """用 Tavily 找具体 proceedings/accepted-papers 页面,保留 link/raw_content 供后续抽取。"""
+    venue = (venue or "").strip()
+    if not venue or not (api_key or "").strip():
+        return []
+
+    from .web_presearch import tavily_search_async
+
+    static_domains = tavily_include_domains_for_venue(venue) or None
+    scored: dict[str, dict[str, Any]] = {}
+    domain_passes = [static_domains, None] if static_domains else [None]
+    for domain_pass in domain_passes:
+        if scored and max(float(x.get("score") or 0) for x in scored.values()) >= 70:
+            break
+        for q in _discovery_queries(venue, year):
+            try:
+                items = await tavily_search_async(
+                    api_key=api_key,
+                    query=q,
+                    max_results=8,
+                    include_domains=domain_pass,
+                    httpx_client=httpx_client,
+                )
+            except Exception as e:
+                logger.debug("[proceedings_discovery] link query failed %r: %s", q[:60], e)
+                continue
+            for it in items or []:
+                link = str(it.get("link") or it.get("url") or "").strip()
+                if not link:
+                    continue
+                sc = _score_proceedings_url(link, venue=venue, year=year)
+                if sc <= 0:
+                    continue
+                prev = scored.get(link)
+                if prev and float(prev.get("score") or 0) >= sc:
+                    continue
+                scored[link] = {
+                    "link": link,
+                    "title": str(it.get("title") or "").strip(),
+                    "snippet": str(it.get("snippet") or it.get("content") or "").strip(),
+                    "raw_content": str(it.get("raw_content") or "")[:24000],
+                    "score": sc,
+                }
+
+    ranked = sorted(scored.values(), key=lambda x: float(x.get("score") or 0), reverse=True)
+    out = ranked[: max(1, int(max_links or 5))]
+    if out:
+        logger.info(
+            "[proceedings_discovery] venue=%s year=%s → links=%s",
+            venue,
+            year,
+            [x.get("link") for x in out],
+        )
+    return out

+ 293 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/proceedings_recall.py

@@ -0,0 +1,293 @@
+"""Proceedings recall from official/discovered pages."""
+
+from __future__ import annotations
+
+import asyncio
+import html
+import logging
+import re
+from typing import Any
+
+import anyio
+
+from ...core.paper import Paper as LitPaper
+from ...core.search.normalize import extract_pinned_topic_terms
+from .paper_filters import should_exclude_main_conference_paper
+from .plan_helpers import is_venue_browse_plan
+from .recall_context import RecallContext
+from .search_plan import ResolvedSearchPlan
+
+logger = logging.getLogger(__name__)
+
+
+def _clean_html_text(raw: str) -> str:
+    return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", raw or ""))).strip()
+
+
+def _paper_from_title(
+    searcher: Any,
+    *,
+    title: str,
+    venue: str,
+    year: int | None,
+    source_url: str = "",
+    authors: list[Any] | None = None,
+    source: str = "tavily",
+) -> LitPaper:
+    journal = venue.strip().upper() or "Official Proceedings"
+    return searcher._make_paper(
+        title=title, authors=authors or [], abstract="",
+        journal=journal, year=int(year) if year else None,
+        source_url=source_url or None, source=source,
+    )
+
+
+async def _llm_extract_papers_from_page(
+    page_text: str,
+    *,
+    venue: str,
+    year: int | None,
+    topic: str,
+    limit: int,
+) -> list[dict[str, Any]]:
+    """Extract main-track paper titles and authors from a page."""
+    try:
+        from ..llm.llm_service import get_llm, is_llm_configured
+        from ..llm.agent_runtime import run_json_task
+
+        if not is_llm_configured():
+            return []
+    except Exception:
+        return []
+
+    clipped = _clean_html_text(page_text)[:12000]
+    if len(clipped) < 200:
+        return []
+
+    prompt = (
+        "从下面的会议页面文本中抽取主会论文的标题和作者。\n"
+        "重要:只抽取真实的学术论文,标题应该是具体的研究成果名称。\n"
+        "绝对不要抽取以下内容:\n"
+        "- 论文集名称(如 Advances in Neural Information Processing Systems)\n"
+        "- 导航链接(如 Proceedings、List of Proceedings、Accepted Papers)\n"
+        "- Workshop、Challenge、Tutorial、Demonstration 论文\n"
+        "- 关于会议本身的元分析/综述论文\n"
+        "- 数据集/代码包/Benchmark 描述文档\n"
+        "- 日程表、征文通知、委员会名单\n"
+        "- 网页导航、页眉页脚、版权声明\n"
+        f"会议:{venue},年份:{year or '未知'},最多 {limit} 篇。\n"
+        "输出 JSON:{\"papers\":[{\"title\":\"...\",\"authors\":[\"...\"]}]}\n\n"
+        f"页面文本:\n{clipped}"
+    )
+    try:
+        data = await anyio.to_thread.run_sync(
+            lambda: run_json_task(
+                task_name="proceedings_page_extract",
+                agent_name="papergraph_proceedings_extractor",
+                llm=get_llm(),
+                system_prompt="你是严格的信息抽取器,只输出合法 JSON,不得编造页面文本中没有的论文。",
+                user_prompt=prompt, timeout_sec=15, retries=0,
+                default={"papers": []},
+            )
+        )
+    except Exception:
+        return []
+    arr = data.get("papers") if isinstance(data, dict) else []
+    if not isinstance(arr, list):
+        return []
+    out: list[dict[str, Any]] = []
+    seen: set[str] = set()
+    for it in arr:
+        if not isinstance(it, dict):
+            continue
+        title = _clean_paper_title(_clean_html_text(str(it.get("title") or "")))
+        if not title or len(title) < 8 or len(title) > 300:
+            continue
+        tl = title.lower()
+        if any(w in tl for w in ("workshop", "challenge", "tutorial", "demo track", "competition")):
+            continue
+        key = title.lower()
+        if key in seen:
+            continue
+        seen.add(key)
+        authors = it.get("authors") if isinstance(it.get("authors"), list) else []
+        out.append({"title": title, "authors": [str(a)[:120] for a in authors[:12] if str(a).strip()]})
+        if len(out) >= limit:
+            break
+    return out
+
+
+async def _fetch_discovered_page(searcher: Any, url: str) -> str:
+    await searcher._ensure_async_client()
+    resp = await searcher._async_http_get_with_retry(
+        url, params={},
+        headers={"User-Agent": searcher._user_agent()},
+        timeout=30.0, max_attempts=2,
+    )
+    return resp.text or ""
+
+
+def _clean_paper_title(raw_title: str) -> str:
+    """Strip common title prefixes."""
+    t = (raw_title or "").strip()
+    t = re.sub(r"^\s*\[PDF\]\s*", "", t, flags=re.I)
+    t = re.sub(r"^\s*\[pdf\]\s*", "", t, flags=re.I)
+    t = re.sub(r"^\s*#+\s*", "", t)
+    t = re.sub(r"^\s*\d+[\.\)]\s*", "", t)
+    t = re.sub(r"\s+", " ", t).strip()
+    return t
+
+
+def _dedupe_by_title(papers: list[LitPaper]) -> list[LitPaper]:
+    seen: set[str] = set()
+    out: list[LitPaper] = []
+    for p in papers:
+        key = (getattr(p, "title", "") or "").strip().lower()
+        if key and key not in seen:
+            seen.add(key)
+            out.append(p)
+    return out
+
+
+async def _recall_from_discovered_links(
+    searcher: Any,
+    *,
+    links: list[dict[str, Any]],
+    venue: str,
+    year: int | None,
+    topic: str,
+    max_results: int,
+) -> list[LitPaper]:
+    """Extract papers from discovered proceedings links."""
+    papers: list[LitPaper] = []
+    seen_titles: set[str] = set()
+    link_limit = min(len(links), max(8, max_results // 3))
+
+    for item in links[:link_limit]:
+        link = str(item.get("link") or "").strip()
+        if not link:
+            continue
+
+        page = str(item.get("raw_content") or "").strip()
+        if len(page) < 200:
+            try:
+                page = await _fetch_discovered_page(searcher, link)
+            except Exception:
+                page = ""
+
+        llm_items = await _llm_extract_papers_from_page(
+            page, venue=venue, year=year, topic=topic, limit=max_results,
+        )
+        # Empty extraction usually means this is not a paper listing.
+
+        for lp in llm_items:
+            title = _clean_paper_title(lp["title"].strip())
+            if not title or len(title) < 8 or len(title) > 300:
+                continue
+            tl = title.lower()
+            if any(w in tl for w in ("workshop", "challenge", "tutorial", "demo track", "competition")):
+                continue
+            key = title.lower()
+            if key in seen_titles:
+                continue
+            seen_titles.add(key)
+            papers.append(
+                _paper_from_title(
+                    searcher, title=title, venue=venue, year=year,
+                    source_url=link, authors=lp.get("authors", []),
+                    source="tavily",
+                )
+            )
+            if len(papers) >= max(8, int(max_results)):
+                return papers
+    return papers
+
+
+async def recall_from_proceedings_site(
+    searcher: Any,
+    *,
+    plan: ResolvedSearchPlan,
+    ctx: RecallContext | None = None,
+    max_results: int = 24,
+) -> list[LitPaper]:
+    """Recall papers through discovery and configured proceedings domains."""
+    if not plan.venues:
+        return []
+    venue = str(plan.venues[0]).strip()
+    if not venue:
+        return []
+
+    year = plan.year_from if plan.year_from is not None else plan.year_to
+    q = (ctx.effective_query if ctx else None) or (plan.query or "").strip()
+    if not q and plan.keywords:
+        q = " ".join(str(k) for k in plan.keywords[:4])
+    if not q:
+        q = venue
+
+    topic = extract_pinned_topic_terms(
+        query=q, merged_kw=list(plan.keywords or []),
+        venue=venue, year=year if isinstance(year, int) else None,
+    )
+    if not topic and is_venue_browse_plan(plan):
+        topic = ""
+
+    from ...settings import get_settings
+
+    tavily_key = str(getattr(get_settings(), "tavily_api_key", "") or "").strip()
+    if not tavily_key:
+        logger.info("[proceedings_recall] skip tavily: no tavily_api_key")
+        return []
+
+    venue_browse = is_venue_browse_plan(plan)
+    y = year if isinstance(year, int) else None
+
+    from .proceedings_discovery import discover_proceedings_links
+    from ...core.search.sources.tavily import search_tavily_proceedings
+
+    async def _discover() -> list[LitPaper]:
+        try:
+            links = await discover_proceedings_links(
+                api_key=tavily_key, venue=venue, year=y,
+                httpx_client=getattr(searcher, "_async_client", None),
+                max_links=max(5, max_results // 2),
+            )
+            if not links:
+                return []
+            logger.info("[proceedings_recall] discovered %d links, LLM extracting…", len(links))
+            return await _recall_from_discovered_links(
+                searcher, links=links, venue=venue, year=y,
+                topic=topic, max_results=max_results,
+            )
+        except Exception:
+            logger.debug("[proceedings_recall] discovery failed", exc_info=True)
+            return []
+
+    async def _domain_search() -> list[LitPaper]:
+        try:
+            proc_cap = max(24, min(40, int(max_results))) if venue_browse else max(8, min(30, int(max_results)))
+            papers = list(
+                await search_tavily_proceedings(
+                    searcher, q, venue, year, proc_cap, venue_browse=venue_browse,
+                ) or []
+            )
+            logger.info("[proceedings_recall] domain search → %d papers", len(papers))
+            return papers
+        except Exception as e:
+            logger.warning("[proceedings_recall] domain search failed: %s", e)
+            return []
+
+    discovered, domain_papers = await asyncio.gather(_discover(), _domain_search())
+    all_papers = _dedupe_by_title(discovered + domain_papers)
+
+    if plan.main_conference_proceedings_only:
+        pin_y = plan.year_from if plan.year_from == plan.year_to else None
+        all_papers = [
+            p for p in all_papers
+            if not should_exclude_main_conference_paper(p, venue, pinned_year=pin_y)
+        ]
+
+    logger.info(
+        "[proceedings_recall] venue=%s year=%s → %d total (discovered=%d, domain=%d)",
+        venue, year, len(all_papers), len(discovered), len(domain_papers),
+    )
+    return all_papers

+ 228 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/ranking_prompt.py

@@ -0,0 +1,228 @@
+"""LLM 精排 Prompt 构建 —— 根据 profile 生成不同排序策略的系统提示与用户提示."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from .method_acronym import is_method_acronym_token
+
+RANKER_SYSTEM_PROMPT = (
+    "你是学术文献评估专家。根据候选论文列表评估与排序;不得虚构论文。\n\n"
+    "排序原则:\n"
+    "1. 优先选择与用户查询语义最相关的论文\n"
+    "2. 识别经典/里程碑论文:引用量极高(>500)且发表≥5年的开创性工作应排在前面\n"
+    "3. 顶会/顶刊论文(Nature/Science/NeurIPS/CVPR/ICML/ICLR等)优先\n"
+    "4. 在相关性接近时,被广泛引用的论文优先于新发论文\n"
+    "5. 平衡新颖性:若用户明显在找最新方法,可适当降低经典论文权重\n\n"
+    "请严格按照要求的JSON格式输出。"
+)
+
+RANKER_SYSTEM_PROMPT_RETRY = (
+    "你是学术文献评估专家。根据候选论文列表评估与排序;不得虚构论文。\n\n"
+    "排序原则:优先语义相关,识别经典/里程碑论文,顶会论文优先,被广泛引用的论文优先。\n"
+    "请严格按照要求的JSON格式输出。"
+)
+
+
+def ranker_short_focus_query(query: str) -> bool:
+    q = (query or "").strip()
+    if not q:
+        return False
+    if is_method_acronym_token(q):
+        return True
+    return len(q.split()) <= 2 and len(q) <= 24
+
+
+def build_retrieval_constraints_block(
+    *,
+    target_titles: list[str] | None,
+    authors: list[str] | None,
+    venues: list[str] | None,
+    year_from: int | None,
+    year_to: int | None,
+    method_acronym: str | None = None,
+) -> str:
+    parts: list[str] = []
+    ma = (method_acronym or "").strip()
+    if ma:
+        parts.append(
+            f"- 方法缩写 **{ma}**:优先标题/摘要含「{ma}」的原始论文;"
+            f"若用户指某会议上的该方法,应匹配该方法的正式论文(锚定标题优先)"
+        )
+    tt = [str(t).strip() for t in (target_titles or []) if str(t).strip()][:4]
+    if tt:
+        parts.append(f"- 目标论文标题(优先精确匹配):{'; '.join(tt)}")
+    au = [str(a).strip() for a in (authors or []) if str(a).strip()][:6]
+    if au:
+        parts.append(f"- 目标作者:{', '.join(au)}")
+    vv = [str(v).strip() for v in (venues or []) if str(v).strip()][:4]
+    if vv:
+        parts.append(f"- 会议/期刊约束:{', '.join(vv)}")
+    if year_from is not None or year_to is not None:
+        yf = year_from if year_from is not None else "?"
+        yt = year_to if year_to is not None else yf
+        parts.append(f"- 年份范围:{yf}–{yt}")
+    if not parts:
+        return ""
+    return "\n## 检索约束(必须遵守)\n" + "\n".join(parts) + "\n"
+
+
+def _profile_task_and_dims(
+    profile: str,
+    *,
+    n_papers: int,
+    top_k: int,
+    target_venue: str | None,
+) -> tuple[str, str]:
+    if profile == "novelty":
+        return (
+            f"从 {n_papers} 篇中选出 top {top_k}(「近期进展 / 新工作」),"
+            "按「新且与查询相关」降序排列;相关度接近时优先更新、更前瞻的工作。",
+            """## 评估维度(novelty)
+1. **时效与趋势**(35%):年份更新;反映该方向最新设定或基准
+2. **主题相关性**(30%):与查询任务一致(可略宽于 accuracy)
+3. **新意与贡献**(25%):架构/目标/数据/结论上相比既有方法有明确新点
+4. **可信度底线**(10%):实验充分;无关或空壳工作后排""",
+        )
+    if profile == "classic":
+        return (
+            f"从 {n_papers} 篇中选出 top {top_k}(「原始奠基 / 里程碑式经典工作」),"
+            "优先**开创性论文**(查询所指方法/架构的原始提出);近年引用/综述后排,除非用户明确要综述。",
+            """## 评估维度(classic)
+1. **开创性与匹配**(40%):是否为查询所指方法/架构的**原始提出论文**或公认首作
+2. **引用与影响力**(35%):总引用与领域地位;仅讨论该方法的 survey 后排
+3. **权威出处**(15%):顶会/期刊正式收录
+4. **时效**(10%):开创性相近时优先更早的奠基论文""",
+        )
+    dims = """## 评估维度(accuracy)
+1. **主题相关性**(40%):论文主题与查询匹配程度
+2. **方法创新性**(25%):方法新颖与创新点
+3. **结果质量**(20%):实验充分性、结果可靠性
+4. **权威与可引用性**(15%):顶会/期刊与引用表现;经典工作可优先于纯新文"""
+    if target_venue:
+        dims = """## 评估维度(accuracy · 会议检索)
+1. **主题相关性**(35%):论文主题与查询匹配程度
+2. **届次与年份**(25%):相关度接近时**优先最近一届**(年份更大者优先)
+3. **方法创新性**(20%):方法新颖与创新点
+4. **结果质量**(10%):实验充分性、结果可靠性
+5. **权威与可引用性**(10%):正式 proceedings 与引用表现"""
+    return (
+        f"从 {n_papers} 篇中选出最相关的 top {top_k},按与检索意图匹配程度降序排列。",
+        dims,
+    )
+
+
+def build_ranking_prompt(
+    papers: list[Any],
+    query: str,
+    top_k: int,
+    ranking_profile: str = "accuracy",
+    *,
+    abstract_max_chars: int = 500,
+    target_venue: str | None = None,
+    main_conference_proceedings_only: bool = False,
+    intent_source_message: str | None = None,
+    target_titles: list[str] | None = None,
+    authors: list[str] | None = None,
+    venues: list[str] | None = None,
+    year_from: int | None = None,
+    year_to: int | None = None,
+    method_acronym: str | None = None,
+) -> str:
+    max_abs = max(120, int(abstract_max_chars or 500))
+    papers_desc: list[str] = []
+    for i, rp in enumerate(papers, 1):
+        paper = rp.paper
+        title = getattr(paper, "title", "N/A")
+        abstract = getattr(paper, "abstract", "") or ""
+        if len(abstract) > max_abs:
+            abstract = abstract[:max_abs] + "..."
+        year = getattr(paper, "year", "N/A")
+        venue = getattr(paper, "venue", "") or getattr(paper, "journal", "N/A")
+        citations = getattr(paper, "citations", 0) or 0
+        papers_desc.append(
+            f"\n【论文 {i}】\n标题:{title}\n年份:{year}\n会议/期刊:{venue}\n"
+            f"引用数:{citations}\n摘要:{abstract}\n"
+        )
+
+    constraint_hint = build_retrieval_constraints_block(
+        target_titles=target_titles,
+        authors=authors,
+        venues=venues or ([target_venue] if target_venue else None),
+        year_from=year_from,
+        year_to=year_to,
+        method_acronym=method_acronym,
+    )
+
+    venue_hint = ""
+    if target_venue:
+        venue_hint = f"""
+## 会场约束
+用户限定了 **{target_venue}** 会议。按以下优先级判断:
+1. **优先**:会议/期刊字段明确标注 {target_venue} 或其 proceedings 全称
+2. **降级**:仅标题/摘要提及 {target_venue} 但会议字段不明(arXiv预印本)
+3. **末位**:会议字段明确为其他会议
+4. 同相关度时**发表年份更近**优先;主会优先于 workshop/symposium。
+每条 reason 注明关联判断依据。
+
+"""
+
+    main_track_hint = ""
+    if main_conference_proceedings_only and target_venue:
+        um = (intent_source_message or "").strip()
+        um_block = f"\n### 用户原始表述\n{um[:700]}\n" if um else ""
+        main_track_hint = f"""
+## 主会议录用
+仅保留 **{target_venue}** 主会正式论文;排除 workshop、卫星会等。{um_block}
+依据「会议/期刊」字段判断,**非主会论文不得进入前 {top_k}**(不足则少填)。
+每条 reason 说明认定为主会的依据。
+
+"""
+
+    profile = (ranking_profile or "accuracy").strip().lower()
+    if profile not in ("accuracy", "novelty", "classic"):
+        profile = "accuracy"
+
+    short_disambig = ""
+    if ranker_short_focus_query(query):
+        short_disambig = """
+## 短查询消歧
+同名缩写论文:优先副标题更匹配 ML 顶会主流问题且会议为高等级 proceedings 的论文;
+下调任务/数据形态与用户意图明显不符的论文。每条 reason 说明区分依据。
+
+"""
+
+    task_line, dims = _profile_task_and_dims(
+        profile, n_papers=len(papers), top_k=top_k, target_venue=target_venue
+    )
+    papers_block = "\n---\n".join(papers_desc)
+
+    return f"""你是一位学术文献评估专家,请根据用户检索需求对以下论文精排序。
+
+## 用户检索需求
+{query}
+{constraint_hint}{short_disambig}{venue_hint}{main_track_hint}
+## 候选论文列表
+{papers_block}
+
+## 排序任务
+{task_line}
+
+{dims}
+
+## 输出格式
+JSON 格式:
+```json
+{{"rankings": [
+  {{"rank": 1, "paper_index": 1, "fine_score": 9.2, "reason": "排序理由:..."}},
+  ...
+]}}
+```
+
+要求(必须遵守):
+1. paper_index 对应序号 1-{len(papers)},不得虚构论文
+2. fine_score 范围 0-10,保留一位小数
+3. reason 用中文 2-3 句话简洁说明
+4. 只输出 JSON,无其他内容
+5. 仅依据上方列表判断,不得编造不存在的论文、会议或结果
+"""

+ 242 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/recall_context.py

@@ -0,0 +1,242 @@
+"""Plan → RecallContext:查询词、约束 kwargs、召回源。"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+from ...core.search import sanitize_pinned_topic_keywords
+from ...utils.author_query_match import pick_primary_english_author_for_query
+from .plan_helpers import (
+    is_pinned_single_year,
+    is_strict_venue_match,
+    is_venue_browse_plan,
+    method_acronym_for,
+    primary_venue,
+    use_venue_proceedings_journal,
+)
+from .search_plan import ResolvedSearchPlan
+from .search_recipe import SearchRecipe
+
+_ACADEMIC_SOURCES = frozenset({"arxiv", "dblp", "openalex"})
+
+
+@dataclass
+class RecallContext:
+    effective_query: str
+    rank_query: str
+    merged_keywords: list[str] = field(default_factory=list)
+    search_kwargs: dict[str, Any] = field(default_factory=dict)
+    ranking_profile: str = "accuracy"
+    recall_sources: list[str] = field(default_factory=list)
+    intent_source_message: str = ""
+    pinned_arxiv_ids: list[str] = field(default_factory=list)
+    tavily_keywords: list[str] = field(default_factory=list)
+    canonical_titles: list[str] = field(default_factory=list)
+    source_plan: dict[str, Any] = field(default_factory=dict)
+
+
+def _has_cjk(text: str) -> bool:
+    return bool(re.search(r"[\u4e00-\u9fff]", text or ""))
+
+
+def _latin_keywords(keywords: list[str]) -> list[str]:
+    out: list[str] = []
+    for kw in keywords:
+        t = str(kw).strip()
+        if not t or not re.search(r"[A-Za-z]", t):
+            continue
+        if _has_cjk(t) and sum(1 for ch in t if ord(ch) > 127) > max(2, len(t) // 3):
+            continue
+        out.append(t)
+    return out[:8]
+
+
+def _resolve_query_terms(plan: ResolvedSearchPlan) -> tuple[str, str, list[str], list[str], list[str]]:
+    raw_msg = (plan.raw_user_message or plan.query or "").strip()
+    query = (plan.query or "").strip()
+    target_titles = [str(t).strip() for t in (plan.target_titles or []) if str(t).strip()][:6]
+    authors = [str(a).strip() for a in (plan.authors or []) if str(a).strip()][:8]
+    author_low = {a.lower() for a in authors}
+
+    keywords: list[str] = []
+    for k in plan.keywords or []:
+        t = str(k).strip()
+        if not t:
+            continue
+        tl = t.lower()
+        if tl in author_low or any(a in tl for a in author_low if len(a) > 2):
+            continue
+        keywords.append(t)
+
+    effective_query = query
+    rank_query = query or raw_msg
+    merged_keywords = list(keywords)
+
+    if target_titles:
+        effective_query = target_titles[0]
+        rank_query = target_titles[0]
+        seen: set[str] = set()
+        merged_keywords = []
+        for t in target_titles + keywords:
+            tl = t.lower()
+            if tl not in seen:
+                merged_keywords.append(t)
+                seen.add(tl)
+        merged_keywords = merged_keywords[:16]
+    elif authors and not target_titles:
+        eng = pick_primary_english_author_for_query(authors) or query
+        effective_query = (eng or query).strip()
+        rank_query = raw_msg or effective_query
+    elif _has_cjk(query):
+        latin = _latin_keywords(keywords)
+        effective_query = (" ".join(latin)[:200].strip() if latin else (keywords[0] if keywords else query))
+        rank_query = raw_msg or query
+    elif not effective_query and keywords:
+        effective_query = keywords[0]
+        rank_query = raw_msg or effective_query
+
+    effective_query, rank_query, merged_keywords = _apply_venue_browse_query_defaults(
+        plan,
+        effective_query=effective_query,
+        rank_query=rank_query,
+        merged_keywords=merged_keywords,
+    )
+    return effective_query, rank_query, merged_keywords, target_titles, authors
+
+
+def _apply_venue_browse_query_defaults(
+    plan: ResolvedSearchPlan,
+    *,
+    effective_query: str,
+    rank_query: str,
+    merged_keywords: list[str],
+) -> tuple[str, str, list[str]]:
+    if not is_venue_browse_plan(plan):
+        return effective_query, rank_query, merged_keywords
+    venue = primary_venue(plan) or ""
+    year = plan.year_from
+    rank_query = (plan.raw_user_message or "").strip() or f"{venue} {year or ''}".strip()
+    return "", rank_query, []
+
+
+def build_recall_context(plan: ResolvedSearchPlan) -> RecallContext:
+    raw_msg = (plan.raw_user_message or plan.query or "").strip()
+    effective_query, rank_query, merged_keywords, target_titles, authors = _resolve_query_terms(plan)
+
+    ma = method_acronym_for(plan)
+    if ma and plan.recipe == SearchRecipe.METHOD and not target_titles:
+        merged_keywords = [ma]
+        effective_query = ma
+        rank_query = raw_msg or f"{ma} {primary_venue(plan) or ''}".strip()
+
+    ranking_profile = str(plan.ranking_profile or "accuracy").strip().lower()
+    if ranking_profile not in ("accuracy", "novelty", "classic"):
+        ranking_profile = "accuracy"
+    if target_titles or (ma and plan.recipe == SearchRecipe.METHOD):
+        ranking_profile = "classic"
+
+    recall_sources = [
+        str(s).strip().lower()
+        for s in (plan.sources or [])
+        if str(s).strip().lower() in _ACADEMIC_SOURCES
+    ] or ["arxiv", "dblp", "openalex"]
+
+    venue = primary_venue(plan)
+    search_kwargs: dict[str, Any] = {
+        "llm_keywords": merged_keywords[:8],
+        "target_titles": target_titles,
+        "authors": authors,
+        "venue": venue,
+        "year_from": plan.year_from,
+        "year_to": plan.year_to,
+        "main_conference_proceedings_only": bool(plan.main_conference_proceedings_only),
+        "venue_proceedings_journal": use_venue_proceedings_journal(plan),
+        "strict_venue_match": is_strict_venue_match(plan),
+        "wants_recent": bool(plan.wants_recent),
+        "sort": plan.sort or "relevance",
+        "arxiv_id_list": list(plan.arxiv_id_list or [])[:16],
+    }
+
+    if is_pinned_single_year(plan) and venue:
+        search_kwargs["pinned_topic_terms"] = sanitize_pinned_topic_keywords(
+            list(merged_keywords) + list(plan.keywords or [])
+        )
+    if is_pinned_single_year(plan) and plan.main_conference_proceedings_only and venue:
+        search_kwargs["venue_fallback_if_empty"] = False
+        search_kwargs["openalex_relax_host_venue_on_empty"] = False
+    if is_venue_browse_plan(plan):
+        search_kwargs["venue_browse"] = True
+    if ma:
+        search_kwargs.update(method_acronym=ma, llm_keywords=[ma], dblp_use_llm_keywords=False)
+
+    pinned = [str(x).strip() for x in (plan.arxiv_id_list or []) if str(x).strip()]
+
+    return RecallContext(
+        effective_query=effective_query,
+        rank_query=rank_query,
+        merged_keywords=merged_keywords,
+        search_kwargs=search_kwargs,
+        ranking_profile=ranking_profile,
+        recall_sources=recall_sources,
+        intent_source_message=raw_msg,
+        pinned_arxiv_ids=pinned,
+        source_plan={
+            "sources": recall_sources,
+            "effective_query": effective_query[:200],
+            "rank_query": rank_query[:200],
+            "ranking_profile": ranking_profile,
+            "recipe": plan.recipe.value,
+        },
+    )
+
+
+async def enrich_recall_context_from_tavily(ctx: RecallContext, plan: ResolvedSearchPlan) -> RecallContext:
+    if not plan.use_tavily:
+        return ctx
+    try:
+        from ...settings import get_settings
+        from .web_presearch import extract_anchor_ids, pick_anchor_title, tavily_search_async
+
+        api_key = str(getattr(get_settings(), "tavily_api_key", "") or "").strip()
+        if not api_key:
+            return ctx
+
+        ma = method_acronym_for(plan, ctx)
+        venue = primary_venue(plan) or ""
+        tq = (f"{ma} {venue} paper".strip() if ma and venue else (ctx.effective_query or ctx.rank_query or plan.query or "")).strip()
+        if not tq:
+            return ctx
+
+        items = await tavily_search_async(api_key=api_key, query=tq[:400], max_results=5)
+        anchors = extract_anchor_ids(items or [])
+
+        arxiv_ids = list(ctx.pinned_arxiv_ids)
+        for aid in anchors.get("arxiv_ids") or []:
+            if aid and aid not in arxiv_ids:
+                arxiv_ids.append(aid)
+        ctx.pinned_arxiv_ids = arxiv_ids[:16]
+
+        if title := pick_anchor_title(items or []):
+            if len(title) >= 12:
+                ctx.canonical_titles.insert(0, title[:240])
+        for it in items or []:
+            t = str(it.get("title") or "").strip()
+            if len(t) >= 12 and t.lower() not in {x.lower() for x in ctx.canonical_titles}:
+                if not any(x in t.lower() for x in ("home", "login", "index of", "schedule")):
+                    ctx.canonical_titles.append(t[:240])
+
+        ctx.search_kwargs["arxiv_id_list"] = ctx.pinned_arxiv_ids
+        tt = list(ctx.search_kwargs.get("target_titles") or [])
+        for title in ctx.canonical_titles:
+            if title and title not in tt:
+                tt.append(title)
+        if tt:
+            ctx.search_kwargs["target_titles"] = tt[:6]
+        if dois := anchors.get("dois"):
+            ctx.search_kwargs["dois"] = dois[:5]
+        ctx.tavily_keywords = list(ctx.canonical_titles)[:4]
+    except Exception:
+        pass
+    return ctx

+ 318 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/recall_jobs.py

@@ -0,0 +1,318 @@
+"""RecallJob — capability 驱动;build + execute 合一模块。"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field, replace
+from typing import Any, Callable, Literal
+
+import anyio
+
+from ...core.paper import Paper as LitPaper
+from ...core.search import PaperSearcher
+from .method_acronym import derive_full_title_from_named_method
+from .plan_helpers import (
+    is_venue_browse_plan,
+    method_acronym_for,
+    primary_venue,
+    should_supplement_from_proceedings_site,
+)
+from .pipeline_runtime import SearchRuntimeConfig
+from .proceedings_recall import recall_from_proceedings_site
+from .recall_context import RecallContext
+from .search_plan import ResolvedSearchPlan
+from .search_recipe import SearchRecipe
+
+MergeStrategy = Literal["prepend", "replace", "append"]
+RunWhen = Literal["always", "empty_candidates", "sparse_or_venue_browse"]
+SideEffect = Literal["none", "derive_method_title", "record_arxiv_fallback"]
+Runner = Literal["search", "proceedings"]
+
+_SKIP_KW = frozenset({"sources", "max_results"})
+
+
+@dataclass
+class RecallJob:
+    name: str
+    query: str
+    sources: list[str]
+    max_results: int
+    kwargs: dict[str, Any] = field(default_factory=dict)
+    runner: Runner = "search"
+    merge_strategy: MergeStrategy = "prepend"
+    run_when: RunWhen = "always"
+    side_effect: SideEffect = "none"
+    required: bool = False
+    needs_derived_query: bool = False
+    timeout_sec: float | None = None
+
+
+def dedupe_papers(
+    papers: list[LitPaper],
+    *,
+    identity_fn: Callable[[LitPaper], str] | None = None,
+) -> list[LitPaper]:
+    if not papers:
+        return []
+    if identity_fn is not None:
+        seen, out = set(), []
+        for p in papers:
+            k = identity_fn(p) or f"untitled:{id(p)}"
+            if k in seen:
+                continue
+            seen.add(k)
+            out.append(p)
+        papers = out
+    searcher = PaperSearcher.__new__(PaperSearcher)
+    return PaperSearcher._smart_deduplicate(searcher, papers)
+
+
+def merge_candidates(
+    current: list[LitPaper],
+    batch: list[LitPaper],
+    strategy: MergeStrategy,
+) -> list[LitPaper]:
+    if not batch:
+        return current
+    if strategy == "replace":
+        return dedupe_papers(list(batch))
+    if strategy == "append":
+        return dedupe_papers(current + batch)
+    return dedupe_papers(batch + current)
+
+
+def should_run_job(
+    job: RecallJob,
+    candidates: list[LitPaper],
+    *,
+    plan: ResolvedSearchPlan,
+    runtime: SearchRuntimeConfig,
+) -> bool:
+    if job.run_when == "empty_candidates":
+        return not candidates
+    if job.run_when == "sparse_or_venue_browse":
+        # Always run proceedings when venue is specified — topic+venue searches
+        # like "nips 异常检测" need venue-filtered papers from proceedings site
+        return runtime.proc_enabled and should_supplement_from_proceedings_site(plan) and (
+            bool(plan.venues)
+            or is_venue_browse_plan(plan)
+            or len(candidates) < runtime.proc_min
+        )
+    return True
+
+
+def _job_wall(job: RecallJob, runtime: SearchRuntimeConfig) -> float:
+    if job.timeout_sec is not None:
+        return float(job.timeout_sec)
+    if job.run_when == "empty_candidates":
+        return runtime.arxiv_fallback_wall
+    if job.required:
+        return runtime.recall_wall
+    return 12.0
+
+
+def _constraint_kwargs(constraint_kwargs: dict[str, Any], plan: ResolvedSearchPlan, runtime: SearchRuntimeConfig) -> dict[str, Any]:
+    sk = {k: v for k, v in constraint_kwargs.items() if k not in _SKIP_KW}
+    sk["sort"] = plan.sort or sk.get("sort") or "relevance"
+    sk.update(runtime.execution_kwargs())
+    return sk
+
+
+def build_recall_jobs(
+    plan: ResolvedSearchPlan,
+    ctx: RecallContext,
+    *,
+    runtime: SearchRuntimeConfig,
+    constraint_kwargs: dict[str, Any],
+) -> list[RecallJob]:
+    sk = _constraint_kwargs(constraint_kwargs, plan, runtime)
+    jobs: list[RecallJob] = [
+        RecallJob(
+            "primary",
+            ctx.effective_query,
+            list(ctx.recall_sources),
+            runtime.recall_cap,
+            kwargs=dict(sk),
+            required=True,
+        )
+    ]
+
+    ma = method_acronym_for(plan, ctx)
+    if ma and plan.recipe in (SearchRecipe.METHOD, SearchRecipe.VENUE_YEAR):
+        ax_sk = {**sk, "llm_keywords": [ma]}
+        jobs.append(
+            RecallJob("method_arxiv_boost", ma, ["arxiv"], 16, kwargs=ax_sk, side_effect="derive_method_title", timeout_sec=12.0)
+        )
+        if plan.venues:
+            jobs.append(
+                RecallJob(
+                    "method_venue_recall",
+                    "",
+                    ["dblp", "openalex"],
+                    runtime.recall_cap,
+                    kwargs={k: v for k, v in sk.items() if k != "venue_browse"},
+                    needs_derived_query=True,
+                    timeout_sec=18.0,
+                )
+            )
+
+    if plan.fallback.allow_arxiv_only and "arxiv" not in ctx.recall_sources:
+        q = (ctx.effective_query or ctx.rank_query or plan.query or "")[:100]
+        jobs.append(
+            RecallJob(
+                "arxiv_fallback",
+                q,
+                ["arxiv"],
+                20,
+                kwargs={k: v for k, v in sk.items() if k != "venue_fallback_if_empty"}
+                | {"http_timeout_sec": 8, "http_max_attempts": 1},
+                merge_strategy="replace",
+                run_when="empty_candidates",
+                side_effect="record_arxiv_fallback",
+            )
+        )
+
+    if should_supplement_from_proceedings_site(plan):
+        jobs.append(
+            RecallJob(
+                "proceedings",
+                ctx.effective_query,
+                ["proceedings"],
+                runtime.recall_cap,
+                runner="proceedings",
+                run_when="sparse_or_venue_browse",
+            )
+        )
+    return jobs
+
+
+def enrich_method_context_from_boost(ax_papers: list[LitPaper], method_acronym: str, ctx: RecallContext) -> str | None:
+    derived: str | None = None
+    for p in ax_papers:
+        if full := derive_full_title_from_named_method(p, method_acronym):
+            if full not in ctx.canonical_titles:
+                ctx.canonical_titles.append(full)
+            derived = derived or full
+    if derived:
+        tt = list(ctx.search_kwargs.get("target_titles") or [])
+        if derived not in tt:
+            ctx.search_kwargs["target_titles"] = (tt + [derived])[:6]
+    return derived
+
+
+async def _run_search_job(searcher: Any, job: RecallJob, runtime: SearchRuntimeConfig) -> tuple[list[LitPaper], str | None]:
+    if not searcher:
+        return [], None
+    wall = _job_wall(job, runtime)
+    sk = {k: v for k, v in job.kwargs.items() if k not in _SKIP_KW}
+    sk.setdefault("sort", "relevance")
+    try:
+        with anyio.fail_after(wall):
+            if hasattr(searcher, "search_async"):
+                papers = await searcher.search_async(job.query, sources=job.sources, max_results=job.max_results, **sk)
+            else:
+                papers = await anyio.to_thread.run_sync(
+                    lambda: searcher.search(job.query, sources=job.sources, max_results=job.max_results, **sk)
+                )
+            return list(papers or []), None
+    except TimeoutError:
+        return ([], f"多源召回超时({wall:.0f}秒)") if job.required else ([], None)
+    except Exception as e:
+        return ([], f"搜索异常: {str(e)[:100]}") if job.required else ([], None)
+
+
+async def execute_recall_jobs(
+    searcher: Any,
+    jobs: list[RecallJob],
+    *,
+    plan: ResolvedSearchPlan,
+    ctx: RecallContext,
+    runtime: SearchRuntimeConfig,
+    meta: dict[str, Any],
+    fallbacks: list[dict[str, Any]],
+) -> list[LitPaper]:
+    candidates: list[LitPaper] = []
+    search_error: str | None = None
+    pending_derived = next((j for j in jobs if j.needs_derived_query), None)
+    jobs_executed: list[str] = []
+    venue = primary_venue(plan)
+
+    for job in jobs:
+        if job.needs_derived_query or not should_run_job(job, candidates, plan=plan, runtime=runtime):
+            continue
+
+        batch: list[LitPaper] = []
+        try:
+            if job.runner == "proceedings":
+                wall = max(8.0, min(45.0, runtime.recall_wall * 0.6))
+                with anyio.fail_after(wall):
+                    batch = list(
+                        await recall_from_proceedings_site(
+                            searcher, plan=plan, ctx=ctx, max_results=job.max_results
+                        )
+                        or []
+                    )
+            else:
+                batch, err = await _run_search_job(searcher, job, runtime)
+                if job.required:
+                    search_error = err
+        except TimeoutError:
+            if job.runner == "proceedings":
+                meta["proceedings_supplement"] = {"error": "timeout"}
+            continue
+        except Exception as e:
+            if job.runner == "proceedings":
+                meta["proceedings_supplement"] = {"error": str(e)[:120]}
+            continue
+
+        if not batch and job.runner != "proceedings":
+            continue
+
+        if job.runner == "proceedings":
+            before = len(candidates)
+            candidates = merge_candidates(candidates, batch, job.merge_strategy)
+            meta["proceedings_supplement"] = {
+                "venue": venue,
+                "year": plan.year_from,
+                "added": len(candidates) - before,
+                "source": "openaccess_proceedings",
+            }
+            fallbacks.append(
+                {"type": "proceedings_site", "reason": "sparse_dblp_openalex_main_track", "count": len(batch)}
+            )
+        else:
+            candidates = merge_candidates(candidates, batch, job.merge_strategy)
+            if job.side_effect == "derive_method_title" and batch:
+                ma = method_acronym_for(plan, ctx)
+                if ma:
+                    meta["method_acronym_arxiv_boost"] = len(batch)
+                    derived = enrich_method_context_from_boost(batch, ma, ctx)
+                    if pending_derived and derived:
+                        if resolved := _resolve_derived_job(pending_derived, derived):
+                            extra, _ = await _run_search_job(searcher, resolved, runtime)
+                            if extra:
+                                meta["method_acronym_venue_recall"] = len(extra)
+                                candidates = merge_candidates(candidates, extra, "prepend")
+                            pending_derived = None
+            if job.side_effect == "record_arxiv_fallback":
+                fallbacks.append({"type": "arxiv_only", "reason": "no_candidates_from_primary"})
+                meta.setdefault("search_debug", {})["fallback"] = "arxiv_only"
+
+        jobs_executed.append(job.name)
+
+    meta["search_debug"] = {
+        "effective_query": ctx.effective_query[:200],
+        "rank_query": ctx.rank_query[:200],
+        "candidates_raw_count": len(candidates),
+        "search_error": search_error,
+        "recall_sources": list(ctx.recall_sources),
+        "recipe": plan.recipe.value,
+        "jobs_executed": jobs_executed,
+    }
+    return candidates
+
+
+def _resolve_derived_job(job: RecallJob, derived_query: str) -> RecallJob | None:
+    if len(derived_query) < 12:
+        return None
+    kwargs = {**job.kwargs, "target_titles": [derived_query]}
+    return replace(job, query=derived_query, kwargs=kwargs, needs_derived_query=False)

+ 149 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/relevance_guard.py

@@ -0,0 +1,149 @@
+"""Score-based relevance guard before LLM rank (only when candidate pool is large)."""
+
+from __future__ import annotations
+
+from ...core.paper import Paper as LitPaper
+from ...core.search.paper_searcher import PaperSearcher, _has_any_author
+from ...utils.author_query_match import normalize_author_names
+from .method_acronym import is_method_acronym_token, title_matches_method_acronym
+from .search_plan import ResolvedSearchPlan
+
+_DEFAULT_THRESHOLD = 40
+_MIN_KEEP = 8
+
+_SCORE_TITLE = 5
+_SCORE_AUTHOR = 4
+_SCORE_VENUE = 3
+_SCORE_KEYWORD = 2
+_SCORE_YEAR = 1
+_SCORE_METHOD_ACRONYM = 6
+
+
+def apply_relevance_guard(
+    candidates: list[LitPaper],
+    *,
+    plan: ResolvedSearchPlan,
+    guard_threshold: int = _DEFAULT_THRESHOLD,
+    min_keep: int = _MIN_KEEP,
+) -> tuple[list[LitPaper], bool]:
+    """候选过多时按相关性打分软过滤;过滤后过少则回退原列表。"""
+    if len(candidates) <= guard_threshold:
+        return candidates, False
+
+    target_titles = [t.lower() for t in (plan.target_titles or []) if t.strip()]
+    keywords = [
+        k.lower()
+        for k in (plan.keywords or [])
+        if len(str(k).strip()) >= 2
+    ]
+    venues = [v for v in (plan.venues or []) if v.strip()]
+    author_phrases = normalize_author_names(plan.authors or [])
+    yf, yt = plan.year_from, plan.year_to
+    method_acronym = (getattr(plan, "method_acronym", None) or "").strip() or None
+    if not method_acronym and len(keywords) == 1 and is_method_acronym_token(keywords[0]):
+        method_acronym = keywords[0]
+
+    kept: list[LitPaper] = []
+    for p in candidates:
+        score = _relevance_score(
+            p,
+            target_titles=target_titles,
+            keywords=keywords,
+            venues=venues,
+            author_phrases=author_phrases,
+            year_from=yf,
+            year_to=yt,
+            method_acronym=method_acronym,
+        )
+        if _passes_guard_threshold(
+            score,
+            plan=plan,
+            has_target_titles=bool(target_titles),
+            has_strong_constraints=bool(venues or author_phrases or yf is not None),
+            method_acronym=method_acronym,
+        ):
+            kept.append(p)
+
+    if len(kept) < min_keep:
+        return candidates, False
+    return kept, True
+
+
+def _passes_guard_threshold(
+    score: int,
+    *,
+    plan: ResolvedSearchPlan,
+    has_target_titles: bool,
+    has_strong_constraints: bool,
+    method_acronym: str | None = None,
+) -> bool:
+    if method_acronym:
+        return score >= _SCORE_METHOD_ACRONYM
+    if has_target_titles:
+        return score >= _SCORE_TITLE
+    if has_strong_constraints:
+        return score >= (_SCORE_VENUE + _SCORE_KEYWORD - 2)  # >= 3
+    return score >= (_SCORE_KEYWORD)  # >= 2 for broad keyword queries
+
+
+def _relevance_score(
+    p: LitPaper,
+    *,
+    target_titles: list[str],
+    keywords: list[str],
+    venues: list[str],
+    author_phrases: list[str],
+    year_from: int | None,
+    year_to: int | None,
+    method_acronym: str | None = None,
+) -> int:
+    score = 0
+    title = (getattr(p, "title", None) or "").lower()
+    abstract = (getattr(p, "abstract", None) or "").lower()
+    journal = (getattr(p, "journal", None) or getattr(p, "venue", None) or "").lower()
+    blob = f"{title} {abstract} {journal}"
+
+    if target_titles and any(
+        (len(tt) > 8 and (tt in title or title in tt)) for tt in target_titles
+    ):
+        score += _SCORE_TITLE
+
+    if method_acronym:
+        if title_matches_method_acronym(f"{title} {abstract}", method_acronym):
+            score += _SCORE_METHOD_ACRONYM
+        elif target_titles and any(
+            len(tt) >= 12 and (tt in title or title in tt) for tt in target_titles
+        ):
+            score += _SCORE_METHOD_ACRONYM
+
+    if author_phrases and _has_any_author(p, author_phrases):
+        score += _SCORE_AUTHOR
+
+    if venues:
+        for v in venues:
+            if PaperSearcher._paper_matches_venue_proceedings(p, v) or v.lower() in blob:
+                score += _SCORE_VENUE
+                break
+
+    if keywords:
+        kw_hits = sum(1 for kw in keywords if kw in blob)
+        if kw_hits >= 2 or (kw_hits >= 1 and len(keywords) <= 3):
+            score += _SCORE_KEYWORD
+        elif kw_hits == 1:
+            score += 1
+
+    if year_from is not None or year_to is not None:
+        try:
+            py = int(getattr(p, "year", 0) or 0)
+        except (TypeError, ValueError):
+            py = 0
+        if py:
+            in_range = True
+            if year_from is not None and py < int(year_from):
+                in_range = False
+            if year_to is not None and py > int(year_to):
+                in_range = False
+            if in_range:
+                score += _SCORE_YEAR
+
+    return score

+ 262 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/search_pipeline.py

@@ -0,0 +1,262 @@
+"""检索流水线 —— 多源召回 → 去重过滤 → LLM 精排 → 结果输出."""
+
+from __future__ import annotations
+
+import asyncio
+from collections import Counter
+from dataclasses import dataclass
+from typing import Any
+
+import anyio
+
+from ...core.paper import Paper as LitPaper
+from ...settings import get_settings
+from .paper_filters import should_exclude_main_conference_paper
+from .paper_ranker import LlmPaperRanker, RankedPaper
+from .pipeline_runtime import SearchRuntimeConfig
+from .plan_helpers import is_venue_browse_plan, method_acronym_for, primary_venue
+from .recall_context import RecallContext, build_recall_context, enrich_recall_context_from_tavily
+from .recall_jobs import build_recall_jobs, dedupe_papers, execute_recall_jobs, merge_candidates
+from .relevance_guard import apply_relevance_guard
+from .search_plan import ResolvedSearchPlan
+
+
+@dataclass
+class SearchPipelineResult:
+    effective_query: str
+    total_candidates: int
+    ranking_method: str
+    ranked: list[RankedPaper]
+    metadata: dict[str, Any]
+    plan: dict[str, Any]
+    plan_explanation: str
+
+
+def _merge_pinned_papers(candidates: list[LitPaper], pinned_ids: list[str], searcher: Any) -> list[LitPaper]:
+    if not pinned_ids:
+        return candidates
+    try:
+        if hasattr(searcher, "search_by_arxiv_ids"):
+            pinned = searcher.search_by_arxiv_ids(pinned_ids)
+        elif hasattr(searcher, "search_async"):
+            loop = asyncio.new_event_loop()
+            try:
+                pinned = loop.run_until_complete(
+                    searcher.search_async(
+                        "",
+                        sources=["arxiv"],
+                        arxiv_id_list=pinned_ids,
+                        max_results=len(pinned_ids) * 2,
+                    )
+                )
+            finally:
+                loop.close()
+        else:
+            pinned = searcher.search(
+                "",
+                sources=["arxiv"],
+                arxiv_id_list=pinned_ids,
+                max_results=len(pinned_ids) * 2,
+            )
+        pinned = pinned or []
+    except Exception:
+        pinned = []
+    return merge_candidates(candidates, list(pinned), "prepend")
+
+
+def _merge_target_titles(plan: ResolvedSearchPlan, ctx: RecallContext) -> list[str]:
+    seen: set[str] = set()
+    out: list[str] = []
+    for t in list(plan.target_titles or []) + list(ctx.canonical_titles or []):
+        tl = (t or "").strip()
+        if tl and tl.lower() not in seen:
+            seen.add(tl.lower())
+            out.append(tl)
+    return out[:6]
+
+
+def normalize_and_filter_candidates(
+    candidates: list[LitPaper],
+    *,
+    plan: ResolvedSearchPlan,
+    ctx: RecallContext,
+    recall_cap: int,
+    meta: dict[str, Any],
+) -> list[LitPaper]:
+    venue = primary_venue(plan)
+    ma = method_acronym_for(plan, ctx) or None
+    candidates = dedupe_papers(candidates)
+
+    if ma:
+        from .method_acronym import paper_matches_method_query
+
+        narrowed = [
+            p
+            for p in candidates
+            if paper_matches_method_query(
+                p,
+                ma,
+                canonical_titles=ctx.canonical_titles,
+                pinned_arxiv_ids=ctx.pinned_arxiv_ids,
+                venue=venue,
+            )
+        ]
+        if narrowed:
+            candidates = narrowed
+
+    guard_threshold = max(36, recall_cap + 8)
+    if ma:
+        guard_threshold = max(10, min(guard_threshold, len(candidates) + 2))
+    if not is_venue_browse_plan(plan):
+        candidates, guard_applied = apply_relevance_guard(candidates, plan=plan, guard_threshold=guard_threshold)
+        if guard_applied:
+            meta["relevance_guard"] = True
+
+    if plan.main_conference_proceedings_only and venue:
+        pin_y = plan.year_from if plan.year_from == plan.year_to else None
+        # Only require strong venue signal if we actually found venue-verified papers
+        from .paper_filters import has_strong_main_conference_venue_signal
+        venue_verified_count = sum(1 for p in candidates if has_strong_main_conference_venue_signal(p, venue))
+        require_venue_signal = venue_verified_count >= 3
+        candidates = [
+            p
+            for p in candidates
+            if not should_exclude_main_conference_paper(
+                p,
+                venue,
+                pinned_year=pin_y,
+                require_venue_signal=require_venue_signal,
+            )
+        ]
+    return candidates
+
+
+async def rank_candidates(
+    candidates: list[LitPaper],
+    *,
+    plan: ResolvedSearchPlan,
+    ctx: RecallContext,
+    runtime: SearchRuntimeConfig,
+    meta: dict[str, Any],
+) -> tuple[list[RankedPaper], str, dict[str, Any]]:
+    if not candidates:
+        return [], "recall_only", {}
+    if not plan.use_llm_rank:
+        return [RankedPaper(paper=p) for p in candidates[: runtime.max_results]], "recall_only", {}
+
+    venue = primary_venue(plan)
+    ranker = LlmPaperRanker(recall_max=runtime.recall_max, fine_top_k=runtime.max_results)
+    prefer_rec = (plan.sort or "").strip().lower() == "date" or bool(plan.year_from) or bool(venue)
+    try:
+        with anyio.fail_after(runtime.rank_wall):
+            ranked, ranking_metadata = await anyio.to_thread.run_sync(
+                lambda: ranker.rank(
+                    candidates,
+                    ctx.rank_query,
+                    runtime.max_results,
+                    ranking_profile=ctx.ranking_profile,
+                    target_venue=venue,
+                    target_titles=_merge_target_titles(plan, ctx),
+                    authors=list(plan.authors or []),
+                    venues=list(plan.venues or []),
+                    year_from=plan.year_from,
+                    year_to=plan.year_to,
+                    sort=plan.sort,
+                    prefer_recency=prefer_rec,
+                    main_conference_proceedings_only=bool(plan.main_conference_proceedings_only),
+                    intent_source_message=ctx.intent_source_message,
+                    method_acronym=ctx.search_kwargs.get("method_acronym"),
+                )
+            )
+        return ranked, ranking_metadata.get("ranking_method", "llm_rank"), ranking_metadata
+    except TimeoutError:
+        meta["ranking_timeout"] = True
+        from .paper_ranker import _papers_to_ranked_pool
+
+        pool = _papers_to_ranked_pool(candidates, cap=runtime.recall_max, prefer_recency=prefer_rec)
+        return pool[: runtime.max_results], "recall_fallback_timeout", {}
+
+
+async def run_search_pipeline_async(
+    *,
+    searcher: Any,
+    plan: ResolvedSearchPlan,
+    max_results: int | None = None,
+) -> SearchPipelineResult:
+    runtime = SearchRuntimeConfig.from_settings(get_settings(), plan, max_results)
+    ctx = await enrich_recall_context_from_tavily(build_recall_context(plan), plan)
+
+    meta: dict[str, Any] = {
+        "ranking_profile": ctx.ranking_profile,
+        "source_plan": ctx.source_plan,
+        "recall_context": {
+            "effective_query": ctx.effective_query[:200],
+            "rank_query": ctx.rank_query[:200],
+            "merged_keywords": ctx.merged_keywords[:12],
+        },
+        "search_recipe": plan.recipe.value,
+    }
+    fallbacks: list[dict[str, Any]] = []
+
+    # 阶段 1: 多源并行召回
+    constraint_kwargs = {**ctx.search_kwargs, "sort": plan.sort or ctx.search_kwargs.get("sort") or "relevance"}
+    jobs = build_recall_jobs(plan, ctx, runtime=runtime, constraint_kwargs=constraint_kwargs)
+    candidates = await execute_recall_jobs(
+        searcher, jobs, plan=plan, ctx=ctx, runtime=runtime, meta=meta, fallbacks=fallbacks
+    )
+
+    # 阶段 2: 补回用户指定的 arXiv ID(pinned papers)
+    pinned_ids = list(ctx.pinned_arxiv_ids or [])
+    if pinned_ids and searcher is not None:
+        try:
+            with anyio.fail_after(3.0):
+                candidates = await anyio.to_thread.run_sync(
+                    _merge_pinned_papers, candidates, pinned_ids, searcher
+                )
+        except TimeoutError:
+            pass
+
+    # 阶段 3: 去重、过滤非主会论文、相关性守卫
+    candidates = normalize_and_filter_candidates(
+        candidates, plan=plan, ctx=ctx, recall_cap=runtime.recall_cap, meta=meta
+    )
+    # 阶段 4: LLM 精排(或召回直接截断)
+    ranked, ranking_method, ranking_metadata = await rank_candidates(
+        candidates, plan=plan, ctx=ctx, runtime=runtime, meta=meta
+    )
+
+    if not candidates and plan.fallback.allow_arxiv_only:
+        fallbacks.append({"type": "arxiv_only", "reason": "no_candidates_after_recall"})
+
+    sc = Counter(getattr(p, "source", "unknown") or "unknown" for p in candidates)
+    rsc = Counter(getattr(rp.paper, "source", "unknown") or "unknown" for rp in ranked)
+    metadata = {
+        "tavily_enabled": plan.use_tavily,
+        "tavily_keywords_count": len(ctx.tavily_keywords),
+        "anchor_title": ctx.canonical_titles[0] if ctx.canonical_titles else None,
+        "anchor_arxiv_ids": pinned_ids,
+        "pinned_arxiv_ids": pinned_ids,
+        "fallbacks": fallbacks,
+        "candidates_by_source": dict(sc),
+        "ranked_by_source": dict(rsc),
+        "deduped_total": len(candidates),
+        "final_ranked": len(ranked),
+        **meta,
+    }
+    if ranking_metadata:
+        metadata["ranking"] = ranking_metadata
+
+    return SearchPipelineResult(
+        effective_query=ctx.effective_query,
+        total_candidates=len(candidates),
+        ranking_method=ranking_method,
+        ranked=ranked,
+        metadata=metadata,
+        plan={
+            "llm_keywords": ctx.merged_keywords,
+            "tavily_keywords": ctx.tavily_keywords,
+            "canonical_titles": ctx.canonical_titles,
+            "recipe": plan.recipe.value,
+        },
+        plan_explanation="",
+    )

+ 127 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/search_plan.py

@@ -0,0 +1,127 @@
+"""Resolved search plan + FallbackPolicy — single source of truth between intent and pipeline."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from .search_recipe import SearchRecipe, finalize_plan_recipe
+
+
+@dataclass
+class FallbackPolicy:
+    allow_arxiv_only: bool = True
+    reason: str = "auto"
+
+
+@dataclass
+class ResolvedSearchPlan:
+    """All search parameters resolved once, used by pipeline. No LLM calls in retrieval layer."""
+    query: str = ""
+    keywords: list[str] = field(default_factory=list)
+    authors: list[str] = field(default_factory=list)
+    venues: list[str] = field(default_factory=list)
+    year_from: int | None = None
+    year_to: int | None = None
+    sources: list[str] = field(default_factory=list)
+    sort: str = "relevance"
+    ranking_profile: str = "accuracy"
+    use_llm_rank: bool = True
+    recall_max_candidates: int = 24
+    target_titles: list[str] = field(default_factory=list)
+    arxiv_id_list: list[str] = field(default_factory=list)
+    main_conference_proceedings_only: bool = False
+    raw_user_message: str = ""
+    wants_recent: bool = False
+    wants_classic: bool = False
+    fallback: FallbackPolicy = field(default_factory=FallbackPolicy)
+    use_tavily: bool = False
+    max_results: int = 10
+    recipe: SearchRecipe = SearchRecipe.GENERAL
+    method_acronym: str | None = None
+
+    @classmethod
+    def from_search_intent(cls, intent) -> "ResolvedSearchPlan":
+        plan = cls(
+            query=(intent.query or "").strip()[:500],
+            keywords=list(intent.keywords or [])[:16],
+            authors=list(getattr(intent, "authors", []) or [])[:8],
+            venues=list(intent.venues or []),
+            year_from=_norm_year(intent.year_from),
+            year_to=_norm_year(intent.year_to),
+            sources=_resolve_sources(intent),
+            sort=_resolve_sort(intent),
+            ranking_profile=_resolve_profile(intent),
+            use_llm_rank=bool(getattr(intent, "use_llm_rank", True)),
+            recall_max_candidates=_resolve_recall_max(intent),
+            target_titles=list(getattr(intent, "target_titles", []) or [])[:6],
+            arxiv_id_list=list(getattr(intent, "arxiv_id_list", []) or [])[:16],
+            main_conference_proceedings_only=bool(getattr(intent, "main_conference_proceedings_only", False)),
+            raw_user_message=(getattr(intent, "raw_user_message", "") or "")[:3200],
+            wants_recent=bool(getattr(intent, "wants_recent", False)),
+            wants_classic=bool(getattr(intent, "wants_classic", False)),
+            use_tavily=_resolve_use_tavily(intent),
+            max_results=max(5, min(30, int(getattr(intent, "max_results", 10) or 10))),
+        )
+        return _finalize_plan_for_retrieval(plan)
+
+
+def _finalize_plan_for_retrieval(plan: ResolvedSearchPlan) -> ResolvedSearchPlan:
+    """RECIPE_RULES 判定并应用策略;派生状态见 plan_helpers。"""
+    return finalize_plan_recipe(plan)
+
+
+def _resolve_sort(intent) -> str:
+    rk = getattr(intent, "ranking_strategy", None)
+    if rk == "date":
+        return "date"
+    if rk == "relevance":
+        return "relevance"
+    if getattr(intent, "wants_recent", False):
+        return "date"
+    if getattr(intent, "wants_classic", False):
+        return "relevance"
+    return str(getattr(intent, "sort", "relevance") or "relevance")
+
+
+def _resolve_profile(intent) -> str:
+    if getattr(intent, "wants_classic", False):
+        return "classic"
+    if getattr(intent, "wants_recent", False):
+        return "novelty"
+    return "accuracy"
+
+
+def _resolve_sources(intent) -> list[str]:
+    llm_src = getattr(intent, "sources", []) or []
+    allowed = {"arxiv", "dblp", "openalex"}
+    if llm_src:
+        resolved = [s for s in llm_src if s in allowed]
+        if resolved:
+            return resolved
+    return ["arxiv", "dblp", "openalex"]
+
+
+def _resolve_use_tavily(intent) -> bool:
+    llm_src = [str(s).strip().lower() for s in (getattr(intent, "sources", []) or [])]
+    return "tavily" in llm_src or bool(getattr(intent, "use_tavily_presearch", False))
+
+
+def _resolve_recall_max(intent) -> int:
+    try:
+        from ...settings import get_settings
+
+        cap = int(get_settings().papergraph_recall_max_candidates)
+    except Exception:
+        cap = 24
+    raw = int(getattr(intent, "rerank_recall_max", 24) or 24)
+    return max(8, min(cap, raw))
+
+
+def _norm_year(y) -> int | None:
+    if y is None:
+        return None
+    try:
+        yi = int(y)
+        return yi if 1900 <= yi <= 2100 else None
+    except (TypeError, ValueError):
+        return None

+ 149 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/search_recipe.py

@@ -0,0 +1,149 @@
+"""SearchRecipe — RECIPE_RULES 表统一判定与应用。"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import TYPE_CHECKING, Callable
+
+from .method_acronym import resolve_method_acronym
+from .plan_helpers import _keywords, _venues, is_pinned_single_year
+
+if TYPE_CHECKING:
+    from .search_plan import ResolvedSearchPlan
+
+
+class SearchRecipe(str, Enum):
+    GENERAL = "general"
+    TITLE = "title"
+    AUTHOR = "author"
+    VENUE_YEAR = "venue_year"
+    METHOD = "method"
+
+
+def _is_venue_method(plan: "ResolvedSearchPlan") -> bool:
+    return bool(_venues(plan) and resolve_method_acronym(plan.query or "", _keywords(plan)))
+
+
+def _is_venue_year(plan: "ResolvedSearchPlan") -> bool:
+    return bool(_venues(plan))
+
+
+def _is_method(plan: "ResolvedSearchPlan") -> bool:
+    return bool(resolve_method_acronym(plan.query or "", _keywords(plan)))
+
+
+def _normalize_venue_years(plan: "ResolvedSearchPlan") -> None:
+    venues = _venues(plan)
+    if not venues:
+        return
+
+    yf, yt = plan.year_from, plan.year_to
+    if plan.wants_recent and not plan.wants_classic:
+        plan.main_conference_proceedings_only = True
+        plan.sort = "date"
+        from ..search_intent.parsing import infer_target_edition_year_for_recent
+
+        pin_y = infer_target_edition_year_for_recent(is_latest=True)
+        if yf is None and yt is None:
+            yf = yt = pin_y
+        elif yf is not None and (yt is None or int(yt) - int(yf) > 1):
+            yf = yt = pin_y
+        elif yf is not None and yt is not None and int(yf) != int(yt):
+            yf = yt = pin_y
+
+    if yf is not None and yt is None:
+        yt = yf
+    plan.year_from, plan.year_to = yf, yt
+
+    pinned_single_year = isinstance(yf, int) and isinstance(yt, int) and yf == yt
+    has_topic = bool(_keywords(plan) or (plan.query or "").strip())
+    if pinned_single_year and not has_topic and not plan.authors and not plan.target_titles:
+        plan.main_conference_proceedings_only = True
+        if not (plan.sort or "").strip() or plan.sort == "relevance":
+            plan.sort = "date"
+
+
+def _apply_venue_year(plan: "ResolvedSearchPlan", *, skip_browse_limits: bool = False) -> None:
+    _normalize_venue_years(plan)
+    if not (is_pinned_single_year(plan) and plan.main_conference_proceedings_only):
+        return
+
+    # Keep all sources (arxiv + dblp + openalex) — let relevance guard and LLM ranker filter,
+    # instead of hardcoding source exclusion.
+    if "arxiv" not in plan.sources:
+        plan.sources = ["arxiv"] + plan.sources
+    try:
+        from ...settings import get_settings
+
+        recall_cap = int(get_settings().papergraph_recall_max_candidates)
+    except Exception:
+        recall_cap = 24
+    plan.recall_max_candidates = min(max(int(plan.recall_max_candidates or 24), 8), recall_cap)
+
+    venues = _venues(plan)
+    venue = venues[0] if venues else ""
+    year = plan.year_from if isinstance(plan.year_from, int) else None
+    orig_q = (plan.query or "").strip()
+    v_blob = " ".join(v.lower() for v in venues)
+
+    if not orig_q or any(v.lower() in orig_q.lower() for v in venues):
+        plan.query = ""
+
+    plan.keywords = [
+        k
+        for k in (plan.keywords or [])
+        if str(k).strip()
+        and str(k).strip().lower() not in v_blob
+        and "computer vision" not in str(k).lower()
+    ][:8]
+
+    from ...core.search.normalize import extract_pinned_topic_terms
+
+    query_only_topic = extract_pinned_topic_terms(
+        query=orig_q, merged_kw=[], venue=venue, year=year
+    ).strip()
+    if not (plan.query or "").strip():
+        if query_only_topic:
+            plan.query = query_only_topic[:200]
+        else:
+            # 用户句子里无独立主题(如「CVPR 最新论文」);丢弃 LLM 附带的 latest/papers 等
+            plan.keywords = []
+
+    if skip_browse_limits:
+        return
+    from .plan_helpers import effective_max_results, effective_recall_max_candidates, is_venue_browse_plan
+
+    if is_venue_browse_plan(plan):
+        plan.max_results = effective_max_results(plan, plan.max_results)
+        plan.recall_max_candidates = effective_recall_max_candidates(plan, plan.recall_max_candidates)
+
+
+def _apply_method(plan: "ResolvedSearchPlan") -> None:
+    ma = resolve_method_acronym(plan.query or "", _keywords(plan))
+    if ma:
+        plan.method_acronym = ma
+
+
+def _apply_method_at_venue(plan: "ResolvedSearchPlan") -> None:
+    _apply_method(plan)
+    _apply_venue_year(plan, skip_browse_limits=True)
+
+
+RecipeApplyFn = Callable[["ResolvedSearchPlan"], None]
+RecipeRule = tuple[Callable[["ResolvedSearchPlan"], bool], SearchRecipe, RecipeApplyFn]
+
+RECIPE_RULES: list[RecipeRule] = [
+    (_is_venue_method, SearchRecipe.METHOD, _apply_method_at_venue),
+    (_is_venue_year, SearchRecipe.VENUE_YEAR, _apply_venue_year),
+    (_is_method, SearchRecipe.METHOD, _apply_method),
+]
+
+
+def finalize_plan_recipe(plan: "ResolvedSearchPlan") -> "ResolvedSearchPlan":
+    for predicate, recipe, apply_fn in RECIPE_RULES:
+        if predicate(plan):
+            plan.recipe = recipe
+            apply_fn(plan)
+            return plan
+    plan.recipe = SearchRecipe.GENERAL
+    return plan

+ 33 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/source_plan.py

@@ -0,0 +1,33 @@
+"""搜索源规划 —— 根据 SearchIntent 生成多源召回策略与优先级."""
+
+from __future__ import annotations
+
+
+def openalex_publication_year_filter(year_from: int | None, year_to: int | None) -> str | None:
+    yf = year_from
+    yt = year_to
+    if yf is not None:
+        try:
+            yf_i = int(yf)
+        except (TypeError, ValueError):
+            yf_i = None
+    else:
+        yf_i = None
+    if yt is not None:
+        try:
+            yt_i = int(yt)
+        except (TypeError, ValueError):
+            yt_i = None
+    else:
+        yt_i = None
+    if yf_i is not None and yt_i is not None:
+        if yf_i > yt_i:
+            yf_i, yt_i = yt_i, yf_i
+        if yf_i == yt_i:
+            return f"publication_year:{yf_i}"
+        return f"publication_year:{yf_i}-{yt_i}"
+    if yf_i is not None:
+        return f"publication_year:>={yf_i}"
+    if yt_i is not None:
+        return f"publication_year:<={yt_i}"
+    return None

+ 180 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/tavily_venue_config.py

@@ -0,0 +1,180 @@
+"""Tavily ``include_domains`` 与会场锚点主机列表:数据驱动(JSON),避免在业务代码里写死映射。
+
+编辑 ``tavily_venue_domains.json`` 即可增删会场;或通过环境变量 / 配置指向自定义 JSON。
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from functools import lru_cache
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from app.core.search.normalize import _venue_canonical_key
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_JSON = Path(__file__).resolve().with_name("tavily_venue_domains.json")
+
+_RE_SAFE_DOMAIN = re.compile(
+    r"^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$",
+    re.I,
+)
+
+
+def _sanitize_domains(raw: Any, *, limit: int = 3) -> List[str]:
+    out: List[str] = []
+    if not isinstance(raw, list):
+        return out
+    for x in raw:
+        s = str(x).strip().lower().rstrip(".")
+        if not s or "." not in s:
+            continue
+        if not _RE_SAFE_DOMAIN.match(s):
+            logger.warning("tavily venue config: skip invalid domain %r", s)
+            continue
+        if s not in out:
+            out.append(s)
+        if len(out) >= limit:
+            break
+    return out
+
+
+def _resolve_config_path() -> Path:
+    env_p = (os.environ.get("PAPERGRAPH_TAVILY_VENUE_DOMAINS_JSON") or "").strip()
+    if env_p:
+        ep = Path(env_p).expanduser()
+        if ep.is_file():
+            return ep
+        logger.warning("tavily venue config: env path not a file: %s", ep)
+    try:
+        from ...settings import get_settings
+
+        cfg = (getattr(get_settings(), "tavily_venue_domains_config_path", None) or "").strip()
+        if cfg:
+            cp = Path(cfg).expanduser()
+            if cp.is_file():
+                return cp
+            logger.warning("tavily venue config: settings path not a file: %s", cp)
+    except Exception:
+        pass
+    return _DEFAULT_JSON
+
+
+@lru_cache(maxsize=4)
+def _load_config_for_path(resolved_path: str) -> Dict[str, Any]:
+    try:
+        p = Path(resolved_path)
+        data = json.loads(p.read_text(encoding="utf-8"))
+        return data if isinstance(data, dict) else {}
+    except FileNotFoundError:
+        logger.error("tavily venue config missing: %s", resolved_path)
+    except json.JSONDecodeError as e:
+        logger.error("tavily venue config JSON invalid (%s): %s", resolved_path, e)
+    except OSError as e:
+        logger.error("tavily venue config read failed (%s): %s", resolved_path, e)
+    return {}
+
+
+def _get_config_data() -> Dict[str, Any]:
+    return _load_config_for_path(str(_resolve_config_path().resolve()))
+
+
+def clear_tavily_venue_config_cache() -> None:
+    """测试或替换 JSON 后调用以失效缓存。"""
+    _load_config_for_path.cache_clear()
+
+
+def get_official_proceedings_hosts() -> tuple[str, ...]:
+    """用于锚点标题 / 关键词排序加权的官方 proceedings 主机列表。"""
+    raw = _get_config_data().get("official_proceedings_hosts") or []
+    hosts = _sanitize_domains(raw, limit=32)
+    if hosts:
+        return tuple(hosts)
+    return tuple(_DEFAULT_BUILTIN_HOSTS)
+
+
+_DEFAULT_BUILTIN_HOSTS = (
+    "proceedings.neurips.cc",
+    "proceedings.mlr.press",
+    "openaccess.thecvf.com",
+    "aclanthology.org",
+    "aaai.org",
+    "ijcai.org",
+)
+
+
+def _canonical_include_map() -> Dict[str, List[str]]:
+    data = _get_config_data().get("include_domains_by_canonical") or {}
+    out: Dict[str, List[str]] = {}
+    if not isinstance(data, dict):
+        return out
+    for k, v in data.items():
+        key = str(k).strip().lower()
+        if not key:
+            continue
+        doms = _sanitize_domains(v)
+        if doms:
+            out[key] = doms
+    return out
+
+
+def _condition_matches(vl: str, cond: Any) -> bool:
+    if not isinstance(cond, dict):
+        return False
+    if "substring" in cond:
+        sub = str(cond.get("substring") or "").lower()
+        return bool(sub) and sub in vl
+    if "regex" in cond:
+        pat = str(cond.get("regex") or "")
+        if not pat:
+            return False
+        try:
+            return bool(re.search(pat, vl))
+        except re.error as e:
+            logger.warning("tavily venue config: bad regex %r: %s", pat, e)
+            return False
+    return False
+
+
+def _first_domains_from_substring_rules(vl: str) -> Optional[List[str]]:
+    rules = _get_config_data().get("substring_rules") or []
+    if not isinstance(rules, list):
+        return None
+    for rule in rules:
+        if not isinstance(rule, dict):
+            continue
+        doms = _sanitize_domains(rule.get("domains"))
+        if not doms:
+            continue
+        any_conds = rule.get("any")
+        if not isinstance(any_conds, list):
+            continue
+        ok = False
+        for c in any_conds:
+            if _condition_matches(vl, c):
+                ok = True
+                break
+        if ok:
+            return doms
+    return None
+
+
+def tavily_include_domains_for_venue(venue: Optional[str]) -> Optional[List[str]]:
+    """根据会场字符串返回 Tavily ``include_domains``(数据来自 JSON)。
+
+    返回 ``None`` 表示不限制域名。ICLR / 泛 ACM DL 等仍建议仅在 JSON 中不配规则。
+    """
+    raw = (venue or "").strip()
+    if not raw:
+        return None
+    key = _venue_canonical_key(raw)
+    if key:
+        m = _canonical_include_map().get(key)
+        if m:
+            return list(m)
+    vl = raw.lower()
+    return _first_domains_from_substring_rules(vl)

+ 81 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/tavily_venue_domains.json

@@ -0,0 +1,81 @@
+{
+  "official_proceedings_hosts": [
+    "papers.nips.cc",
+    "proceedings.neurips.cc",
+    "proceedings.iclr.cc",
+    "proceedings.mlr.press",
+    "openaccess.thecvf.com",
+    "aclanthology.org",
+    "aaai.org",
+    "ijcai.org"
+  ],
+  "include_domains_by_canonical": {
+    "neurips": ["papers.nips.cc", "proceedings.neurips.cc"],
+    "iclr": ["proceedings.iclr.cc"],
+    "icml": ["proceedings.mlr.press"],
+    "cvpr": ["openaccess.thecvf.com"],
+    "iccv": ["openaccess.thecvf.com"],
+    "eccv": ["openaccess.thecvf.com"],
+    "aaai": ["aaai.org"],
+    "acl": ["aclanthology.org"],
+    "emnlp": ["aclanthology.org"],
+    "coling": ["aclanthology.org"],
+    "ijcai": ["ijcai.org"],
+    "sigir": ["aclanthology.org"]
+  },
+  "substring_rules": [
+    {
+      "domains": ["papers.nips.cc", "proceedings.neurips.cc"],
+      "any": [
+        {"regex": "(?<!\\w)nips(?!\\w)"},
+        {"substring": "neurips"},
+        {"substring": "neural information processing"}
+      ]
+    },
+    {
+      "domains": ["proceedings.iclr.cc"],
+      "any": [
+        {"substring": "iclr"},
+        {"substring": "international conference on learning representations"}
+      ]
+    },
+    {
+      "domains": ["proceedings.mlr.press"],
+      "any": [
+        {"substring": "icml"},
+        {"substring": "international conference on machine learning"},
+        {"regex": "\\bpmlr\\b"}
+      ]
+    },
+    {
+      "domains": ["openaccess.thecvf.com"],
+      "any": [
+        {"substring": "cvpr"},
+        {"substring": "iccv"},
+        {"substring": "eccv"},
+        {"substring": "wacv"},
+        {"substring": "ieee/cvf"},
+        {"substring": "computer vision and pattern recognition"},
+        {"substring": "european conference on computer vision"},
+        {"substring": "international conference on computer vision"}
+      ]
+    },
+    {"domains": ["aaai.org"], "any": [{"substring": "aaai"}]},
+    {"domains": ["ijcai.org"], "any": [{"substring": "ijcai"}]},
+    {
+      "domains": ["aclanthology.org"],
+      "any": [
+        {"substring": "emnlp"},
+        {"substring": "naacl"},
+        {"substring": "eacl"},
+        {"substring": "coling"},
+        {"substring": "aclanthology"},
+        {"substring": "findings of acl"},
+        {"substring": "findings of emnlp"},
+        {"substring": "findings of naacl"},
+        {"regex": "\\bacl\\b"}
+      ]
+    },
+    {"domains": ["aclanthology.org"], "any": [{"substring": "sigir"}]}
+  ]
+}

+ 282 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/retrieval/web_presearch.py

@@ -0,0 +1,282 @@
+"""Web 预搜索:在多源学术检索之前先做"锚点"召回。
+
+目标:
+- 解决短词/术语(如 patchcore)导致的多源召回噪声与歧义
+- 先从 Web 搜索拿到最可信的论文标题/DOI/arXiv,再由 Agent 生成更精确的检索单元
+
+说明:
+- Settings 默认 ``tavily_presearch_enabled=true``;未配置 ``TAVILY_API_KEY`` 时不会发外呼。
+- Tavily 会场→域名映射见 ``tavily_venue_domains.json``(``tavily_venue_config``),勿在此文件堆业务映射。
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+from .tavily_venue_config import (  # noqa: E402
+    get_official_proceedings_hosts,
+)
+
+# Tavily:query 超过 400 字符会返回 400(见官方文档与常见报错)
+TAVILY_MAX_QUERY_CHARS = 400
+
+
+def _normalize_tavily_query(query: str, *, max_chars: int = TAVILY_MAX_QUERY_CHARS) -> str:
+    q = (query or "").strip()
+    if not q:
+        return ""
+    if len(q) <= max_chars:
+        return q
+    clipped = q[:max_chars].rstrip()
+    logger.warning(
+        "tavily: query 过长已截断 (%d -> %d 字符),避免 Tavily 400",
+        len(q),
+        len(clipped),
+    )
+    return clipped
+
+
+async def tavily_search_async(
+    *,
+    api_key: str,
+    query: str,
+    max_results: int = 5,
+    timeout_sec: int = 20,
+    include_domains: Optional[List[str]] = None,
+    httpx_client: Optional[httpx.AsyncClient] = None,
+) -> List[Dict[str, Any]]:
+    """Async Tavily Search API call. Reuses shared httpx client when available."""
+    q = _normalize_tavily_query(query)
+    if not q:
+        return []
+    if not (api_key or "").strip():
+        return []
+
+    n = max(1, min(10, int(max_results or 5)))
+    url = "https://api.tavily.com/search"
+    payload = {
+        "api_key": api_key,
+        "query": q,
+        "max_results": n,
+        "include_answer": True,
+        "include_raw_content": True,
+    }
+    dom = [str(x).strip() for x in (include_domains or []) if str(x).strip()][:3]
+    if dom:
+        payload["include_domains"] = dom
+
+    timeout = httpx.Timeout(timeout_sec)
+    async def _do_post(client):
+        resp = await client.post(url, json=payload)
+        if resp.status_code >= 400:
+            payload2 = dict(payload)
+            payload2["include_raw_content"] = False
+            resp = await client.post(url, json=payload2)
+        resp.raise_for_status()
+        return resp.json() or {}
+
+    if httpx_client is not None:
+        data = await _do_post(httpx_client)
+    else:
+        async with httpx.AsyncClient(timeout=timeout) as client:
+            data = await _do_post(client)
+
+    out: List[Dict[str, Any]] = []
+    ans = str(data.get("answer") or "").strip()
+    if ans:
+        out.append({"title": ans[:180], "link": "", "snippet": ans})
+    for it in (data.get("results") or [])[:n]:
+        if not isinstance(it, dict):
+            continue
+        title = str(it.get("title") or "").strip()
+        link = str(it.get("url") or "").strip()
+        snippet = str(it.get("content") or "").strip()
+        if not title and not link and not snippet:
+            continue
+        out.append({
+            "title": title[:200] if title else "",
+            "link": link,
+            "snippet": snippet[:300] if snippet else "",
+            "raw_content": str(it.get("raw_content") or "")[:20000],
+        })
+    return out
+
+
+def pick_anchor_title(items: List[Dict[str, Any]]) -> Optional[str]:
+    """Pick the best paper title from Tavily results. Prefer trusted academic sources."""
+    if not items:
+        return None
+
+    trusted = ("arxiv.org", "doi.org", "neurips.cc", "openreview.net", "proceedings.")
+    def _score(it: Dict[str, Any]) -> float:
+        title = str(it.get("title") or "").strip()
+        if not title or len(title) < 8:
+            return -1e9
+        link = str(it.get("link") or it.get("url") or "").lower()
+        score = float(len(title))
+        if any(h in link for h in trusted):
+            score += 200.0
+        if any(h in title.lower() for h in ("github", "repo", "awesome-")):
+            score -= 500.0
+        return score
+
+    best = max(items, key=_score)
+    title = str(best.get("title") or "").strip()
+    title = re.sub(r"^\s*(\[PDF\]|\(PDF\))\s*", "", title, flags=re.I)
+    return title or None
+
+_ARXIV_ID_RE = re.compile(r"(?:arxiv\.org/(?:abs|pdf)/|arxiv:)\s*([0-9]{4}\.[0-9]{4,5})(?:v\d+)?", re.I)
+_DOI_RE = re.compile(r"\b10\.\d{4,9}/[^\s\"'<>]+", re.I)
+
+
+def extract_anchor_ids(items: List[Dict[str, Any]]) -> Dict[str, List[str]]:
+    """从 Tavily 返回里提取高置信 ID(arXiv / DOI)。
+
+    用途:当 query 是短词/术语时,用这些 ID 作为"最匹配"的强证据加入候选集,
+    但不绑定到某个具体 query(避免硬编码)。
+    """
+    arxiv_ids: List[str] = []
+    dois: List[str] = []
+
+    def _push_unique(buf: List[str], x: str, limit: int):
+        t = (x or "").strip()
+        if not t:
+            return
+        tl = t.lower()
+        if any(y.lower() == tl for y in buf):
+            return
+        buf.append(t)
+        if len(buf) > limit:
+            del buf[limit:]
+
+    for it in (items or [])[:10]:
+        if not isinstance(it, dict):
+            continue
+        hay = " ".join(
+            [
+                str(it.get("title") or ""),
+                str(it.get("link") or it.get("url") or ""),
+                str(it.get("snippet") or it.get("content") or ""),
+                str(it.get("raw_content") or ""),
+            ]
+        )
+        for m in _ARXIV_ID_RE.finditer(hay):
+            _push_unique(arxiv_ids, m.group(1), 5)
+        for m in _DOI_RE.finditer(hay):
+            doi = m.group(0).rstrip(").,;]")
+            _push_unique(dois, doi, 5)
+
+    return {"arxiv_ids": arxiv_ids, "dois": dois}
+
+
+_NON_PAPER_HOSTS = ("youtube.com", "youtu.be", "reddit.com", "twitter.com", "x.com", "facebook.com", "instagram.com")
+
+
+def _clean_keyword_phrase(s: str, max_len: int = 100) -> str:
+    t = (s or "").strip()
+    if not t:
+        return ""
+    t = re.sub(r"^\s*(\[\s*pdf\s*\]|\(\s*pdf\s*\)|【\s*pdf\s*】)\s*", "", t, flags=re.I)
+    t = re.sub(r"^\s*pdf\s*[::]\s*", "", t, flags=re.I)
+    t = re.sub(r"\s*[·|\-]\s*GitHub\s*$", "", t, flags=re.I)
+    t = re.sub(r"\.pdf\s+at\s+main.*$", "", t, flags=re.I)
+    t = re.sub(r"\s*\.\.\.$", "", t).strip()
+    t = re.sub(r"^(?:[A-Z]{2,10})\s*[::]\s+", "", t).strip()
+    # 去掉多余空白与换行
+    t = re.sub(r"\s+", " ", t).strip()
+    # 限制长度
+    if len(t) > max_len:
+        t = t[:max_len-1].rstrip() + "…"
+    return t
+
+
+def _snippet_as_keyword(snippet: str, max_len: int = 140) -> str:
+    s = (snippet or "").strip().replace("\n", " ")
+    if not s:
+        return ""
+    s = re.sub(r"\s+", " ", s).strip()
+    if len(s) > max_len:
+        s = s[: max_len - 1].rstrip() + "…"
+    return s
+
+
+def _tavily_item_keyword_priority(it: Dict[str, Any]) -> int:
+    """排序:优先无 URL 的 answer 摘要,其次 arXiv/DOI 等学术落地页,降低论坛/博客噪声顺序。"""
+    if not isinstance(it, dict):
+        return 0
+    link = str(it.get("link") or it.get("url") or "").strip().lower()
+    if not link:
+        return 110
+    if "arxiv.org" in link:
+        return 100
+    if "doi.org" in link or "openreview.net" in link:
+        return 95
+    if any(h in link for h in get_official_proceedings_hosts()):
+        return 88
+    if any(h in link for h in ("cv-foundation.org", "aclweb.org")):
+        return 88
+    if any(h in link for h in ("ieee.org", "acm.org", "springer", "nature.com", "science.org")):
+        return 82
+    if any(h in link for h in _NON_PAPER_HOSTS):
+        return 0
+    return 40
+
+
+def tavily_items_to_llm_keywords(
+    items: List[Dict[str, Any]],
+    user_query: str,
+    *,
+    max_phrases: int = 16,
+) -> List[str]:
+    """把 Tavily 返回的论文标题/摘要片段转成后续学术检索用的 llm_keywords(去重、限长)。
+
+    设计目标:用户希望「Tavily 搜到的论文名/内容」**直接**参与 arXiv/OpenAlex 等 OR 检索,
+    而不是只选一个启发式锚点标题。
+    """
+    uq = (user_query or "").strip()
+    out: List[str] = []
+    seen: set[str] = set()
+
+    def push(x: str) -> None:
+        t = _clean_keyword_phrase(x)
+        if not t or len(t) < 8:
+            return
+        low = t.lower()
+        if low in seen:
+            return
+        # 过滤明显非论文页标题
+        if any(h in low for h in ("github", "repo", "awesome-", "arxiv-sanity", "paperswithcode")):
+            return
+        out.append(t)
+        seen.add(low)
+        if len(out) >= max_phrases:
+            return
+
+    if uq:
+        push(uq)
+
+    pool = [x for x in (items or [])[:16] if isinstance(x, dict)]
+    pool.sort(key=_tavily_item_keyword_priority, reverse=True)
+    for it in pool[:12]:
+        link = str(it.get("link") or it.get("url") or "").lower()
+        if any(h in link for h in _NON_PAPER_HOSTS):
+            continue
+        title = str(it.get("title") or "").strip()
+        if title:
+            push(title)
+        if len(out) >= max_phrases:
+            break
+        sn = str(it.get("snippet") or it.get("content") or "").strip()
+        sk = _snippet_as_keyword(sn)
+        if sk and sk.lower() not in seen and sk.lower() != (title or "").lower():
+            push(sk)
+        if len(out) >= max_phrases:
+            break
+
+    return out[:max_phrases]

+ 15 - 0
Co-creation-projects/DeLunnLi-PaperGraph/backend/app/services/search_intent/__init__.py

@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from .parsing import (
+    apply_llm_intent_hygiene,
+    extract_json_object,
+    finalize_llm_intent,
+    search_intent_from_dict,
+)
+
+__all__ = [
+    "apply_llm_intent_hygiene",
+    "extract_json_object",
+    "finalize_llm_intent",
+    "search_intent_from_dict",
+]

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است