소스 검색

Merge pull request #733 from chengH425/feature/paper-assistant

[毕业设计] PaperAssistant - 智能论文助手
Sizhou Chen 1 개월 전
부모
커밋
63d593f1b4

+ 13 - 0
Co-creation-projects/chengH425-PaperAssistant/.env.example

@@ -0,0 +1,13 @@
+# LLM Configuration
+LLM_MODEL_ID=deepseek-chat
+LLM_API_KEY=your_api_key_here
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_TIMEOUT=60
+
+# Semantic Scholar API Key(可选,免费申请: https://www.semanticscholar.org/product/api)
+# 不设置则 100次/5分钟,设置后 1000次/5分钟
+# SEMANTIC_SCHOLAR_API_KEY=your_key_here
+
+# AMiner API Key(免费注册: https://open.aminer.cn/)
+# 用于检索中文学术论文
+# AMINER_API_KEY=your_key_here

+ 18 - 0
Co-creation-projects/chengH425-PaperAssistant/.gitignore

@@ -0,0 +1,18 @@
+# Environment
+.env
+
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+
+# Jupyter
+.ipynb_checkpoints/
+
+# Outputs
+outputs/*.md
+data/*.pdf
+
+# IDE
+.vscode/
+.idea/

+ 21 - 0
Co-creation-projects/chengH425-PaperAssistant/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 chengH425
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 172 - 0
Co-creation-projects/chengH425-PaperAssistant/README.md

@@ -0,0 +1,172 @@
+# PaperAssistant - 智能论文助手
+
+> 基于 HelloAgents 框架 + DeepSeek 的多智能体学术辅助工具,集成 6 大学术数据源,覆盖文献检索、论文总结、引用生成、论文润色、大纲生成、论文写作和 PDF 处理。
+
+## 📝 项目简介
+
+PaperAssistant 是一个面向研究生和科研人员的智能论文助手,通过多智能体协作帮助用户高效完成从文献调研到论文撰写的全流程学术任务。
+
+### 解决什么问题?
+
+- 文献检索耗时,单一数据库覆盖不全 → **6 大数据源自由切换**
+- 论文阅读量大,难以快速提取关键信息 → **LLM 结构化总结**
+- 引用格式繁琐,容易出错 → **GB/T 7714 / APA / MLA 一键生成**
+- 论文润色需要反复修改 → **多轮对话式润色,记住上下文持续优化**
+- 论文大纲构思困难 → **多轮对话式大纲构建,持续调整细化**
+- 论文写作难度大 → **根据大纲逐章撰写,引用真实文献**
+
+## ✨ 核心功能
+
+- [x] **文献检索**:集成 6 大学术数据源(Semantic Scholar / AMiner / OpenAlex / PubMed / CrossRef / arXiv),自由切换,支持学科和年份高级筛选
+- [x] **论文总结**:结构化提取论文核心观点、方法和结论
+- [x] **引用生成**:自动生成 GB/T 7714、APA 7th、MLA 9th 三种格式的参考文献
+- [x] **论文润色**:多轮对话式润色,持续优化表达,记住上下文
+- [x] **大纲生成**:多轮对话式大纲构建,随时细化调整
+- [x] **论文写作**:根据大纲逐章撰写,引用真实文献(拒绝 AI 编造)
+- [x] **PDF → Markdown**:上传 PDF 论文,自动识别章节结构,转换为 Markdown
+- [x] **Gradio Web UI**:8 标签页图形化界面 + 对话记录自动保存 + 会话历史管理
+
+## 🛠️ 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| 智能体框架 | HelloAgents v1.0 |
+| 智能体范式 | SimpleAgent(多轮对话记忆) |
+| LLM 后端 | DeepSeek-Chat |
+| Web 界面 | Gradio |
+| 学术 API | Semantic Scholar / AMiner / OpenAlex / PubMed / CrossRef / arXiv |
+| PDF 处理 | PyPDF2(文本提取 + Markdown 转换) |
+| 对话管理 | JSON 持久化 + 多轮上下文记忆 + 会话历史 |
+
+## 📁 项目结构
+
+```
+chengH425-PaperAssistant/
+├── README.md              # 项目文档
+├── requirements.txt        # 依赖列表
+├── main.ipynb              # Jupyter Notebook(完整演示)
+├── app.py                  # Gradio Web 界面(8 标签页)
+├── .env / .env.example     # 环境变量
+├── src/                    # 源代码模块(9 个工具)
+│   ├── __init__.py
+│   ├── literature_tool.py  # Semantic Scholar 全学科检索(2亿+)
+│   ├── aminer_tool.py      # AMiner 中文学术检索(3.2亿+)
+│   ├── openalex_tool.py    # OpenAlex 开放获取检索(2.5亿+)
+│   ├── pubmed_tool.py      # PubMed 生物医学检索(3600万+)
+│   ├── crossref_tool.py    # CrossRef 期刊论文检索(1.5亿+)
+│   ├── arxiv_tool.py       # arXiv 预印本检索
+│   ├── pdf_tool.py         # PDF 转 Markdown 工具
+│   └── citation_tool.py    # 学术引用生成
+├── data/                   # 示例数据
+└── outputs/                # 输出结果、对话记录、会话存档
+```
+
+## 🚀 快速开始
+
+### 环境要求
+
+- Python 3.10+
+- HelloAgents >= 1.0.0
+
+### 安装依赖
+
+```bash
+pip install -r requirements.txt
+```
+
+### 配置 API 密钥
+
+```bash
+cp .env.example .env
+# 编辑 .env 文件,填入 DeepSeek API Key
+```
+
+### 方式一:启动 Web 界面(推荐)
+
+```bash
+python app.py
+# 浏览器打开 http://127.0.0.1:7860
+```
+
+### 方式二:Jupyter Notebook
+
+```bash
+jupyter lab
+# 打开 main.ipynb 并运行所有单元格
+```
+
+## 📖 功能说明
+
+### 界面标签页(8 个)
+
+| 标签页 | 功能 | 技术实现 |
+|--------|------|---------|
+| 📚 文献检索 | 6 数据源自由切换 + 高级筛选(学科/年份) + API 重试 | SimpleAgent + 多工具路由 |
+| 📝 论文总结 | 粘贴论文内容,生成结构化总结 | SimpleAgent |
+| 📎 引用生成 | 填写表单,一键生成 3 种格式引用 | CitationTool(确定性计算) |
+| ✍️ 论文润色 | 多轮对话式润色 + 会话历史加载 | SimpleAgent(对话记忆 + 会话持久化) |
+| 📊 大纲生成 | 多轮对话式大纲构建 + 会话历史加载 | SimpleAgent(对话记忆 + 会话持久化) |
+| 📝 论文写作 | 根据大纲逐章撰写,引用真实文献 + 会话历史加载 | SimpleAgent + 5 检索工具 |
+| 📄 PDF → Markdown | 上传 PDF,自动识别标题/章节/段落,输出 Markdown | PDFExtractTool |
+| 💬 对话记录 | 自动保存所有操作,每条记录独立删除,可回溯查看 | JSON 持久化 + HTML 卡片展示 |
+
+### 6 大数据源对比
+
+| 数据源 | 覆盖量 | 学科范围 | 特色 |
+|--------|:-----:|---------|------|
+| Semantic Scholar | 2亿+ | 全学科 | 推荐使用,覆盖广 |
+| AMiner | 3.2亿+ | 全学科 | 中文论文最强,清华出品 |
+| OpenAlex | 2.5亿+ | 全学科 | 开放获取、跨库聚合 |
+| PubMed | 3600万+ | 生物医学 | 医学领域最权威 |
+| CrossRef | 1.5亿+ | 全学科 | 期刊元数据最完整 |
+| arXiv | 240万+ | CS/数学/物理 | 预印本最快 |
+
+### 代码调用示例
+
+```python
+from hello_agents import HelloAgentsLLM, ToolRegistry
+from src.literature_tool import LiteratureSearchTool
+
+# 全学科检索
+tool = LiteratureSearchTool()
+result = tool.run({
+    "keyword": "large language model agent",
+    "field": "计算机科学",
+    "year_from": "2023",
+    "max_results": 5
+})
+print(result.text)
+```
+
+## 🎯 项目亮点
+
+- **6 数据源集成**:自由切换,告别单一数据库,含中文文献支持
+- **真实数据驱动**:文献检索和论文写作均基于学术 API,杜绝 LLM 幻觉
+- **多轮对话记忆**:润色、大纲、写作支持上下文连续的对话式交互
+- **会话历史管理**:自动保存对话,支持加载继续、删除历史
+- **模块化工具系统**:9 个自定义 Tool,独立可测试
+- **双入口设计**:Gradio Web UI + Jupyter Notebook
+- **多格式引用**:GB/T 7714 / APA 7th / MLA 9th,规则引擎确定性生成
+
+## 🔮 未来计划
+
+- [ ] 接入 CNKI / 万方等中文数据库
+- [ ] 支持 PDF 论文的图表识别与解析
+- [ ] 增加论文查重分析功能
+- [ ] 支持更多 LLM 后端(OpenAI / Claude / GLM)
+- [ ] 支持 DOCX/LaTeX 格式导出
+
+## 👤 作者
+
+- GitHub: [@chengH425](https://github.com/chengH425)
+- Email: 1793636425@qq.com
+
+## 📄 许可证
+
+MIT License
+
+所使用的各学术 API(Semantic Scholar、OpenAlex、PubMed、CrossRef、arXiv、AMiner)均属于其各自所有者的财产,本项目仅通过其公开 API 进行非商业用途的学术检索。
+
+## 🙏 致谢
+
+感谢 Datawhale 社区和 Hello-Agents 项目!

+ 933 - 0
Co-creation-projects/chengH425-PaperAssistant/app.py

@@ -0,0 +1,933 @@
+"""
+PaperAssistant - 智能论文助手 Gradio Web 界面
+
+提供文献检索、论文总结、引用生成、论文润色、大纲生成、PDF 提取等功能。
+所有操作自动记录到对话历史,可回溯查看。
+"""
+import os
+import sys
+import json
+from datetime import datetime
+import gradio as gr
+
+from dotenv import load_dotenv
+load_dotenv()
+
+# Windows UTF-8 兼容
+sys.stdout.reconfigure(encoding='utf-8')
+
+from hello_agents import (
+    HelloAgentsLLM, SimpleAgent, ToolRegistry, Config
+)
+from src.arxiv_tool import ArxivSearchTool
+from src.pdf_tool import PDFExtractTool
+from src.citation_tool import CitationTool
+from src.literature_tool import LiteratureSearchTool
+from src.pubmed_tool import PubMedSearchTool
+from src.crossref_tool import CrossRefSearchTool
+from src.openalex_tool import OpenAlexSearchTool
+from src.aminer_tool import AminerSearchTool
+
+
+# ========================================
+# 对话日志系统
+# ========================================
+HISTORY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "outputs", "conversations")
+
+class ConversationLogger:
+    """对话日志管理器:记录、持久化、检索所有交互"""
+
+    def __init__(self, save_dir=HISTORY_DIR):
+        self.save_dir = save_dir
+        os.makedirs(save_dir, exist_ok=True)
+        self.records = self._load_all()
+
+    def _filepath(self):
+        """当前会话的日志文件"""
+        today = datetime.now().strftime("%Y-%m-%d")
+        return os.path.join(self.save_dir, f"session_{today}.json")
+
+    def _load_all(self):
+        """加载所有历史记录"""
+        records = []
+        if os.path.exists(self.save_dir):
+            for fname in sorted(os.listdir(self.save_dir), reverse=True):
+                if fname.endswith(".json"):
+                    fpath = os.path.join(self.save_dir, fname)
+                    try:
+                        with open(fpath, "r", encoding="utf-8") as f:
+                            records.extend(json.load(f))
+                    except Exception:
+                        pass
+        return records
+
+    def add(self, tab, action, user_input, output):
+        """添加一条对话记录并持久化"""
+        record = {
+            "id": len(self.records) + 1,
+            "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+            "tab": tab,
+            "action": action,
+            "user_input": user_input[:200] + ("..." if len(user_input) > 200 else ""),
+            "output_preview": output[:200] + ("..." if len(output) > 200 else ""),
+            "output_full": output
+        }
+        self.records.insert(0, record)  # 最新的在前
+
+        # 追加到今日文件
+        today_file = self._filepath()
+        try:
+            existing = []
+            if os.path.exists(today_file):
+                with open(today_file, "r", encoding="utf-8") as f:
+                    existing = json.load(f)
+            existing.insert(0, record)
+            with open(today_file, "w", encoding="utf-8") as f:
+                json.dump(existing, f, ensure_ascii=False, indent=2)
+        except Exception:
+            pass
+
+        return record
+
+    def format_history_html(self):
+        """格式化为 HTML 展示,每条记录带独立删除按钮"""
+        if not self.records:
+            return "<p><i>暂无对话记录,开始使用后会自动保存。</i></p>"
+
+        lines = [f'<p style="color:#888;">共 {len(self.records)} 条记录</p>']
+        for r in self.records[:50]:
+            escaped_output = (r['output_full']
+                             .replace("&", "&amp;")
+                             .replace("<", "&lt;")
+                             .replace(">", "&gt;")
+                             .replace("\n", "<br>")
+                             .replace("`", "&#96;"))
+            rid = r["id"]
+            lines.append(f'''
+<div style="border:1px solid #e0e0e0; border-radius:8px; padding:12px; margin:10px 0; position:relative;">
+  <div style="position:absolute; top:8px; right:8px;">
+    <button onclick="document.getElementById('del_trigger').querySelector('textarea,input').value='{rid}';
+                     document.getElementById('del_trigger').querySelector('textarea,input').dispatchEvent(new Event('input',{{bubbles:true}}));
+                     document.getElementById('del_trigger').querySelector('textarea,input').dispatchEvent(new Event('change',{{bubbles:true}}));"
+            style="background:#e74c3c; color:#fff; border:none; border-radius:4px; cursor:pointer; padding:4px 12px; font-size:12px;">
+      ✕ 删除
+    </button>
+  </div>
+  <div style="margin-right:70px;">
+    <strong>[#{r['id']}] {r['timestamp']}</strong>
+    <span style="color:#666;"> | {r['tab']} | {r['action']}</span>
+    <p style="margin:6px 0 2px 0; color:#555; font-size:13px;"><b>输入:</b> {r['user_input']}</p>
+    <details style="margin-top:6px;">
+      <summary style="cursor:pointer; color:#2980b9;">查看完整输出</summary>
+      <div style="background:#f8f9fa; padding:10px; border-radius:4px; margin-top:4px; max-height:300px; overflow-y:auto; font-size:13px; white-space:pre-wrap;">{escaped_output}</div>
+    </details>
+  </div>
+</div>''')
+        return "\n".join(lines)
+
+    def delete_record(self, record_id: int) -> str:
+        """删除单条记录"""
+        for i, r in enumerate(self.records):
+            if r.get("id") == record_id:
+                del self.records[i]
+                # 重新持久化当天文件
+                today_file = self._filepath()
+                try:
+                    with open(today_file, "w", encoding="utf-8") as f:
+                        json.dump(self.records, f, ensure_ascii=False, indent=2)
+                except Exception:
+                    pass
+                return f"已删除记录 #{record_id}"
+        return f"未找到记录 #{record_id}"
+
+    def clear(self):
+        """清空记录"""
+        self.records = []
+        for fname in os.listdir(self.save_dir):
+            if fname.endswith(".json"):
+                os.remove(os.path.join(self.save_dir, fname))
+        return "对话记录已清空。"
+
+
+# 全局日志实例
+logger = ConversationLogger()
+
+
+# ========================================
+# 会话管理器(润色 & 大纲的对话历史)
+# ========================================
+class ChatSessionManager:
+    """管理润色和大纲的多轮对话会话"""
+
+    def __init__(self, save_dir: str):
+        self.save_dir = save_dir
+        os.makedirs(save_dir, exist_ok=True)
+
+    def _filepath(self, session_id: str) -> str:
+        return os.path.join(self.save_dir, f"{session_id}.json")
+
+    def save(self, session_id: str, messages: list, title: str = ""):
+        """保存会话"""
+        data = {
+            "id": session_id,
+            "title": title or f"会话 {session_id[:8]}",
+            "updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+            "messages": messages
+        }
+        with open(self._filepath(session_id), "w", encoding="utf-8") as f:
+            json.dump(data, f, ensure_ascii=False, indent=2)
+
+    def load(self, session_id: str) -> list:
+        """加载会话,返回 messages 列表"""
+        with open(self._filepath(session_id), "r", encoding="utf-8") as f:
+            data = json.load(f)
+        return data.get("messages", [])
+
+    def list_sessions(self):
+        """列出所有会话 [(id, title, updated), ...]"""
+        sessions = []
+        if os.path.exists(self.save_dir):
+            for fname in sorted(os.listdir(self.save_dir), reverse=True):
+                if fname.endswith(".json"):
+                    try:
+                        with open(os.path.join(self.save_dir, fname), "r", encoding="utf-8") as f:
+                            data = json.load(f)
+                        sessions.append((
+                            data.get("id", fname[:-5]),
+                            data.get("title", fname[:-5]),
+                            data.get("updated", "")
+                        ))
+                    except Exception:
+                        pass
+        return sessions
+
+    def delete(self, session_id: str):
+        """删除会话"""
+        path = self._filepath(session_id)
+        if os.path.exists(path):
+            os.remove(path)
+
+
+# 为润色和大纲各创建一个会话管理器
+polish_sessions = ChatSessionManager(os.path.join(os.path.dirname(os.path.abspath(__file__)), "outputs", "polish_sessions"))
+outline_sessions = ChatSessionManager(os.path.join(os.path.dirname(os.path.abspath(__file__)), "outputs", "outline_sessions"))
+paper_sessions = ChatSessionManager(os.path.join(os.path.dirname(os.path.abspath(__file__)), "outputs", "paper_sessions"))
+
+
+# ========================================
+# 初始化:LLM + 工具 + 智能体
+# ========================================
+config = Config(trace_enabled=False)
+llm = HelloAgentsLLM()
+
+tool_registry = ToolRegistry()
+tool_registry.register_tool(LiteratureSearchTool())
+tool_registry.register_tool(ArxivSearchTool())
+tool_registry.register_tool(PubMedSearchTool())
+tool_registry.register_tool(CrossRefSearchTool())
+tool_registry.register_tool(OpenAlexSearchTool())
+tool_registry.register_tool(AminerSearchTool())
+tool_registry.register_tool(PDFExtractTool())
+tool_registry.register_tool(CitationTool())
+
+# ---- 文献检索智能体 ----
+search_agent = SimpleAgent(
+    name="文献检索助手", llm=llm, config=config,
+    system_prompt="""你是一位学术文献检索专家。你有 6 个检索工具可用,请严格按用户指定的工具名称调用:
+
+- literature_search: Semantic Scholar,全学科覆盖(推荐)
+- aminer_search: AMiner,中文学术论文
+- openalex_search: OpenAlex,开放获取论文
+- pubmed_search: PubMed,生物医学领域
+- crossref_search: CrossRef,期刊论文元数据
+- arxiv_search: arXiv,CS/数学/物理预印本
+
+规则:
+1. 必须使用用户指定的工具搜索论文,不要用其他工具替代
+2. 基于工具返回的真实结果进行分析和推荐
+3. 绝对禁止在工具调用失败时凭空编造论文信息
+4. 如果工具返回错误,直接向用户报告错误"""
+)
+# 注册全部 5 个检索工具
+search_agent.add_tool(tool_registry.get_tool("literature_search"))
+search_agent.add_tool(tool_registry.get_tool("openalex_search"))
+search_agent.add_tool(tool_registry.get_tool("pubmed_search"))
+search_agent.add_tool(tool_registry.get_tool("crossref_search"))
+search_agent.add_tool(tool_registry.get_tool("arxiv_search"))
+search_agent.add_tool(tool_registry.get_tool("aminer_search"))
+
+# ---- 论文总结智能体 ----
+summary_agent = SimpleAgent(
+    name="论文总结助手", llm=llm, config=config,
+    system_prompt="""你是一位学术论文审稿专家。请按以下结构生成总结报告:
+
+## 论文信息
+## 研究问题
+## 方法与创新点
+## 贡献与局限
+## 启发与延伸
+
+请使用中文输出报告,专业术语保留英文。"""
+)
+
+# ---- 对话智能体工厂(每次新对话创建独立实例,保持上下文记忆) ----
+
+def create_polish_agent():
+    """创建论文润色对话智能体"""
+    return SimpleAgent(
+        name="论文润色助手", llm=llm, config=config,
+        system_prompt="""你是资深学术论文语言编辑。以对话方式帮助用户润色论文。
+
+润色原则:
+1. 保持原意不变,仅优化表达
+2. 改善句式结构,消除冗余
+3. 确保逻辑连贯,统一术语
+4. 对修改处简要说明原因
+
+对话方式:用户可能多次提出修改要求(如"更正式一些"、"缩短第三段"),
+你需要记住之前的内容和修改历史,在此基础上继续优化。"""
+    )
+
+def create_outline_agent():
+    """创建论文大纲对话智能体"""
+    return SimpleAgent(
+        name="大纲生成助手", llm=llm, config=config,
+        system_prompt="""你是经验丰富的学术导师。以对话方式帮助用户构建论文大纲。
+
+你需要:
+1. 根据主题拆解核心章节和子主题
+2. 为每个章节规划核心内容要点
+3. 推荐研究方法和参考文献方向
+
+对话方式:用户可能多次要求调整(如"在第三章加入实验对比"、"细化文献综述部分"),
+你需要记住已生成的大纲内容,在此基础上修改,而不是每次重新开始。"""
+    )
+
+def create_paper_writer_agent():
+    """创建论文写作对话智能体(带文献检索能力)"""
+    agent = SimpleAgent(
+        name="论文写作助手", llm=llm, config=config,
+        system_prompt="""你是一位学术论文写作专家。你有 6 个文献检索工具可用:
+
+- literature_search: Semantic Scholar,全学科文献检索(推荐优先使用)
+- aminer_search: AMiner,中文学术论文
+- openalex_search: OpenAlex,开放获取论文
+- pubmed_search: PubMed,生物医学文献
+- crossref_search: CrossRef,期刊论文
+- arxiv_search: arXiv,预印本
+
+写作规则(必须严格遵守):
+1. 根据用户提供的大纲,逐章节撰写论文
+2. 学术化语言风格,逻辑严谨,段落清晰
+3. **引用文献时,必须先使用检索工具搜索真实论文,只引用工具返回的真实文献**
+4. 每引用一篇论文,必须在参考文献处标注真实信息(作者、标题、年份、期刊)
+5. **绝对禁止**编造不存在的论文标题、作者或期刊名
+6. 如果工具检索失败,明确告知用户"该领域文献检索失败,建议稍后重试",而不是编造文献"""
+    )
+    # 注册全部检索工具,确保文献来源真实
+    for name in ["literature_search", "openalex_search", "pubmed_search",
+                 "crossref_search", "arxiv_search", "aminer_search"]:
+        agent.add_tool(tool_registry.get_tool(name))
+    return agent
+
+
+# ========================================
+# Gradio 回调函数(所有操作自动记录日志)
+# ========================================
+
+def search_papers(query, source, max_results, field, year_from, year_to):
+    """文献检索 — 支持 5 大数据源"""
+    if not query.strip():
+        return "请输入搜索关键词。"
+
+    # 数据源 → 工具名映射
+    SOURCE_MAP = {
+        "Semantic Scholar": "literature_search",
+        "AMiner": "aminer_search",
+        "OpenAlex": "openalex_search",
+        "PubMed": "pubmed_search",
+        "CrossRef": "crossref_search",
+        "arXiv": "arxiv_search",
+    }
+    tool_name = next((v for k, v in SOURCE_MAP.items() if source.startswith(k)), "literature_search")
+    source_name = next((k for k in SOURCE_MAP if source.startswith(k)), "Semantic Scholar")
+
+    # 构建参数(高级筛选仅 Semantic Scholar 和 OpenAlex 支持)
+    params_str = f"max_results={int(max_results)}"
+    supports_advanced = source_name in ("Semantic Scholar", "OpenAlex", "PubMed", "CrossRef")
+    if supports_advanced and field and field != "全部领域":
+        params_str += f", field='{field}'"
+    if supports_advanced and year_from and year_from.strip():
+        params_str += f", year_from='{year_from.strip()}'"
+    if supports_advanced and year_to and year_to.strip():
+        params_str += f", year_to='{year_to.strip()}'"
+
+    try:
+        result = search_agent.run(
+            f"请使用 {tool_name} 工具搜索以下主题的论文,然后分析结果:{query}\n"
+            f"参数设置: {params_str}"
+        )
+        logger.add("文献检索", f"{source_name} 论文搜索", query, result)
+        return result
+    except Exception as e:
+        err = f"检索出错: {str(e)}"
+        logger.add("文献检索", f"{source_name} 搜索失败", query, err)
+        return err
+
+
+def summarize_paper(content):
+    """论文总结"""
+    if not content.strip():
+        return "请输入论文内容。"
+    try:
+        result = summary_agent.run(f"请对以下论文内容进行结构化总结:\n\n{content}")
+        logger.add("论文总结", "结构化总结", content, result)
+        return result
+    except Exception as e:
+        err = f"总结出错: {str(e)}"
+        logger.add("论文总结", "总结失败", content, err)
+        return err
+
+
+def generate_citation(title, authors, journal, year, volume, pages, doi, fmt):
+    """引用生成"""
+    if not title.strip() or not authors.strip():
+        return "请至少填写论文标题和作者。"
+    user_input = f"{title} | {authors} | {journal} | {year} | 格式: {fmt}"
+    try:
+        params = {
+            "title": title, "authors": authors,
+            "journal": journal, "year": year,
+            "volume": volume, "pages": pages, "doi": doi,
+            "format": fmt
+        }
+        resp = tool_registry.execute_tool("citation_generator", json.dumps(params))
+        logger.add("引用生成", f"{fmt} 格式引用", user_input, resp.text)
+        return resp.text
+    except Exception as e:
+        err = f"生成出错: {str(e)}"
+        logger.add("引用生成", "生成失败", user_input, err)
+        return err
+
+
+def polish_chat(message, history, session_id):
+    """论文润色对话 — 多轮交互,自动保存会话"""
+    if not message.strip():
+        return "", history, session_id, _polish_sessions_dropdown()
+
+    # 新会话自动生成 ID
+    if not session_id:
+        session_id = f"polish_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+
+    try:
+        context = ""
+        for msg in history:
+            role = "用户" if msg["role"] == "user" else "助手"
+            context += f"{role}: {msg['content']}\n"
+        context += f"用户: {message}\n助手: "
+
+        agent = create_polish_agent()
+        result = agent.run(context)
+        history.append({"role": "user", "content": message})
+        history.append({"role": "assistant", "content": result})
+
+        # 自动保存(用第一条用户消息做标题)
+        title = history[0]["content"][:50] if history else "新对话"
+        polish_sessions.save(session_id, history, title)
+        logger.add("论文润色(对话)", "多轮润色", message, result)
+        return "", history, session_id, _polish_sessions_dropdown()
+    except Exception as e:
+        err = f"润色出错: {str(e)}"
+        history.append({"role": "user", "content": message})
+        history.append({"role": "assistant", "content": err})
+        return "", history, session_id, _polish_sessions_dropdown()
+
+
+
+
+def outline_chat(message, history, session_id):
+    """大纲生成对话 — 多轮交互,自动保存会话"""
+    if not message.strip():
+        return "", history, session_id, _outline_sessions_dropdown()
+    if not session_id:
+        session_id = f"outline_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+    try:
+        context = ""
+        for msg in history:
+            role = "用户" if msg["role"] == "user" else "助手"
+            context += f"{role}: {msg['content']}\n"
+        context += f"用户: {message}\n助手: "
+        agent = create_outline_agent()
+        result = agent.run(context)
+        history.append({"role": "user", "content": message})
+        history.append({"role": "assistant", "content": result})
+        title = history[0]["content"][:50] if history else "新对话"
+        outline_sessions.save(session_id, history, title)
+        logger.add("大纲生成(对话)", "多轮大纲调整", message, result)
+        return "", history, session_id, _outline_sessions_dropdown()
+    except Exception as e:
+        err = f"生成出错: {str(e)}"
+        history.append({"role": "user", "content": message})
+        history.append({"role": "assistant", "content": err})
+        return "", history, session_id, _outline_sessions_dropdown()
+
+
+def _polish_choices():
+    sessions = polish_sessions.list_sessions()
+    return [(f"{t} ({u})", sid) for sid, t, u in sessions]
+
+def _outline_choices():
+    sessions = outline_sessions.list_sessions()
+    return [(f"{t} ({u})", sid) for sid, t, u in sessions]
+
+def _paper_choices():
+    sessions = paper_sessions.list_sessions()
+    return [(f"{t} ({u})", sid) for sid, t, u in sessions]
+
+def _polish_sessions_dropdown():
+    """润色会话列表 → gr.update"""
+    choices = _polish_choices()
+    return gr.update(choices=choices, value=None if not choices else choices[0][1])
+
+def _outline_sessions_dropdown():
+    """大纲会话列表 → gr.update"""
+    choices = _outline_choices()
+    return gr.update(choices=choices, value=None if not choices else choices[0][1])
+
+def _paper_sessions_dropdown():
+    """论文写作会话列表 → gr.update"""
+    choices = _paper_choices()
+    return gr.update(choices=choices, value=None if not choices else choices[0][1])
+
+def clear_polish_chat():
+    """重置润色对话"""
+    return "", [], "", _polish_sessions_dropdown()
+
+
+def clear_outline_chat():
+    """重置大纲对话"""
+    return "", [], "", _outline_sessions_dropdown()
+
+
+def load_polish_session(session_id):
+    """加载润色历史会话到 chatbot"""
+    if not session_id:
+        return [], session_id, _polish_sessions_dropdown()
+    try:
+        messages = polish_sessions.load(session_id)
+        return messages, session_id, _polish_sessions_dropdown()
+    except Exception:
+        return [], "", _polish_sessions_dropdown()
+
+
+def load_outline_session(session_id):
+    """加载大纲历史会话到 chatbot"""
+    if not session_id:
+        return [], session_id, _outline_sessions_dropdown()
+    try:
+        messages = outline_sessions.load(session_id)
+        return messages, session_id, _outline_sessions_dropdown()
+    except Exception:
+        return [], "", _outline_sessions_dropdown()
+
+def delete_polish_session(session_id):
+    """删除润色历史会话"""
+    if session_id:
+        polish_sessions.delete(session_id)
+    return [], "", _polish_sessions_dropdown()
+
+def delete_outline_session(session_id):
+    """删除大纲历史会话"""
+    if session_id:
+        outline_sessions.delete(session_id)
+    return [], "", _outline_sessions_dropdown()
+
+
+# ========================================
+# 论文写作回调(对话模式 + DOCX 下载)
+
+def paper_write_chat(message, history, session_id):
+    """论文写作对话 — 多轮交互,自动保存"""
+    if not message.strip():
+        return "", history, session_id, _paper_sessions_dropdown()
+    if not session_id:
+        session_id = f"paper_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+    try:
+        context = ""
+        for msg in history:
+            role = "用户" if msg["role"] == "user" else "助手"
+            context += f"{role}: {msg['content']}\n"
+        context += f"用户: {message}\n助手: "
+        agent = create_paper_writer_agent()
+        result = agent.run(context)
+        history.append({"role": "user", "content": message})
+        history.append({"role": "assistant", "content": result})
+        title = history[0]["content"][:50] if history else "新对话"
+        paper_sessions.save(session_id, history, title)
+        logger.add("论文写作(对话)", "多轮写作", message, result)
+        return "", history, session_id, _paper_sessions_dropdown()
+    except Exception as e:
+        err = f"写作出错: {str(e)}"
+        history.append({"role": "user", "content": message})
+        history.append({"role": "assistant", "content": err})
+        return "", history, session_id, _paper_sessions_dropdown()
+
+def load_paper_session(session_id):
+    """加载论文写作历史会话"""
+    if not session_id:
+        return [], session_id, _paper_sessions_dropdown()
+    try:
+        messages = paper_sessions.load(session_id)
+        return messages, session_id, _paper_sessions_dropdown()
+    except Exception:
+        return [], "", _paper_sessions_dropdown()
+
+def delete_paper_session(session_id):
+    """删除论文写作历史会话"""
+    if session_id:
+        paper_sessions.delete(session_id)
+    return [], "", _paper_sessions_dropdown()
+
+def clear_paper_chat():
+    """重置论文写作对话"""
+    return "", [], "", _paper_sessions_dropdown()
+
+def extract_pdf(pdf_file, max_chars):
+    """PDF 文本提取"""
+    if pdf_file is None:
+        return "请上传一个 PDF 文件。"
+    try:
+        resp = tool_registry.execute_tool("pdf_extract", json.dumps({
+            "file_path": pdf_file.name,
+            "max_chars": int(max_chars)
+        }))
+        logger.add("PDF 提取", "PDF 文本提取", f"文件: {pdf_file.name}", resp.text)
+        return resp.text
+    except Exception as e:
+        err = f"提取出错: {str(e)}"
+        logger.add("PDF 提取", "提取失败", f"文件: {pdf_file.name}", err)
+        return err
+
+
+def refresh_history():
+    """刷新对话记录显示"""
+    return logger.format_history_html()
+
+
+def delete_history_record(record_id):
+    """删除指定编号的记录(由 HTML 按钮触发)"""
+    if not record_id:
+        return logger.format_history_html()
+    try:
+        rid = int(record_id)
+        logger.delete_record(rid)
+        return logger.format_history_html()
+    except (ValueError, TypeError):
+        return logger.format_history_html()
+
+
+def clear_history():
+    """清空对话记录"""
+    msg = logger.clear()
+    return msg
+
+
+# ========================================
+# Gradio UI 布局
+# ========================================
+
+THEME = gr.themes.Soft(primary_hue="blue", secondary_hue="slate")
+
+with gr.Blocks(title="PaperAssistant - 智能论文助手") as demo:
+    gr.Markdown("""
+    # 🎓 PaperAssistant - 智能论文助手
+    ### 基于 HelloAgents 框架 + DeepSeek 的多智能体论文学术辅助工具
+    """)
+
+    with gr.Tab("📚 文献检索"):
+        with gr.Row():
+            with gr.Column(scale=3):
+                search_input = gr.Textbox(
+                    label="研究主题",
+                    placeholder="支持中英文关键词,如:气候变化对农业的影响、cancer immunotherapy...",
+                    lines=2
+                )
+                with gr.Row():
+                    search_source = gr.Dropdown(
+                        choices=[
+                            "Semantic Scholar(全学科推荐)",
+                            "AMiner(中文论文强项)",
+                            "OpenAlex(开放获取综合)",
+                            "PubMed(生物医学)",
+                            "CrossRef(期刊论文)",
+                            "arXiv(CS/数学/物理)"
+                        ],
+                        value="Semantic Scholar(全学科推荐)",
+                        label="数据源"
+                    )
+                    max_results = gr.Slider(1, 10, value=5, step=1, label="返回论文数")
+
+                # 高级筛选(仅 Semantic Scholar 支持)
+                with gr.Accordion("高级筛选", open=False):
+                    search_field = gr.Dropdown(
+                        choices=["全部领域"] + [
+                            "计算机科学", "人工智能", "医学", "生物学", "物理学", "化学",
+                            "数学", "经济学", "心理学", "社会学", "语言学", "哲学",
+                            "工程", "环境科学", "材料科学", "教育学", "法学", "商学"
+                        ],
+                        value="全部领域",
+                        label="学科领域"
+                    )
+                    with gr.Row():
+                        year_from = gr.Textbox(label="起始年份", placeholder="2020", scale=1)
+                        year_to = gr.Textbox(label="截止年份", placeholder="2025", scale=1)
+
+                search_btn = gr.Button("🔍 开始检索", variant="primary")
+            with gr.Column(scale=7):
+                search_output = gr.Markdown(label="检索结果", value="*等待搜索...*")
+        search_btn.click(
+            fn=search_papers,
+            inputs=[search_input, search_source, max_results, search_field, year_from, year_to],
+            outputs=search_output
+        )
+
+    with gr.Tab("📝 论文总结"):
+        with gr.Row():
+            with gr.Column(scale=4):
+                summary_input = gr.Textbox(
+                    label="论文内容(粘贴标题、作者、摘要等信息)",
+                    placeholder="粘贴论文信息,包括标题、作者、摘要、方法描述...",
+                    lines=15
+                )
+                summary_btn = gr.Button("📝 生成总结", variant="primary")
+            with gr.Column(scale=6):
+                summary_output = gr.Markdown(label="总结报告", value="*等待输入...*")
+        summary_btn.click(
+            fn=summarize_paper,
+            inputs=[summary_input],
+            outputs=summary_output
+        )
+
+    with gr.Tab("📎 引用生成"):
+        with gr.Row():
+            with gr.Column(scale=4):
+                cite_title = gr.Textbox(label="论文标题 *", placeholder="Attention Is All You Need")
+                cite_authors = gr.Textbox(label="作者 *", placeholder="Vaswani, A., Shazeer, N., Parmar, N., et al.")
+                with gr.Row():
+                    cite_journal = gr.Textbox(label="期刊/会议", placeholder="NeurIPS")
+                    cite_year = gr.Textbox(label="年份", placeholder="2017")
+                with gr.Row():
+                    cite_volume = gr.Textbox(label="卷号", placeholder="30")
+                    cite_pages = gr.Textbox(label="页码", placeholder="5998-6008")
+                cite_doi = gr.Textbox(label="DOI(可选)")
+                cite_format = gr.Radio(
+                    choices=["gbt7714", "apa", "mla"],
+                    value="gbt7714",
+                    label="引用格式"
+                )
+                cite_btn = gr.Button("📎 生成引用", variant="primary")
+            with gr.Column(scale=6):
+                cite_output = gr.Textbox(label="生成的引用", lines=8)
+        cite_btn.click(
+            fn=generate_citation,
+            inputs=[cite_title, cite_authors, cite_journal, cite_year,
+                    cite_volume, cite_pages, cite_doi, cite_format],
+            outputs=cite_output
+        )
+
+    with gr.Tab("✍️ 论文润色"):
+        # 当前会话 ID(隐藏)
+        polish_session_id = gr.State(value="")
+
+        # 历史会话面板
+        with gr.Accordion("📋 历史会话", open=False):
+            with gr.Row():
+                polish_history_list = gr.Dropdown(
+                    label="历史对话", choices=_polish_choices(), scale=6,
+                    info="选择一条历史会话后点击加载,可继续对话"
+                )
+                polish_load_btn = gr.Button("📂 加载", variant="primary", size="sm", scale=1)
+                polish_del_btn = gr.Button("🗑️ 删除", variant="stop", size="sm", scale=1)
+
+        # 对话区
+        gr.Markdown("粘贴文本后可以持续对话: 说'更正式一些'、'缩短第三段'等,我会记住上下文。")
+        polish_chatbot = gr.Chatbot(label="润色对话", height=450)
+        with gr.Row():
+            polish_msg = gr.Textbox(
+                label="输入修改要求",
+                placeholder="例如:请润色这段文字... / 把第二段改得更学术化...",
+                scale=7
+            )
+            polish_send = gr.Button("发送", variant="primary", scale=1)
+        polish_clear = gr.Button("🗑️ 开始新对话", size="sm", variant="stop")
+
+        # 事件绑定
+        polish_send.click(
+            fn=polish_chat,
+            inputs=[polish_msg, polish_chatbot, polish_session_id],
+            outputs=[polish_msg, polish_chatbot, polish_session_id, polish_history_list]
+        )
+        polish_msg.submit(
+            fn=polish_chat,
+            inputs=[polish_msg, polish_chatbot, polish_session_id],
+            outputs=[polish_msg, polish_chatbot, polish_session_id, polish_history_list]
+        )
+        polish_clear.click(
+            fn=clear_polish_chat,
+            outputs=[polish_msg, polish_chatbot, polish_session_id, polish_history_list]
+        )
+        polish_load_btn.click(
+            fn=load_polish_session,
+            inputs=[polish_history_list],
+            outputs=[polish_chatbot, polish_session_id, polish_history_list]
+        )
+        polish_del_btn.click(
+            fn=delete_polish_session,
+            inputs=[polish_history_list],
+            outputs=[polish_chatbot, polish_session_id, polish_history_list]
+        )
+
+    with gr.Tab("📊 大纲生成"):
+        # 当前会话 ID(隐藏)
+        outline_session_id = gr.State(value="")
+
+        # 历史会话面板
+        with gr.Accordion("📋 历史会话", open=False):
+            with gr.Row():
+                outline_history_list = gr.Dropdown(
+                    label="历史对话", choices=_outline_choices(), scale=6,
+                    info="选择一条历史会话后点击加载,可继续对话"
+                )
+                outline_load_btn = gr.Button("📂 加载", variant="primary", size="sm", scale=1)
+                outline_del_btn = gr.Button("🗑️ 删除", variant="stop", size="sm", scale=1)
+
+        # 对话区
+        gr.Markdown("输入论文主题后,可以持续对话优化: 说'细化第三章'、'增加实验对比章节'等,我会记住已有大纲并在此基础上修改。")
+        outline_chatbot = gr.Chatbot(label="大纲对话", height=450)
+        with gr.Row():
+            outline_msg = gr.Textbox(
+                label="输入要求",
+                placeholder="例如:我想写一篇关于XX的毕业论文,帮我生成大纲...",
+                scale=7
+            )
+            outline_send = gr.Button("发送", variant="primary", scale=1)
+        outline_clear = gr.Button("🗑️ 开始新对话", size="sm", variant="stop")
+
+        # 事件绑定
+        outline_send.click(
+            fn=outline_chat,
+            inputs=[outline_msg, outline_chatbot, outline_session_id],
+            outputs=[outline_msg, outline_chatbot, outline_session_id, outline_history_list]
+        )
+        outline_msg.submit(
+            fn=outline_chat,
+            inputs=[outline_msg, outline_chatbot, outline_session_id],
+            outputs=[outline_msg, outline_chatbot, outline_session_id, outline_history_list]
+        )
+        outline_clear.click(
+            fn=clear_outline_chat,
+            outputs=[outline_msg, outline_chatbot, outline_session_id, outline_history_list]
+        )
+        outline_load_btn.click(
+            fn=load_outline_session,
+            inputs=[outline_history_list],
+            outputs=[outline_chatbot, outline_session_id, outline_history_list]
+        )
+        outline_del_btn.click(
+            fn=delete_outline_session,
+            inputs=[outline_history_list],
+            outputs=[outline_chatbot, outline_session_id, outline_history_list]
+        )
+
+    with gr.Tab("📝 论文写作"):
+        paper_session_id = gr.State(value="")
+
+        with gr.Accordion("📋 历史会话", open=False):
+            with gr.Row():
+                paper_history_list = gr.Dropdown(
+                    label="历史对话", choices=_paper_choices(), scale=6,
+                    info="选择历史会话后加载,可继续写作"
+                )
+                paper_load_btn = gr.Button("📂 加载", variant="primary", size="sm", scale=1)
+                paper_del_btn = gr.Button("🗑️ 删除", variant="stop", size="sm", scale=1)
+
+        gr.Markdown("根据大纲逐章撰写论文。粘贴大纲后说'开始写第一章',可持续对话调整内容。")
+        paper_chatbot = gr.Chatbot(label="论文写作对话", height=450)
+        with gr.Row():
+            paper_msg = gr.Textbox(
+                label="输入写作要求",
+                placeholder="例如:以下是论文大纲...请从摘要开始撰写 / 写第三章实验部分 / 这部分再详细一些...",
+                scale=7
+            )
+            paper_send = gr.Button("发送", variant="primary", scale=1)
+        with gr.Row():
+            paper_clear = gr.Button("🗑️ 开始新对话", size="sm", variant="stop")
+
+        paper_send.click(
+            fn=paper_write_chat,
+            inputs=[paper_msg, paper_chatbot, paper_session_id],
+            outputs=[paper_msg, paper_chatbot, paper_session_id, paper_history_list]
+        )
+        paper_msg.submit(
+            fn=paper_write_chat,
+            inputs=[paper_msg, paper_chatbot, paper_session_id],
+            outputs=[paper_msg, paper_chatbot, paper_session_id, paper_history_list]
+        )
+        paper_clear.click(
+            fn=clear_paper_chat,
+            outputs=[paper_msg, paper_chatbot, paper_session_id, paper_history_list]
+        )
+        paper_load_btn.click(
+            fn=load_paper_session,
+            inputs=[paper_history_list],
+            outputs=[paper_chatbot, paper_session_id, paper_history_list]
+        )
+        paper_del_btn.click(
+            fn=delete_paper_session,
+            inputs=[paper_history_list],
+            outputs=[paper_chatbot, paper_session_id, paper_history_list]
+        )
+    with gr.Tab("📄 PDF → Markdown"):
+        gr.Markdown("上传 PDF 论文,自动识别标题、章节、段落,输出为格式化的 **Markdown** 文本。")
+        with gr.Row():
+            with gr.Column(scale=4):
+                pdf_input = gr.File(label="上传 PDF 文件", file_types=[".pdf"])
+                pdf_max_chars = gr.Slider(0, 100000, value=0, step=1000,
+                                           label="字符上限(0=不限制)")
+                pdf_btn = gr.Button("📄 转换为 Markdown", variant="primary")
+            with gr.Column(scale=6):
+                pdf_output = gr.Code(label="Markdown 输出", language="markdown", lines=20)
+        pdf_btn.click(
+            fn=extract_pdf,
+            inputs=[pdf_input, pdf_max_chars],
+            outputs=pdf_output
+        )
+
+    with gr.Tab("💬 对话记录"):
+        # 操作按钮(页面顶部)
+        with gr.Row():
+            refresh_btn = gr.Button("🔄 刷新", size="sm")
+            clear_btn = gr.Button("🗑️ 清空全部", size="sm", variant="stop")
+
+        # 隐藏触发组件:删除按钮通过 JS 填充此字段
+        delete_trigger = gr.Textbox(visible=False, elem_id="del_trigger")
+
+        # 历史展示(HTML 格式,每条带删除按钮)
+        history_display = gr.HTML(value=logger.format_history_html())
+
+        refresh_btn.click(fn=refresh_history, outputs=history_display)
+        clear_btn.click(fn=clear_history, outputs=history_display)
+        delete_trigger.change(
+            fn=delete_history_record,
+            inputs=[delete_trigger],
+            outputs=[history_display]
+        )
+
+    gr.Markdown("""
+    ---
+    ### 👤 作者: [@chengH425](https://github.com/chengH425) | 🙏 感谢 Datawhale 社区和 Hello-Agents 项目
+    """)
+
+
+if __name__ == "__main__":
+    demo.launch(server_name="127.0.0.1", server_port=7860, share=False, theme=THEME)

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 80 - 0
Co-creation-projects/chengH425-PaperAssistant/main.ipynb


BIN
Co-creation-projects/chengH425-PaperAssistant/outputs/paper_20260721_103418.docx


+ 15 - 0
Co-creation-projects/chengH425-PaperAssistant/requirements.txt

@@ -0,0 +1,15 @@
+# Core
+hello-agents>=1.0.0
+
+# Environment
+python-dotenv>=1.0.0
+
+# Web UI
+gradio>=4.0.0
+
+# PDF Processing
+PyPDF2>=3.0.0
+
+# Jupyter
+jupyterlab>=4.0.0
+ipykernel>=6.5.0

+ 15 - 0
Co-creation-projects/chengH425-PaperAssistant/src/__init__.py

@@ -0,0 +1,15 @@
+from .arxiv_tool import ArxivSearchTool
+from .pdf_tool import PDFExtractTool
+from .citation_tool import CitationTool
+from .literature_tool import LiteratureSearchTool
+from .pubmed_tool import PubMedSearchTool
+from .crossref_tool import CrossRefSearchTool
+from .openalex_tool import OpenAlexSearchTool
+from .aminer_tool import AminerSearchTool
+
+__all__ = [
+    "ArxivSearchTool", "PDFExtractTool", "CitationTool",
+    "LiteratureSearchTool", "PubMedSearchTool",
+    "CrossRefSearchTool", "OpenAlexSearchTool",
+    "AminerSearchTool"
+]

+ 191 - 0
Co-creation-projects/chengH425-PaperAssistant/src/aminer_tool.py

@@ -0,0 +1,191 @@
+"""
+AMiner 中文学术检索工具
+
+通过 AMiner API 检索中文学术论文,补充知网/万方无法免费接入的缺口。
+AMiner 由清华大学开发,覆盖 3.2 亿+ 论文和 1.3 亿+ 学者。
+
+注册地址: https://open.aminer.cn/
+"""
+import urllib.request
+import urllib.parse
+import urllib.error
+import json
+import os
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+
+class AminerSearchTool(Tool):
+    """AMiner 中文学术检索工具
+
+    通过 AMiner API 检索学术论文,特别擅长中文文献和中文作者。
+    覆盖 3.2 亿+ 论文,是 Semantic Scholar 的中文补充。
+    """
+
+    SEARCH_URL = "https://datacenter.aminer.cn/gateway/open_platform/api/paper/search"
+
+    def __init__(self):
+        super().__init__(
+            name="aminer_search",
+            description="通过 AMiner API 检索中英文学术论文。"
+                        "覆盖 3.2 亿+ 论文,擅长中文文献和中文作者搜索。"
+                        "当需要检索中文学术论文或中国学者的英文论文时使用此工具。"
+                        "需要先注册获取 API Key: https://open.aminer.cn/"
+        )
+
+    def _get_api_key(self) -> str:
+        """获取 AMiner API Key"""
+        key = os.getenv("AMINER_API_KEY", "")
+        if not key:
+            raise RuntimeError(
+                "未配置 AMiner API Key。请前往 https://open.aminer.cn/ 注册获取,"
+                "然后在 .env 中设置: AMINER_API_KEY=你的key"
+            )
+        return key
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        max_results = min(parameters.get("max_results", 5), 20)
+
+        if not keyword and not author:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message="请至少提供关键词(keyword)或作者(author)"
+            )
+
+        # AMiner 用 title 参数做关键词搜索
+        query = keyword or author
+        params = {
+            "title": query.strip(),
+            "page": "1",
+            "size": str(max_results)
+        }
+
+        url = f"{self.SEARCH_URL}?{urllib.parse.urlencode(params)}"
+
+        try:
+            api_key = self._get_api_key()
+            req = urllib.request.Request(url, headers={
+                "User-Agent": "PaperAssistant/1.0",
+                "Authorization": f"Bearer {api_key}",
+                "Accept": "application/json"
+            })
+
+            with urllib.request.urlopen(req, timeout=20) as resp:
+                data = json.loads(resp.read().decode("utf-8"))
+
+            code = data.get("code", -1)
+            if code != 200 and code != 0:
+                msg = data.get("msg", data.get("message", "未知错误"))
+                return ToolResponse.error(
+                    code="API_ERROR",
+                    message=f"AMiner API 返回错误 (code={code}): {msg}"
+                )
+
+            papers = data.get("data", [])
+            if isinstance(papers, dict):
+                papers = papers.get("list", papers.get("results", []))
+
+            total = data.get("total", len(papers))
+
+            if not papers:
+                return ToolResponse.success(
+                    text=f"在 AMiner 中未找到匹配的论文(共 {total} 条结果)。",
+                    data={"count": 0, "total": total, "papers": []}
+                )
+
+            # 格式化输出
+            lines = [f"在 AMiner 中找到 {len(papers)} 篇论文(共 {total} 条结果):\n"]
+            for i, paper in enumerate(papers, 1):
+                title = paper.get("title") or paper.get("name") or "N/A"
+                paper_id = paper.get("id") or paper.get("paper_id") or ""
+                doi = paper.get("doi") or ""
+                year = paper.get("year") or paper.get("pub_year") or "N/A"
+
+                # 作者
+                authors_raw = paper.get("authors") or paper.get("author") or []
+                if isinstance(authors_raw, list):
+                    author_names = []
+                    for a in authors_raw:
+                        if isinstance(a, dict):
+                            author_names.append(a.get("name", ""))
+                        elif isinstance(a, str):
+                            author_names.append(a)
+                    authors_str = ", ".join(author_names[:5])
+                    if len(authors_raw) > 5:
+                        authors_str += " et al."
+                elif isinstance(authors_raw, str):
+                    authors_str = authors_raw
+                else:
+                    authors_str = "N/A"
+
+                # 期刊/会议
+                venue = paper.get("venue") or paper.get("journal") or ""
+                if isinstance(venue, dict):
+                    venue = venue.get("name", "") or venue.get("raw", "")
+
+                # 引用次数
+                citations = paper.get("n_citation") or paper.get("citation_count") or 0
+
+                lines.append(f"### {i}. {title}")
+                if authors_str and authors_str != "N/A":
+                    lines.append(f"> 作者: {authors_str}")
+                lines.append(f"> 发表: {year} | {venue or 'N/A'}")
+                lines.append(f"> 引用: {citations} 次")
+                if doi:
+                    lines.append(f"> DOI: [{doi}](https://doi.org/{doi})")
+                if paper_id:
+                    lines.append(f"> AMiner ID: {paper_id}")
+                lines.append("")
+
+            return ToolResponse.success(
+                text="\n".join(lines),
+                data={
+                    "count": len(papers),
+                    "total": total,
+                    "source": "AMiner",
+                    "papers": [
+                        {
+                            "title": p.get("title", ""),
+                            "authors": p.get("authors", []),
+                            "year": p.get("year", ""),
+                            "doi": p.get("doi", ""),
+                            "venue": str(p.get("venue", "")),
+                        }
+                        for p in papers
+                    ]
+                }
+            )
+
+        except urllib.error.HTTPError as e:
+            if e.code == 401:
+                return ToolResponse.error(
+                    code="ACCESS_DENIED",
+                    message="AMiner API Key 无效或已过期。请检查 .env 中的 AMINER_API_KEY。"
+                )
+            return ToolResponse.error(
+                code="NETWORK_ERROR",
+                message=f"AMiner API 请求失败 (HTTP {e.code})"
+            )
+        except RuntimeError as e:
+            return ToolResponse.error(code="ACCESS_DENIED", message=str(e))
+        except Exception as e:
+            return ToolResponse.error(
+                code="INTERNAL_ERROR",
+                message=f"AMiner 检索出错: {str(e)}"
+            )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(name="keyword", type="string",
+                         description="搜索关键词,支持中文和英文",
+                         required=False),
+            ToolParameter(name="author", type="string",
+                         description="作者姓名,支持中文名和英文名",
+                         required=False),
+            ToolParameter(name="max_results", type="integer",
+                         description="最大返回结果数(默认5,最大20)",
+                         required=False),
+        ]

+ 181 - 0
Co-creation-projects/chengH425-PaperAssistant/src/arxiv_tool.py

@@ -0,0 +1,181 @@
+"""
+arXiv API 检索工具
+
+通过 arXiv 官方 API 检索学术论文,返回结构化结果。
+文档: https://info.arxiv.org/help/api/
+"""
+import urllib.request
+import urllib.parse
+import xml.etree.ElementTree as ET
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+
+class ArxivSearchTool(Tool):
+    """arXiv 学术论文检索工具
+
+    从 arXiv 数据库检索学术论文,支持关键词搜索、作者筛选、时间范围等条件。
+    返回论文的标题、作者、摘要、发表日期和 PDF 链接。
+    """
+
+    BASE_URL = "http://export.arxiv.org/api/query"
+
+    def __init__(self):
+        super().__init__(
+            name="arxiv_search",
+            description="在 arXiv 学术论文数据库中搜索论文。"
+                        "支持按关键词、作者、时间范围筛选。"
+                        "返回论文标题、作者、摘要、发表日期和链接。"
+                        "当需要查找最新的学术研究论文时使用此工具。"
+        )
+
+    def _build_query(self, parameters: Dict[str, Any]) -> str:
+        """构建 arXiv API 查询字符串"""
+        parts = []
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        category = parameters.get("category", "")
+
+        if keyword:
+            # 对关键词进行 URL 编码并构建查询
+            terms = [f"all:{t.strip()}" for t in keyword.split() if t.strip()]
+            parts.append("+AND+".join(terms))
+
+        if author:
+            parts.append(f'au:{author.replace(" ", "+")}')
+
+        if category:
+            # arXiv 分类如 cs.AI, cs.CL, stat.ML
+            parts.append(f"cat:{category.strip()}")
+
+        if not parts:
+            parts.append("all:machine+learning")  # 默认查询
+
+        return "+AND+".join(parts)
+
+    def _parse_atom_response(self, xml_text: str) -> List[Dict[str, Any]]:
+        """解析 arXiv API 返回的 Atom XML"""
+        ns = {
+            "atom": "http://www.w3.org/2005/Atom",
+            "arxiv": "http://arxiv.org/schemas/atom"
+        }
+
+        root = ET.fromstring(xml_text)
+        entries = root.findall("atom:entry", ns)
+
+        papers = []
+        for entry in entries:
+            title = entry.find("atom:title", ns)
+            authors = entry.findall("atom:author", ns)
+            summary = entry.find("atom:summary", ns)
+            published = entry.find("atom:published", ns)
+            link = None
+            for l in entry.findall("atom:link", ns):
+                if l.get("title") == "pdf" or l.get("type") == "application/pdf":
+                    link = l.get("href")
+                    break
+            if not link:
+                # 用 id 构造 arXiv 页面链接
+                paper_id = entry.find("atom:id", ns)
+                if paper_id is not None and paper_id.text:
+                    arxiv_id = paper_id.text.split("/abs/")[-1]
+                    link = f"https://arxiv.org/pdf/{arxiv_id}"
+
+            paper = {
+                "title": title.text.strip().replace("\n", " ") if title is not None and title.text else "N/A",
+                "authors": [a.find("atom:name", ns).text
+                           for a in authors if a.find("atom:name", ns) is not None],
+                "summary": summary.text.strip().replace("\n", " ")[:500]
+                          if summary is not None and summary.text else "N/A",
+                "published": published.text[:10] if published is not None and published.text else "N/A",
+                "pdf_url": link or "N/A"
+            }
+            papers.append(paper)
+
+        return papers
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        max_results = min(parameters.get("max_results", 5), 20)
+
+        if not keyword and not author:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message="请至少提供关键词(keyword)或作者(author)"
+            )
+
+        query = self._build_query(parameters)
+        url = f"{self.BASE_URL}?search_query={query}&max_results={max_results}&sortBy=relevance"
+
+        try:
+            req = urllib.request.Request(url, headers={"User-Agent": "PaperAssistant/1.0"})
+            with urllib.request.urlopen(req, timeout=15) as resp:
+                xml_data = resp.read().decode("utf-8")
+
+            papers = self._parse_atom_response(xml_data)
+
+            if not papers:
+                return ToolResponse.success(
+                    text="未找到匹配的论文,请尝试调整关键词。",
+                    data={"count": 0, "papers": []}
+                )
+
+            # 格式化输出
+            lines = [f"找到 {len(papers)} 篇论文:\n"]
+            for i, p in enumerate(papers, 1):
+                authors_str = ", ".join(p["authors"][:3])
+                if len(p["authors"]) > 3:
+                    authors_str += " et al."
+                lines.append(f"### {i}. {p['title']}")
+                lines.append(f"   作者: {authors_str}")
+                lines.append(f"   发表: {p['published']}")
+                lines.append(f"   摘要: {p['summary'][:300]}...")
+                lines.append(f"   PDF: {p['pdf_url']}")
+                lines.append("")
+
+            return ToolResponse.success(
+                text="\n".join(lines),
+                data={"count": len(papers), "papers": papers, "query": query}
+            )
+
+        except urllib.error.URLError as e:
+            return ToolResponse.error(
+                code="NETWORK_ERROR",
+                message=f"arXiv API 请求失败: {str(e)}"
+            )
+        except ET.ParseError as e:
+            return ToolResponse.error(
+                code="INVALID_FORMAT",
+                message=f"解析 arXiv 返回数据失败: {str(e)}"
+            )
+        except Exception as e:
+            return ToolResponse.error(
+                code="INTERNAL_ERROR",
+                message=f"检索过程出错: {str(e)}"
+            )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(
+                name="keyword", type="string",
+                description="搜索关键词,如 'large language model reasoning'",
+                required=False
+            ),
+            ToolParameter(
+                name="author", type="string",
+                description="作者姓名,如 'Geoffrey Hinton'",
+                required=False
+            ),
+            ToolParameter(
+                name="category", type="string",
+                description="arXiv 分类,如 cs.AI(人工智能) / cs.CL(计算语言学) / stat.ML(机器学习)",
+                required=False
+            ),
+            ToolParameter(
+                name="max_results", type="integer",
+                description="最大返回结果数(默认5,最多20)",
+                required=False
+            ),
+        ]

+ 102 - 0
Co-creation-projects/chengH425-PaperAssistant/src/citation_tool.py

@@ -0,0 +1,102 @@
+"""
+学术引用生成工具
+
+支持 GB/T 7714、APA 7th、MLA 9th 三种主流学术引用格式。
+"""
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+
+class CitationTool(Tool):
+    """学术引用生成工具
+
+    根据论文元数据生成指定格式的学术引用。
+    """
+
+    def __init__(self):
+        super().__init__(
+            name="citation_generator",
+            description="根据论文信息生成指定格式的学术引用。"
+                        "支持 GB/T 7714(中文期刊标准)、APA 第7版、MLA 第9版。"
+                        "当需要生成参考文献引用时使用此工具。"
+        )
+
+    def _format_authors(self, authors_str: str, format_type: str) -> str:
+        authors = [a.strip() for a in authors_str.split(",")]
+        if format_type == "gbt7714":
+            return ", ".join(authors)
+        elif format_type == "apa":
+            if len(authors) == 1:
+                return authors[0]
+            elif len(authors) == 2:
+                return f"{authors[0]}, & {authors[1]}"
+            else:
+                return ", ".join(authors[:-1]) + f", & {authors[-1]}"
+        elif format_type == "mla":
+            if len(authors) == 1:
+                return authors[0]
+            elif len(authors) == 2:
+                return f"{authors[0]}, and {authors[1]}"
+            else:
+                return f"{authors[0]}, et al"
+        return authors_str
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        title = parameters.get("title", "")
+        authors_str = parameters.get("authors", "")
+        journal = parameters.get("journal", "")
+        year = parameters.get("year", "")
+        volume = parameters.get("volume", "")
+        pages = parameters.get("pages", "")
+        doi = parameters.get("doi", "")
+        format_type = parameters.get("format", "gbt7714")
+
+        if not title or not authors_str:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message="标题和作者为必填项"
+            )
+
+        formatted_authors = self._format_authors(authors_str, format_type)
+
+        if format_type == "gbt7714":
+            citation = f"{formatted_authors}. {title}[J]. {journal}, {year}, {volume}: {pages}."
+        elif format_type == "apa":
+            citation = f"{formatted_authors} ({year}). {title}. {journal}, {volume}, {pages}."
+            if doi:
+                citation += f" https://doi.org/{doi}"
+        elif format_type == "mla":
+            citation = f'{formatted_authors}. "{title}." {journal}, vol. {volume}, {year}, pp. {pages}.'
+        else:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message=f"不支持的引用格式: {format_type},支持: gbt7714, apa, mla"
+            )
+
+        return ToolResponse.success(
+            text=citation,
+            data={"format": format_type, "citation": citation}
+        )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(name="title", type="string",
+                          description="论文标题", required=True),
+            ToolParameter(name="authors", type="string",
+                          description="作者列表,用逗号分隔",
+                          required=True),
+            ToolParameter(name="journal", type="string",
+                          description="期刊/会议名称", required=False),
+            ToolParameter(name="year", type="string",
+                          description="发表年份", required=False),
+            ToolParameter(name="volume", type="string",
+                          description="卷号", required=False),
+            ToolParameter(name="pages", type="string",
+                          description="页码", required=False),
+            ToolParameter(name="doi", type="string",
+                          description="DOI 号", required=False),
+            ToolParameter(name="format", type="string",
+                          description="引用格式:gbt7714 / apa / mla",
+                          required=False),
+        ]

+ 192 - 0
Co-creation-projects/chengH425-PaperAssistant/src/crossref_tool.py

@@ -0,0 +1,192 @@
+"""
+CrossRef 期刊论文检索工具
+
+通过 CrossRef REST API 检索已发表的学术期刊论文。
+CrossRef 是学术出版物的 DOI 注册机构,覆盖 1.5 亿+ 记录。
+
+API 文档: https://api.crossref.org/
+"""
+import urllib.request
+import urllib.parse
+import urllib.error
+import json
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+
+class CrossRefSearchTool(Tool):
+    """CrossRef 期刊论文检索工具
+
+    通过 CrossRef REST API 检索正式发表的期刊论文、会议论文、书籍等。
+    覆盖 1.5 亿+ 学术作品,拥有最完整的期刊论文元数据(DOI、ISSN、页码等)。
+    特别适合检索正式发表的期刊论文和获取引用元数据。
+    """
+
+    BASE_URL = "https://api.crossref.org/works"
+
+    def __init__(self):
+        super().__init__(
+            name="crossref_search",
+            description="通过 CrossRef API 检索正式发表的期刊论文和会议论文。"
+                        "覆盖 1.5 亿+ 记录,拥有最完整的引用元数据(DOI、期刊名、"
+                        "卷号、页码等)。特别适合按 DOI 查找论文或检索特定期刊的文献。"
+                        "当需要精确的引用信息时使用此工具。"
+        )
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        doi = parameters.get("doi", "")
+        journal = parameters.get("journal", "")
+        max_results = min(parameters.get("max_results", 5), 20)
+        year_from = parameters.get("year_from", "")
+        year_to = parameters.get("year_to", "")
+
+        # DOI 精确查询(最高效)
+        if doi:
+            url = f"{self.BASE_URL}/{urllib.parse.quote(doi.strip(), safe='')}"
+        else:
+            if not keyword and not author and not journal:
+                return ToolResponse.error(
+                    code="INVALID_PARAM",
+                    message="请提供关键词(keyword)、作者(author)、DOI(doi)或期刊名(journal)"
+                )
+
+            # 构建过滤条件
+            filters = []
+            if year_from or year_to:
+                f = f"from-pub-date:{year_from or '1900'}"
+                if year_to:
+                    f += f",until-pub-date:{year_to}"
+                filters.append(f)
+
+            # 查询字段
+            query_parts = []
+            if keyword:
+                query_parts.append(keyword.strip())
+            if author:
+                query_parts.append(author.strip())
+            if journal:
+                query_parts.append(journal.strip())
+
+            params = {
+                "query": " ".join(query_parts),
+                "rows": str(max_results),
+            }
+            if filters:
+                params["filter"] = ",".join(filters)
+
+            url = f"{self.BASE_URL}?{urllib.parse.urlencode(params)}"
+
+        try:
+            req = urllib.request.Request(
+                url,
+                headers={
+                    "User-Agent": "PaperAssistant/1.0 (mailto:1793636425@qq.com)",
+                    "Accept": "application/json"
+                }
+            )
+
+            with urllib.request.urlopen(req, timeout=20) as resp:
+                data = json.loads(resp.read().decode("utf-8"))
+
+            # 解析结果
+            if doi:
+                # 单篇论文查询
+                msg = data.get("message", {})
+                items = [msg] if msg else []
+                total = len(items)
+            else:
+                msg = data.get("message", {})
+                items = msg.get("items", [])
+                total = msg.get("total-results", 0)
+
+            if not items:
+                return ToolResponse.success(
+                    text=f"在 CrossRef 中未找到匹配的论文。"
+                         f"{' DOI 可能不正确。' if doi else ' 请尝试更换关键词。'}",
+                    data={"count": 0, "papers": []}
+                )
+
+            # 格式化输出
+            lines = [f"找到 {total} 篇论文(显示前 {len(items)} 篇):\n"]
+            for i, item in enumerate(items, 1):
+                title_list = item.get("title", ["N/A"])
+                title = title_list[0] if title_list else "N/A"
+
+                # 作者
+                authors = item.get("author", [])
+                author_names = []
+                for a in authors[:5]:
+                    given = a.get("given", "")
+                    family = a.get("family", "")
+                    if given or family:
+                        author_names.append(f"{family} {given}".strip())
+                authors_str = ", ".join(author_names)
+                if len(authors) > 5:
+                    authors_str += " et al."
+
+                # 发表信息
+                published = item.get("published-print", {}) or item.get("published-online", {})
+                pub_date = "-".join(str(v) for v in published.get("date-parts", [["?"]])[0]) if published else "N/A"
+
+                # 期刊
+                container = item.get("container-title", [])
+                venue = container[0] if container else item.get("publisher", "N/A")
+
+                # 引用次数
+                ref_count = item.get("is-referenced-by-count", 0)
+
+                item_doi = item.get("DOI", "")
+
+                lines.append(f"### {i}. {title}")
+                if authors_str:
+                    lines.append(f"> 作者: {authors_str}")
+                lines.append(f"> 发表: {pub_date} | {venue}")
+                lines.append(f"> 引用: {ref_count} 次")
+                if item_doi:
+                    lines.append(f"> DOI: [{item_doi}](https://doi.org/{item_doi})")
+                lines.append("")
+
+            lines.append(f"---")
+            lines.append(f"*数据来源: CrossRef API*")
+
+            return ToolResponse.success(
+                text="\n".join(lines),
+                data={"count": len(items), "total": total, "papers": items}
+            )
+
+        except urllib.error.HTTPError as e:
+            return ToolResponse.error(
+                code="NETWORK_ERROR",
+                message=f"CrossRef API 请求失败 (HTTP {e.code})"
+            )
+        except json.JSONDecodeError:
+            return ToolResponse.error(
+                code="INVALID_FORMAT",
+                message="解析 CrossRef 返回数据失败"
+            )
+        except Exception as e:
+            return ToolResponse.error(
+                code="INTERNAL_ERROR",
+                message=f"CrossRef 检索出错: {str(e)}"
+            )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(name="keyword", type="string",
+                         description="搜索关键词", required=False),
+            ToolParameter(name="author", type="string",
+                         description="作者姓名", required=False),
+            ToolParameter(name="doi", type="string",
+                         description="DOI 号码(精确查询,优先级最高)", required=False),
+            ToolParameter(name="journal", type="string",
+                         description="期刊名称", required=False),
+            ToolParameter(name="year_from", type="string",
+                         description="起始年份", required=False),
+            ToolParameter(name="year_to", type="string",
+                         description="截止年份", required=False),
+            ToolParameter(name="max_results", type="integer",
+                         description="最大返回结果数(默认5,最大20)", required=False),
+        ]

+ 341 - 0
Co-creation-projects/chengH425-PaperAssistant/src/literature_tool.py

@@ -0,0 +1,341 @@
+"""
+文献检索工具 — Semantic Scholar API
+
+覆盖 2 亿+ 学术论文,涵盖计算机科学、医学、生物学、物理学、化学、
+社会科学、经济学、人文艺术等全学科领域。
+
+API 文档: https://api.semanticscholar.org/api-docs/
+"""
+import urllib.request
+import urllib.parse
+import urllib.error
+import json
+import os
+import time
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+
+class LiteratureSearchTool(Tool):
+    """全学科文献检索工具
+
+    通过 Semantic Scholar API 在多学科数据库中检索学术论文。
+    覆盖 2 亿+ 论文,支持关键词、作者、年份、学科领域等筛选条件。
+    返回论文标题、作者、摘要、发表信息、引用次数、PDF 链接等。
+    """
+
+    BASE_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
+
+    # 请求的论文字段
+    FIELDS = [
+        "title", "abstract", "authors", "year", "venue",
+        "externalIds", "citationCount", "influentialCitationCount",
+        "openAccessPdf", "journal", "publicationTypes", "fieldsOfStudy"
+    ]
+
+    # 中文学科关键词映射
+    FIELD_ALIASES = {
+        "计算机科学": "Computer Science",
+        "人工智能": "Artificial Intelligence",
+        "机器学习": "Machine Learning",
+        "医学": "Medicine",
+        "生物学": "Biology",
+        "物理学": "Physics",
+        "化学": "Chemistry",
+        "数学": "Mathematics",
+        "经济学": "Economics",
+        "心理学": "Psychology",
+        "社会学": "Sociology",
+        "语言学": "Linguistics",
+        "哲学": "Philosophy",
+        "历史": "History",
+        "工程": "Engineering",
+        "环境科学": "Environmental Science",
+        "材料科学": "Materials Science",
+        "教育学": "Education",
+        "法学": "Law",
+        "政治学": "Political Science",
+        "商学": "Business",
+        "艺术": "Art",
+        "地理": "Geography",
+        "地质": "Geology",
+    }
+
+    def __init__(self):
+        super().__init__(
+            name="literature_search",
+            description="通过 Semantic Scholar 在全学科数据库中检索学术论文。"
+                        "覆盖 2 亿+ 论文,涵盖计算机科学、医学、生物、物理、化学、"
+                        "社会科学、经济学、人文等所有学术领域。"
+                        "支持按关键词、作者、年份范围、学科领域筛选。"
+                        "返回论文标题、作者、摘要、期刊、引用次数、PDF 链接等信息。"
+                        "当需要跨学科检索学术文献时使用此工具,比 arXiv 覆盖面更广。"
+        )
+
+    def _map_field(self, field_input: str) -> str:
+        """将中文/模糊学科名映射到 Semantic Scholar 领域"""
+        if not field_input:
+            return ""
+        field_input = field_input.strip()
+        # 直接匹配
+        for cn, en in self.FIELD_ALIASES.items():
+            if cn in field_input or field_input.lower() in cn.lower():
+                return en
+        # 已经是英文则直接返回
+        return field_input
+
+    def _build_url(self, parameters: Dict[str, Any]) -> str:
+        """构建 Semantic Scholar 搜索 URL"""
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        field = parameters.get("field", "")
+        year_from = parameters.get("year_from", "")
+        year_to = parameters.get("year_to", "")
+        limit = min(parameters.get("max_results", 5), 20)
+
+        # 构建查询字符串
+        query_parts = []
+        if keyword:
+            query_parts.append(keyword.strip())
+        if author:
+            query_parts.append(f'author:"{author.strip()}"')
+
+        query = " ".join(query_parts) if query_parts else "machine learning"
+
+        params = {
+            "query": query,
+            "limit": str(limit),
+            "fields": ",".join(self.FIELDS)
+        }
+
+        # 学科筛选
+        mapped_field = self._map_field(field) if field else ""
+        if mapped_field:
+            params["fieldsOfStudy"] = mapped_field
+
+        # 年份筛选
+        if year_from or year_to:
+            year_filter = f"{year_from or '1900'}-{year_to or '2026'}"
+            params["year"] = year_filter
+
+        return f"{self.BASE_URL}?{urllib.parse.urlencode(params)}"
+
+    def _format_paper(self, paper: Dict, index: int, keyword: str = "") -> str:
+        """格式化单篇论文为 Markdown"""
+        title = paper.get("title", "N/A")
+        year = paper.get("year", "N/A")
+        venue = paper.get("venue", "")
+        journal = paper.get("journal", {})
+        journal_name = journal.get("name", "") if journal else ""
+        publication_venue = venue or journal_name or "N/A"
+
+        # 作者列表
+        authors_list = paper.get("authors", [])
+        author_names = [a.get("name", "") for a in authors_list[:5]]
+        authors_str = ", ".join(author_names)
+        if len(authors_list) > 5:
+            authors_str += " et al."
+
+        # 摘要:优先取 TLDR,其次取 abstract
+        abstract = paper.get("abstract") or "暂无摘要"
+        if len(abstract) > 400:
+            abstract = abstract[:400] + "..."
+
+        # 引用次数
+        citations = paper.get("citationCount", 0)
+
+        # DOI
+        external_ids = paper.get("externalIds", {}) or {}
+        doi = external_ids.get("DOI", "")
+
+        # PDF 链接
+        open_access = paper.get("openAccessPdf", {}) or {}
+        pdf_url = open_access.get("url", "")
+        arxiv_id = external_ids.get("ArXiv", "")
+
+        # 领域标签
+        fields = paper.get("fieldsOfStudy", []) or []
+        fields_str = ", ".join(fields[:3]) if fields else ""
+
+        lines = [f"### {index}. {title}"]
+        if authors_str:
+            lines.append(f"> 作者: {authors_str}")
+        lines.append(f"> 发表: {year} | {publication_venue}")
+        if fields_str:
+            lines.append(f"> 领域: {fields_str}")
+        lines.append(f"> 引用: {citations} 次")
+
+        # 链接
+        links = []
+        if doi:
+            links.append(f"[DOI](https://doi.org/{doi})")
+        if pdf_url:
+            links.append(f"[PDF]({pdf_url})")
+        if arxiv_id:
+            links.append(f"[arXiv](https://arxiv.org/abs/{arxiv_id})")
+        if links:
+            lines.append(f"> {' | '.join(links)}")
+
+        lines.append(f">> {abstract}")
+        lines.append("")
+        return "\n".join(lines)
+
+    def _make_request(self, url: str, api_key: str, max_retries: int = 3) -> Dict:
+        """发送 API 请求,带指数退避重试"""
+        last_error = None
+        for attempt in range(max_retries):
+            try:
+                req = urllib.request.Request(
+                    url,
+                    headers={
+                        "User-Agent": "PaperAssistant/1.0",
+                        "Accept": "application/json"
+                    }
+                )
+                if api_key:
+                    req.add_header("x-api-key", api_key)
+
+                with urllib.request.urlopen(req, timeout=20) as resp:
+                    return json.loads(resp.read().decode("utf-8"))
+
+            except urllib.error.HTTPError as e:
+                if e.code == 429:
+                    # 速率限制:等待后重试
+                    wait = 2 ** (attempt + 1)  # 2s, 4s, 8s
+                    if attempt < max_retries - 1:
+                        time.sleep(wait)
+                        continue
+                    raise RuntimeError(
+                        "API 请求频率已达上限(429 Too Many Requests)。\n"
+                        "Semantic Scholar 免费额度为 100 次/5 分钟。\n"
+                        "请稍等 1-5 分钟后重试,或申请免费 API Key:\n"
+                        "https://www.semanticscholar.org/product/api\n"
+                        "获取后在 .env 中设置 SEMANTIC_SCHOLAR_API_KEY"
+                    ) from e
+                raise RuntimeError(
+                    f"Semantic Scholar API 返回 HTTP {e.code}: {e.reason}"
+                ) from e
+            except urllib.error.URLError as e:
+                last_error = e
+                if attempt < max_retries - 1:
+                    time.sleep(2 ** (attempt + 1))
+                    continue
+                raise RuntimeError(f"网络连接失败: {str(e.reason)}") from e
+
+        raise RuntimeError(f"请求失败(已重试 {max_retries} 次): {last_error}")
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        field = parameters.get("field", "")
+
+        if not keyword and not author:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message="请至少提供关键词(keyword)或作者(author)"
+            )
+
+        url = self._build_url(parameters)
+        api_key = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
+
+        try:
+            data = self._make_request(url, api_key)
+
+            papers = data.get("data", [])
+            total = data.get("total", 0)
+            offset = data.get("offset", 0)
+
+            if not papers:
+                # 尝试推荐相似的搜索词
+                suggestion = ""
+                if keyword:
+                    suggestion = f"\n\n建议:尝试更简短的关键词,或更换同义词。如将 '{keyword}' 改为更通用的表述。"
+                return ToolResponse.success(
+                    text=f"未找到匹配的论文(共 {total} 条结果)。{suggestion}",
+                    data={"count": 0, "total": total, "papers": []}
+                )
+
+            # 格式化输出
+            lines = [f"找到 {total} 篇论文(显示前 {len(papers)} 篇,偏移 {offset}):\n"]
+            for i, paper in enumerate(papers, 1):
+                lines.append(self._format_paper(paper, i, keyword))
+
+            lines.append(f"---")
+            lines.append(f"*本次检索共 {total} 篇结果。如需更多,请调整关键词或筛选条件。*")
+            if total > len(papers):
+                lines.append(f"*提示:可通过增加 max_results 获取更多结果(最大 20)。*")
+
+            return ToolResponse.success(
+                text="\n".join(lines),
+                data={
+                    "count": len(papers),
+                    "total": total,
+                    "offset": offset,
+                    "papers": [
+                        {
+                            "title": p.get("title"),
+                            "authors": [a.get("name") for a in p.get("authors", [])],
+                            "year": p.get("year"),
+                            "venue": p.get("venue", ""),
+                            "citationCount": p.get("citationCount", 0),
+                            "abstract": (p.get("abstract") or "")[:300],
+                            "doi": (p.get("externalIds") or {}).get("DOI", ""),
+                            "fieldsOfStudy": p.get("fieldsOfStudy", [])
+                        }
+                        for p in papers
+                    ]
+                }
+            )
+
+        except RuntimeError as e:
+            # _make_request 中已含重试逻辑,此处为最终失败
+            return ToolResponse.error(
+                code="API_ERROR",
+                message=f"[检索失败] {str(e)}\n\n"
+                        "请等待 1-2 分钟后重试。在此期间可使用其他数据源(OpenAlex、CrossRef、PubMed)。"
+            )
+        except json.JSONDecodeError:
+            return ToolResponse.error(
+                code="INVALID_FORMAT",
+                message="解析 API 返回数据失败,请稍后重试。"
+            )
+        except Exception as e:
+            return ToolResponse.error(
+                code="INTERNAL_ERROR",
+                message=f"检索过程出错: {str(e)}"
+            )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(
+                name="keyword", type="string",
+                description="搜索关键词,支持中英文。如 'transformer attention mechanism' 或 '深度学习 图像分割'",
+                required=False
+            ),
+            ToolParameter(
+                name="author", type="string",
+                description="作者姓名,如 'Geoffrey Hinton' 或 '何恺明'",
+                required=False
+            ),
+            ToolParameter(
+                name="field", type="string",
+                description="学科领域,支持中英文。如 '计算机科学'/'Computer Science'、'医学'/'Medicine'、'物理学'/'Physics'",
+                required=False
+            ),
+            ToolParameter(
+                name="year_from", type="string",
+                description="起始年份,如 '2020'",
+                required=False
+            ),
+            ToolParameter(
+                name="year_to", type="string",
+                description="截止年份,如 '2026'",
+                required=False
+            ),
+            ToolParameter(
+                name="max_results", type="integer",
+                description="最大返回结果数(默认5,最大20)",
+                required=False
+            ),
+        ]

+ 215 - 0
Co-creation-projects/chengH425-PaperAssistant/src/openalex_tool.py

@@ -0,0 +1,215 @@
+"""
+OpenAlex 开放学术资源检索工具
+
+OpenAlex 是一个完全开放、免费的学术文献索引。
+聚合了 CrossRef、PubMed、arXiv、DOAJ 等多个来源,覆盖 2.5 亿+ 学术作品。
+
+API 文档: https://docs.openalex.org/
+"""
+import urllib.request
+import urllib.parse
+import urllib.error
+import json
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+
+class OpenAlexSearchTool(Tool):
+    """OpenAlex 开放学术资源检索工具
+
+    通过 OpenAlex REST API 检索全球学术文献。
+    聚合多个数据源(CrossRef、PubMed、arXiv、DOAJ、ORCID 等),
+    覆盖 2.5 亿+ 作品、9000 万+ 作者、10 万+ 期刊/会议。
+    完全免费、无需 API Key,开放数据(CC0 协议)。
+    """
+
+    BASE_URL = "https://api.openalex.org/works"
+
+    def __init__(self):
+        super().__init__(
+            name="openalex_search",
+            description="通过 OpenAlex API 检索全球学术文献。"
+                        "覆盖 2.5 亿+ 作品,聚合多个数据源,完全免费无需 Key。"
+                        "支持按关键词、作者、机构、期刊、年份、开放获取状态等筛选。"
+                        "特别适合检索开放获取(OA)论文和跨数据库的综合检索。"
+        )
+
+    def _format_authorship(self, authorships: List[Dict]) -> str:
+        """格式化作者列表"""
+        names = []
+        for a in authorships[:5]:
+            author = a.get("author", {})
+            name = author.get("display_name", "")
+            if name:
+                names.append(name)
+        result = ", ".join(names)
+        if len(authorships) > 5:
+            result += " et al."
+        return result or "N/A"
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        institution = parameters.get("institution", "")
+        max_results = min(parameters.get("max_results", 5), 20)
+        year_from = parameters.get("year_from", "")
+        year_to = parameters.get("year_to", "")
+        open_access_only = parameters.get("open_access_only", False)
+
+        if not keyword and not author and not institution:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message="请至少提供关键词(keyword)、作者(author)或机构(institution)"
+            )
+
+        # 构建搜索参数
+        params: Dict[str, Any] = {
+            "per-page": str(max_results),
+            "sort": "cited_by_count:desc",
+        }
+
+        # 搜索关键词
+        search_terms = []
+        if keyword:
+            search_terms.append(keyword.strip())
+        if author:
+            search_terms.append(f"author.display_name.search:{author.strip()}")
+        if institution:
+            search_terms.append(f"authorships.institutions.display_name.search:{institution.strip()}")
+
+        if search_terms:
+            params["search"] = " ".join(search_terms)
+
+        # 年份筛选
+        if year_from:
+            params["filter"] = params.get("filter", "") + f"from_publication_date:{year_from}-01-01,"
+        if year_to:
+            params["filter"] = params.get("filter", "") + f"to_publication_date:{year_to}-12-31,"
+
+        # 开放获取筛选
+        if open_access_only:
+            params["filter"] = params.get("filter", "") + "is_oa:true,"
+
+        # 清理尾部逗号
+        if "filter" in params:
+            params["filter"] = params["filter"].rstrip(",")
+            if not params["filter"]:
+                del params["filter"]
+
+        url = f"{self.BASE_URL}?{urllib.parse.urlencode(params)}"
+
+        try:
+            req = urllib.request.Request(
+                url,
+                headers={
+                    "User-Agent": "PaperAssistant/1.0",
+                    "Accept": "application/json"
+                }
+            )
+
+            with urllib.request.urlopen(req, timeout=20) as resp:
+                data = json.loads(resp.read().decode("utf-8"))
+
+            results = data.get("results", [])
+            meta = data.get("meta", {})
+            total = meta.get("count", 0)
+
+            if not results:
+                return ToolResponse.success(
+                    text=f"在 OpenAlex 中未找到匹配的论文(共 {total} 条结果)。",
+                    data={"count": 0, "total": total, "papers": []}
+                )
+
+            # 格式化输出
+            lines = [f"找到 {total} 篇论文(显示前 {len(results)} 篇,按引用数排序):\n"]
+            for i, work in enumerate(results, 1):
+                title = work.get("display_name", work.get("title", "N/A"))
+
+                authors_str = self._format_authorship(work.get("authorships", []))
+
+                pub_date = work.get("publication_date", "N/A")
+
+                # 期刊/会议名
+                source = work.get("primary_location", {}) or {}
+                source_obj = source.get("source", {}) or {}
+                source_name = source_obj.get("display_name", "")
+                if not source_name:
+                    source_name = work.get("host_venue", {}).get("display_name", "N/A")
+
+                # 引用次数
+                citations = work.get("cited_by_count", 0)
+
+                # DOI
+                doi = work.get("doi", "")
+                doi_clean = doi.replace("https://doi.org/", "") if doi else ""
+
+                # OA 状态
+                oa = work.get("open_access", {}) or {}
+                is_oa = oa.get("is_oa", False)
+                oa_badge = "🔓" if is_oa else ""
+
+                # 类型
+                work_type = work.get("type", "").replace("-", " ").title()
+
+                lines.append(f"### {i}. {title} {oa_badge}")
+                lines.append(f"> 作者: {authors_str}")
+                lines.append(f"> 发表: {pub_date} | {source_name} | {work_type}")
+                lines.append(f"> 引用: {citations} 次")
+                if doi_clean:
+                    lines.append(f"> DOI: [{doi_clean}](https://doi.org/{doi_clean})")
+                if is_oa:
+                    lines.append(f"> 状态: 开放获取")
+                lines.append("")
+
+            lines.append(f"---")
+            lines.append(f"*数据来源: OpenAlex (CC0)* | *排序: 按引用数降序*")
+
+            return ToolResponse.success(
+                text="\n".join(lines),
+                data={
+                    "count": len(results),
+                    "total": total,
+                    "papers": [
+                        {
+                            "title": w.get("display_name"),
+                            "authors": [a.get("author", {}).get("display_name", "")
+                                      for a in w.get("authorships", [])],
+                            "year": w.get("publication_date", "")[:4],
+                            "doi": w.get("doi", ""),
+                            "cited_by": w.get("cited_by_count", 0),
+                            "is_oa": (w.get("open_access") or {}).get("is_oa", False),
+                        }
+                        for w in results
+                    ]
+                }
+            )
+
+        except urllib.error.HTTPError as e:
+            return ToolResponse.error(
+                code="NETWORK_ERROR",
+                message=f"OpenAlex API 请求失败 (HTTP {e.code})"
+            )
+        except Exception as e:
+            return ToolResponse.error(
+                code="INTERNAL_ERROR",
+                message=f"OpenAlex 检索出错: {str(e)}"
+            )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(name="keyword", type="string",
+                         description="搜索关键词,支持中英文", required=False),
+            ToolParameter(name="author", type="string",
+                         description="作者姓名", required=False),
+            ToolParameter(name="institution", type="string",
+                         description="机构名称,如 'Tsinghua University'", required=False),
+            ToolParameter(name="year_from", type="string",
+                         description="起始年份", required=False),
+            ToolParameter(name="year_to", type="string",
+                         description="截止年份", required=False),
+            ToolParameter(name="open_access_only", type="boolean",
+                         description="仅返回开放获取论文(True/False)", required=False),
+            ToolParameter(name="max_results", type="integer",
+                         description="最大返回结果数(默认5,最大20)", required=False),
+        ]

+ 292 - 0
Co-creation-projects/chengH425-PaperAssistant/src/pdf_tool.py

@@ -0,0 +1,292 @@
+"""
+PDF 转 Markdown 工具
+
+从 PDF 文件中提取文本并转换为结构化的 Markdown 格式。
+支持本地文件和 URL,适用于学术论文 PDF 的读取。
+"""
+import os
+import re
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+
+class PDFExtractTool(Tool):
+    """PDF 转 Markdown 工具
+
+    从 PDF 文件中提取文本内容,自动识别论文结构(标题、章节、段落),
+    并转换为格式化的 Markdown 输出。支持本地文件路径和 URL。
+    """
+
+    # 常见论文章节标题模式
+    SECTION_PATTERNS = [
+        r'^(abstract|摘要|Abstract)$',
+        r'^(introduction|引言|Introduction)$',
+        r'^(related\s*work|相关工作|Related\s*Work)$',
+        r'^(background|背景|Background)$',
+        r'^(method|方法|Method(ology|s)?)$',
+        r'^(experiment|实验|Experiment(s|al\s*setup)?)$',
+        r'^(result|结果|Result(s)?(\s*and\s*analysis)?)$',
+        r'^(discussion|讨论|Discussion)$',
+        r'^(conclusion|结论|Conclusion(\s*and\s*future\s*work)?)$',
+        r'^(reference|参考文献|Reference(s)?)$',
+        r'^(appendix|附录|Appendix)$',
+        r'^(evaluation|评估|Evaluation)$',
+        r'^(implementation|实现|Implementation)$',
+        r'^(limitation|局限|Limitation(s)?)$',
+    ]
+
+    def __init__(self):
+        super().__init__(
+            name="pdf_extract",
+            description="从 PDF 文件中提取文本并转换为 Markdown 格式。"
+                        "自动识别论文结构(标题、章节、段落),"
+                        "清理 PDF 断行和页码等噪声。"
+                        "支持本地 PDF 文件路径或 PDF URL。"
+                        "适合将论文 PDF 转为 Markdown 后用于进一步分析。"
+        )
+
+    def _extract_raw_text(self, file_path: str, start_page: int = 1,
+                           end_page: int = -1) -> str:
+        """使用 PyPDF2 提取原始文本"""
+        try:
+            from PyPDF2 import PdfReader
+        except ImportError:
+            raise ImportError("请安装 PyPDF2: pip install PyPDF2")
+
+        reader = PdfReader(file_path)
+        total_pages = len(reader.pages)
+
+        if end_page == -1 or end_page > total_pages:
+            end_page = total_pages
+
+        all_text = []
+        for i in range(start_page - 1, min(end_page, total_pages)):
+            page = reader.pages[i]
+            text = page.extract_text()
+            if text:
+                all_text.append(text)
+
+        if not all_text:
+            return ""
+
+        return "\n".join(all_text)
+
+    def _clean_text(self, text: str) -> str:
+        """清理 PDF 提取的噪声"""
+        # 移除独立的页码行
+        text = re.sub(r'^\d{1,4}$', '', text, flags=re.MULTILINE)
+        # 移除页眉页脚常见模式(如 "作者名 / 期刊名" 跨页重复)
+        text = re.sub(r'^\d+\s*\n', '\n', text, flags=re.MULTILINE)
+        # 合并多余的连续空行
+        text = re.sub(r'\n{4,}', '\n\n\n', text)
+        # 清理尾部空格
+        text = re.sub(r'[ \t]+$', '', text, flags=re.MULTILINE)
+        # 移除零宽字符
+        text = re.sub(r'[​‌‍]', '', text)
+        return text.strip()
+
+    def _fix_broken_lines(self, text: str) -> str:
+        """修复 PDF 提取中常见的断行问题。
+
+        PDF 提取经常在段落中间产生不必要的换行。
+        将不以标点/冒号结尾且下一行以小写字母开头的行合并。
+        """
+        lines = text.split('\n')
+        fixed = []
+        i = 0
+        while i < len(lines):
+            line = lines[i].strip()
+            if not line:
+                fixed.append('')
+                i += 1
+                continue
+
+            # 如果当前行不以句号/问号/感叹号/冒号/引号结尾,
+            # 且下一行存在且不以大写字母、数字编号或空行开头 → 合并
+            if (i + 1 < len(lines) and
+                not re.search(r'[.!?:\"»)]$', line) and
+                len(line) > 20 and  # 短行(标题)不合并
+                lines[i + 1].strip() and
+                not re.match(r'^[A-Z0-9#]', lines[i + 1].strip()) and
+                not re.match(r'^\[', lines[i + 1].strip())):
+
+                fixed.append(line + ' ' + lines[i + 1].strip())
+                i += 2
+            else:
+                fixed.append(line)
+                i += 1
+
+        return '\n'.join(fixed)
+
+    def _to_markdown(self, text: str) -> str:
+        """将清理后的文本转换为 Markdown"""
+        lines = text.split('\n')
+        md_lines = []
+        in_code_block = False
+
+        for line in lines:
+            stripped = line.strip()
+
+            if not stripped:
+                # 空行 = 段落分隔
+                md_lines.append('')
+                continue
+
+            # 跳过纯页码和短数字行
+            if re.match(r'^\d{1,4}$', stripped):
+                continue
+
+            # 检测编号章节标题:1. / 1.1 / 2.3.1 等
+            numbered_heading = re.match(
+                r'^(\d{1,2}(?:\.\d{1,2}){0,2})\s+(.+)', stripped
+            )
+            if numbered_heading and len(stripped) < 80:
+                depth = numbered_heading.group(1).count('.') + 1
+                prefix = '#' * min(depth + 1, 4)  # 最多 ####
+                md_lines.append(f'\n{prefix} {stripped}')
+                continue
+
+            # 检测常见学术章节标题
+            is_section = False
+            for pattern in self.SECTION_PATTERNS:
+                if re.match(pattern, stripped, re.IGNORECASE):
+                    is_section = True
+                    break
+            if is_section and len(stripped) < 60:
+                md_lines.append(f'\n## {stripped}')
+                continue
+
+            # 检测全大写短行 → 很可能是标题
+            if (stripped.isupper() and len(stripped) < 60 and
+                len(stripped.split()) >= 2):
+                md_lines.append(f'\n### {stripped.title()}')
+                continue
+
+            # 检测列表项
+            list_match = re.match(r'^[\-\•\*\d+]\s{1,3}', stripped)
+            if list_match:
+                md_lines.append(f'- {stripped[list_match.end():]}')
+                continue
+
+            # 普通段落
+            md_lines.append(stripped)
+
+        # 合并结果
+        result = '\n'.join(md_lines)
+        # 清理多余空行
+        result = re.sub(r'\n{3,}', '\n\n', result)
+        # 确保标题前后有空行
+        result = re.sub(r'([^\n])\n(#{1,4}\s)', r'\1\n\n\2', result)
+        return result.strip()
+
+    def _download_pdf(self, url: str, save_dir: str = "outputs") -> str:
+        """下载远程 PDF 文件"""
+        import urllib.request
+
+        os.makedirs(save_dir, exist_ok=True)
+        filename = os.path.join(save_dir, f"downloaded_{abs(hash(url))}.pdf")
+
+        req = urllib.request.Request(url, headers={"User-Agent": "PaperAssistant/1.0"})
+        with urllib.request.urlopen(req, timeout=30) as resp:
+            with open(filename, "wb") as f:
+                f.write(resp.read())
+
+        return filename
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        file_path = parameters.get("file_path", "")
+        url = parameters.get("url", "")
+        start_page = parameters.get("start_page", 1)
+        end_page = parameters.get("end_page", -1)
+        max_chars = parameters.get("max_chars", 0) or 0  # 0 = 不限制
+
+        if not file_path and not url:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message="请提供 file_path(本地文件路径)或 url(PDF 链接)"
+            )
+
+        try:
+            if url:
+                file_path = self._download_pdf(url)
+
+            if not os.path.exists(file_path):
+                return ToolResponse.error(
+                    code="NOT_FOUND",
+                    message=f"文件不存在: {file_path}"
+                )
+
+            # 1. 提取原始文本
+            raw_text = self._extract_raw_text(file_path, start_page, end_page)
+
+            if not raw_text:
+                return ToolResponse.error(
+                    code="INVALID_FORMAT",
+                    message="未能从 PDF 中提取到文本。可能是扫描版 PDF(图片格式),建议使用 OCR 工具预处理。"
+                )
+
+            # 2. 清洗 → 3. 修复断行 → 4. 转 Markdown
+            cleaned = self._clean_text(raw_text)
+            fixed = self._fix_broken_lines(cleaned)
+            markdown = self._to_markdown(fixed)
+
+            # 可选截断(max_chars=0 时不限制)
+            truncated = max_chars > 0 and len(markdown) > max_chars
+            if truncated:
+                markdown = markdown[:max_chars]
+                last_break = max(markdown.rfind('\n\n'), markdown.rfind('\n'))
+                if last_break > max_chars * 0.8:
+                    markdown = markdown[:last_break]
+                markdown += f"\n\n> *内容已截断(共显示前 {max_chars} 字符)。设为 0 可获取全文。*"
+
+            stats = {
+                "total_chars": len(markdown),
+                "word_count": len(markdown.split()),
+                "line_count": len(markdown.split("\n")),
+                "pages": f"{start_page}-{end_page if end_page != -1 else '全部'}",
+                "truncated": truncated,
+                "format": "markdown"
+            }
+
+            return ToolResponse.success(text=markdown, data=stats)
+
+        except ImportError as e:
+            return ToolResponse.error(
+                code="INTERNAL_ERROR",
+                message=str(e)
+            )
+        except Exception as e:
+            return ToolResponse.error(
+                code="EXECUTION_ERROR",
+                message=f"PDF 转 Markdown 失败: {str(e)}"
+            )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(
+                name="file_path", type="string",
+                description="PDF 文件本地路径",
+                required=False
+            ),
+            ToolParameter(
+                name="url", type="string",
+                description="PDF 文件 URL(如 arXiv 论文链接)",
+                required=False
+            ),
+            ToolParameter(
+                name="start_page", type="integer",
+                description="起始页码(默认 1)",
+                required=False
+            ),
+            ToolParameter(
+                name="end_page", type="integer",
+                description="结束页码(-1 表示全部)",
+                required=False
+            ),
+            ToolParameter(
+                name="max_chars", type="integer",
+                description="最大返回字符数(0=不限制,默认 0)",
+                required=False
+            ),
+        ]

+ 203 - 0
Co-creation-projects/chengH425-PaperAssistant/src/pubmed_tool.py

@@ -0,0 +1,203 @@
+"""
+PubMed 生物医学文献检索工具
+
+通过 NCBI Entrez API (E-utilities) 检索 PubMed 数据库中的生物医学论文。
+覆盖 3600 万+ 论文,是生物医学领域最权威的数据库。
+
+API 文档: https://www.ncbi.nlm.nih.gov/books/NBK25501/
+"""
+import urllib.request
+import urllib.parse
+import urllib.error
+import ssl
+import xml.etree.ElementTree as ET
+from typing import Dict, Any, List
+
+from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
+
+# Windows SSL 兼容
+_ssl_ctx = ssl.create_default_context()
+_ssl_ctx.check_hostname = False
+_ssl_ctx.verify_mode = ssl.CERT_NONE
+
+
+class PubMedSearchTool(Tool):
+    """PubMed 生物医学文献检索工具
+
+    通过 NCBI Entrez API 检索 PubMed/PMC 数据库。
+    覆盖医学、生物学、药学、护理学、公共卫生等生物医学全领域。
+    """
+
+    SEARCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
+    FETCH_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
+    SUMMARY_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi"
+
+    def __init__(self):
+        super().__init__(
+            name="pubmed_search",
+            description="在 PubMed 数据库中检索生物医学论文。"
+                        "覆盖 3600 万+ 论文,涵盖医学、生物学、药学、护理学、"
+                        "公共卫生等所有生物医学领域。"
+                        "支持 MeSH 主题词搜索、作者、期刊、年份等筛选。"
+                        "适合医学研究、药物研发、临床实践等场景。"
+        )
+
+    def _search_pmids(self, query: str, max_results: int = 5,
+                       year_from: str = "", year_to: str = "") -> List[str]:
+        """搜索返回 PMID 列表"""
+        # 构建查询条件
+        search_terms = [query.strip()]
+        if year_from or year_to:
+            from_year = year_from or "1900"
+            to_year = year_to or "2026"
+            search_terms.append(f"{from_year}:{to_year}[dp]")
+
+        full_query = " AND ".join(search_terms)
+
+        params = {
+            "db": "pubmed",
+            "term": full_query,
+            "retmax": str(max_results),
+            "retmode": "xml",
+            "sort": "relevance"
+        }
+
+        url = f"{self.SEARCH_URL}?{urllib.parse.urlencode(params)}"
+        req = urllib.request.Request(url, headers={"User-Agent": "PaperAssistant/1.0"})
+
+        with urllib.request.urlopen(req, timeout=15, context=_ssl_ctx) as resp:
+            root = ET.fromstring(resp.read().decode("utf-8"))
+            id_list = root.find(".//IdList")
+            if id_list is None:
+                return []
+            return [elem.text for elem in id_list.findall("Id")]
+
+    def _fetch_summaries(self, pmids: List[str]) -> List[Dict[str, Any]]:
+        """获取论文摘要信息"""
+        if not pmids:
+            return []
+
+        params = {
+            "db": "pubmed",
+            "id": ",".join(pmids),
+            "retmode": "xml"
+        }
+
+        url = f"{self.SUMMARY_URL}?{urllib.parse.urlencode(params)}"
+        req = urllib.request.Request(url, headers={"User-Agent": "PaperAssistant/1.0"})
+
+        with urllib.request.urlopen(req, timeout=15, context=_ssl_ctx) as resp:
+            root = ET.fromstring(resp.read().decode("utf-8"))
+
+        papers = []
+        for doc in root.findall(".//DocSum"):
+            paper = {
+                "pmid": doc.find("Id").text if doc.find("Id") is not None else "",
+                "title": "N/A",
+                "authors": [],
+                "pubdate": "N/A",
+                "source": "N/A",
+                "doi": "",
+            }
+
+            for item in doc.findall("Item"):
+                name = item.get("Name", "")
+                if name == "Title":
+                    paper["title"] = item.text or "N/A"
+                elif name == "AuthorList":
+                    paper["authors"] = [a.text for a in item.findall("Item")
+                                       if a.text]
+                elif name == "PubDate":
+                    paper["pubdate"] = item.text or "N/A"
+                elif name == "Source":
+                    paper["source"] = item.text or "N/A"
+                elif name == "DOI":
+                    paper["doi"] = item.text or ""
+
+            papers.append(paper)
+
+        return papers
+
+    def run(self, parameters: Dict[str, Any]) -> ToolResponse:
+        keyword = parameters.get("keyword", "")
+        author = parameters.get("author", "")
+        max_results = min(parameters.get("max_results", 5), 20)
+        year_from = parameters.get("year_from", "")
+        year_to = parameters.get("year_to", "")
+
+        if not keyword and not author:
+            return ToolResponse.error(
+                code="INVALID_PARAM",
+                message="请至少提供关键词(keyword)或作者(author)"
+            )
+
+        # 构建查询
+        query_parts = []
+        if keyword:
+            query_parts.append(keyword.strip())
+        if author:
+            query_parts.append(f'{author.strip()}[Author]')
+        query = " AND ".join(query_parts)
+
+        try:
+            pmids = self._search_pmids(query, max_results, year_from, year_to)
+
+            if not pmids:
+                return ToolResponse.success(
+                    text=f"在 PubMed 中未找到匹配的论文。\n"
+                         f"建议:尝试更简短的关键词、使用 MeSH 主题词、"
+                         f"或检查拼写。查询: {query}",
+                    data={"count": 0, "papers": []}
+                )
+
+            papers = self._fetch_summaries(pmids)
+
+            # 格式化输出
+            lines = [f"在 PubMed 中找到 {len(papers)} 篇论文:\n"]
+            for i, p in enumerate(papers, 1):
+                authors_str = ", ".join(p["authors"][:3])
+                if len(p["authors"]) > 3:
+                    authors_str += " et al."
+                lines.append(f"### {i}. {p['title']}")
+                if authors_str:
+                    lines.append(f"> 作者: {authors_str}")
+                lines.append(f"> PMID: {p['pmid']} | 发表: {p['pubdate']} | {p['source']}")
+                if p.get("doi"):
+                    lines.append(f"> [DOI](https://doi.org/{p['doi']}) | "
+                                f"[PubMed](https://pubmed.ncbi.nlm.nih.gov/{p['pmid']}/)")
+                lines.append("")
+
+            lines.append(f"---")
+            lines.append(f"*数据来源: PubMed/NCBI*")
+
+            return ToolResponse.success(
+                text="\n".join(lines),
+                data={"count": len(papers), "papers": papers, "query": query}
+            )
+
+        except urllib.error.HTTPError as e:
+            return ToolResponse.error(
+                code="NETWORK_ERROR",
+                message=f"PubMed API 请求失败 (HTTP {e.code})"
+            )
+        except Exception as e:
+            return ToolResponse.error(
+                code="INTERNAL_ERROR",
+                message=f"PubMed 检索出错: {str(e)}"
+            )
+
+    def get_parameters(self) -> List[ToolParameter]:
+        return [
+            ToolParameter(name="keyword", type="string",
+                         description="搜索关键词,支持 MeSH 主题词,如 'diabetes treatment metformin'",
+                         required=False),
+            ToolParameter(name="author", type="string",
+                         description="作者姓名,如 'Anthony Fauci'",
+                         required=False),
+            ToolParameter(name="year_from", type="string",
+                         description="起始年份", required=False),
+            ToolParameter(name="year_to", type="string",
+                         description="截止年份", required=False),
+            ToolParameter(name="max_results", type="integer",
+                         description="最大返回结果数(默认5,最大20)", required=False),
+        ]

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.